NiDynDDR.sys is the packet-capture driver for the Observer RMON probe. Network Instruments wrote it. JDSU acquired them in January 2014, and the company became Viavi in August 2015. Observer is still shipping. Build 17.0.7.0, signed by Network Instruments, LLC, is inside the signed ObserverSetup.exe 17.0.7.0 sample on VirusTotal. It loads as a legacy kernel service, with no extra hardware.
On Windows 10 21H2, three IOCTLs aim an indirect kernel jump at an address the caller picks. An IOCTL is the 32-bit code passed to DeviceIoControl. A ret already in the driver returned cleanly. The marker 0x0000414141414141 showed up in the jump slot and on the crash stack.
Viavi was notified on April 17, 2026, and has not replied. The driver is not in LOLDrivers or on Microsoft’s recommended driver blocklist. Older VirusTotal copies show the same dispatch shape back to a 2003 x86 build.
The driver
| Field | Value |
|---|---|
| File | NiDynDDR.sys |
| Vendor | Network Instruments, LLC (acquired by JDSU in 2014; JDSU became Viavi in 2015) |
| Product | NiDynDDR Device Driver for NI RMON Probe |
| Version | 17.0.7.0 x64 |
| SHA-256 | 71e69b87ef086cb9660eb017bf63bb2a3962cb3361b41b34afc06f11c8e2e8a1 |
| Size | 133,352 bytes |
| Device | \\.\NiDynDDR |
| Source | VirusTotal ObserverSetup.exe 17.0.7.0 (79348f5d…22e21a93) |
| Compile | 2015-02-12 UTC |
DriverEntry creates \Device\NiDynDDR with a plain IoCreateDevice. There is no security descriptor and no IoCreateDeviceSecure. An administrator has to load the driver. After that, opening the device does not require elevation. The open that worked asked for MAXIMUM_ALLOWED. GENERIC_READ | GENERIC_WRITE hung.
RtlInitUnicodeString(&DestinationString, L"\\Device\\NiDynDDR");
IoCreateDevice(DriverObject, 0x50, &DestinationString, 0x9C40, 0, 0, &DeviceObject);
RtlInitUnicodeString(&SymbolicLinkName, L"\\DosDevices\\NiDynDDR");
IoCreateSymbolicLink(&SymbolicLinkName, &DestinationString);
Load it as a kernel service:
sc create NiDynDDR type= kernel binPath= C:\Windows\System32\drivers\NiDynDDR.sys
sc start NiDynDDR
IOCTL dispatch
Most drivers switch on the IOCTL code and call a matching handler. This one turns the code into an array index instead: IRP_MJ_DEVICE_CONTROL lands in sub_231E0 and jumps through a global table of function pointers:
shr ecx, 2
and ecx, 1FFh
cmp ecx, 200h
jnb fail ; dead: ecx is already 0..511
call sub_23A30
The mask keeps 9 bits, so the index is 0 through 511 and cmp ecx, 200h never fires. In this build the populated pointers are slots 8 through 45. The IOCTL code selects the index.
The trampoline is sub_23A30:
__int64 sub_23A30(int a1, unsigned int a2, unsigned int a3, __int64 a4)
{
return qword_2D5D0[a1 - 1](a2, a3, a4);
}
In the binary that is 4A FF 24 D0, jmp qword ptr [rax+r10*8], at NiDynDDR+0x13A53. rax is the table base and r10 is index - 1, so the QWORD in that slot is the next address the CPU executes.
The caller chooses the index, including slots past that populated range. The table is writable data, so a QWORD written into one of those slots is where the next matching IOCTL jumps.
Adjacent tables
The sample was analyzed at image base 0x10000, so subtract that base to get a runtime RVA. The globals that matter:
| Symbol (RVA) | What it is |
|---|---|
qword_2D5D00x1D5D0 | Primary dispatch table |
qword_2D6300x1D630 | Secondary table (overlaps the primary, +12 QWORDs) |
qword_2D7B80x1D7B8 | Adjacent global. Primary-relative entry 61, past populated slots 8–45 |
qword_2E3A00x1E3A0 | Context table, 64 slots. Registration does not bounds-check the slot |
From those RVAs:
0x1E3A0 - 0x1D5D0 = 0xDD0 = 442 QWORDs
qword_2E3A0[0] is qword_2D5D0[442] // context slot 0
0x1D7B8 - 0x1D5D0 = 0x1E8 = 61 QWORDs
qword_2D7B8 is qword_2D5D0[61] // adjacent global
Entry 61 is past the populated handlers. qword_2D7B8 is the global at that address. The write later in this post stores a pointer there, and the out-of-range lookup jumps through it.
IOCTL 1: register a context
Both paths below start by registering a context. IOCTL 0x9C40E400 is special-cased in sub_231E0, before the trampoline. It is METHOD_BUFFERED and requires read and write access.
cmp ecx, 9C40E400h
jz register_path
...
cmp dword ptr [rax], 89ABCDEFh ; SystemBuffer+0
jnz fail
call sub_22850 ; NonPagedPool 0x2C30, tag NidY
The registration buffer is 512 bytes. Offset 0 is the magic value 0x89ABCDEF. Offset 4 is the slot. The handler allocates 0x2C30 bytes from NonPagedPool with tag NidY and stores the pointer at qword_2E3A0[slot]. The slot is not checked against the 64 entries the lookup helper uses. The test used slot 0.
A second jump, into the pool
IOCTL 0x9C40071C is a different slot from the one the test registered:
(0x9C40071C >> 2) & 0x1FF = 455
sub_23A30(455) reads qword_2D5D0[454]
0x1D5D0 + 454 * 8 = 0x1E400
0x1E400 - 0x1E3A0 = 0x60 = 12 QWORDs
entry 454 is qword_2E3A0[12], not qword_2E3A0[0]
Index 455 reads qword_2D5D0[454], which is qword_2E3A0[12]. Context slot 0 is entry 442. The test registered slot 0 and took the three-IOCTL path below, through qword_2D7B8. Legacy NonPagedPool can still be executable on Windows 10 and 11, so a jump to that allocation runs the bytes there when the pages are executable.
The 3-IOCTL chain: a caller-chosen target
This chain writes a chosen QWORD to qword_2D7B8, then dispatches through it as qword_2D5D0[61]. The tests pointed that QWORD at a ret in the driver, and at the marker 0x0000414141414141.
IOCTL 2: plant the pointer
IOCTL 0x9C400024 is index 9. Slot 8 of the table is sub_23930, a second dispatcher sitting behind that one control code:
(0x9C400024 >> 2) & 0x1FF = 9
qword_2D5D0[8] = sub_23930
sub_23930 reads two WORDs from the input buffer. Offset 0 is a secondary index into qword_2D630. Offset 2 is the context slot. The index has to fall in [1, 33]. Index 32 is sub_245B0 at qword_2D630[32]. The allow mask still accepts index 32 when the context lookup returns NULL, and sub_245B0 ignores that argument. The test registered slot 0.
sub_245B0 takes the write path only when its third argument is 42. Disassembly shows that argument is InputBufferLength - 2, so the input on this path is 44 bytes.
if (a3 == 42)
{
*a4 = sub_27BF0(a2); // a2 = SystemBuffer + 4
*a6 = 4;
}
Decompilation of sub_23930 shows v7 = (_DWORD)v3 + 4, which looks like a 32-bit truncation. The instruction is a 64-bit lea, so the source is the full SystemBuffer + 4 pointer. The plant and trigger IOCTLs are METHOD_BUFFERED, and the handler sees a kernel buffer.
sub_27BF0 does the store. After two guards, it copies QWORDs from the buffer onto adjacent globals, including qword_2D7B8:
if (!*(DWORD *)(a1 + 32)) return 0;
if (!*(DWORD *)(a1 + 24)) return 0;
qword_2D7B8 = *(_QWORD *)a1; // primary-relative entry 61
qword_2D7C0 = *(_QWORD *)(a1 + 8); // overwritten before the divide
qword_2D7C8 = *(_QWORD *)(a1 + 16);
qword_2D7D0 = *(_QWORD *)(a1 + 24);
qword_2D7D8 = *(_QWORD *)(a1 + 32);
// sub_27AE0 overwrites qword_2D7C0 before the divide
The guards read the low DWORDs at buffer offsets 36 and 28. A zero DWORD returns early. The five QWORD stores then copy buffer bytes 4 through 43 onto the adjacent globals. The first QWORD is the jump target, stored at qword_2D7B8. Offset 12 goes to qword_2D7C0, and sub_27AE0 replaces it with callback output or zero before the divide at NiDynDDR+0x17ca8. The divisor is bytes 20 through 27. A zero divisor bugchecks with INTEGER_DIVIDE_BY_ZERO.
+0 WORD 32 secondary index
+2 WORD 0 context slot
+4 QWORD gadget a1+0 → qword_2D7B8
+12 QWORD 1 a1+8 → qword_2D7C0 (initial value; later overwritten)
+20 QWORD 0x100 a1+16 → qword_2D7C8 (divisor, bytes 20–27)
+28 QWORD nonzero a1+24 → qword_2D7D0; low DWORD is checked
+36 QWORD nonzero a1+32 → qword_2D7D8; low DWORD is checked
sub_27BF0; a1 is &buf[4]. Each guard checks only the low DWORD of its QWORD.IOCTL 3: jump
IOCTL 0x9C4000F8 maps to index 62:
(0x9C4000F8 >> 2) & 0x1FF = 62
sub_23A30(62) reads qword_2D5D0[61]
jmp [rax+r10*8] → RIP = the planted QWORD
The three calls, in order:
IOCTL 0x9C40E400 512 bytes reported register length, slot 0
IOCTL 0x9C400024 44 bytes length implied by the check above
IOCTL 0x9C4000F8 256 bytes reported trigger length
Proof
The first test planted a ret at RVA 0xCE3E. The xor eax, eax at 0xCE3C is skipped by a branch to 0xCE3E. The third IOCTL returned success and the machine kept running. RSP was still the original kernel stack, and the return address was NiDynDDR+0x1326A, the instruction after call sub_23A30. The trampoline is a jmp, so it leaves that return address in place.
The second test planted 0x0000414141414141. Windows 10 21H2 bugchecked. WinDbg on the same 17.0.7.0 driver shows the jump before it is taken, then the crash stack:
NiDynDDR+0x13A53, on jmp qword ptr [rax+r10*8], before the branch is taken. The module base in this session is fffff805`36d90000. rax is that base plus 0x1D5D0. r10 is 0x3d (61), and [rax+r10*8] is NiDynDDR+0x1D7B8, holding 0x0000414141414141. The stack includes NiDynDDR+0x1326a and nt!IofCallDriver.
nidynddr_v17_poc2.exe. nt!KiBugCheck2 is entered with 0x139. The stack includes nt!guard_icall_bugcheck and nt!KeCheckStackAndTargetAddress. The marker sits between nt!KiPageFault and NiDynDDR+0x1326a.The jump slot holds the marker. The crash goes through nt!guard_icall_bugcheck, and KiBugCheck2 is called with 0x139, KERNEL_SECURITY_CHECK_FAILURE. The marker sits on the stack between nt!KiPageFault and NiDynDDR+0x1326a.
Impact
The bug is control of that jump. On Windows 10 a common next step is clearing KTHREAD.PreviousMode on the current thread. The byte is 1 for a user-mode request, so access checks still run, and 0 when the kernel treats the request as its own. With it cleared, OpenProcess and TerminateProcess can reach Protected Process Light processes such as Defender’s MsMpEng.exe.
Clearing PreviousMode stops working on Windows 11 23H2 and later. Those builds need a different follow-on, usually a short ROP chain, and the chain still has to fit the mitigations enabled on the machine. The outcome people are after is the same: a chance to disable endpoint security products a normal administrator cannot kill.
Older versions
Older NiDynDDR.sys samples on VirusTotal share the device name, a plain IoCreateDevice, register IOCTL 0x9C40E400, and magic 0x89ABCDEF. The dispatch sequences use the same index formula and the same dead >= 0x200 check.
index = (ioctl_code >> 2) & 0x1FF
if (index >= 0x200) return STATUS_NOT_IMPLEMENTED
jmp [table + (index - 1) * 8]
v13 and v16 x64 use the same jmp [rax+r10*8] trampoline. On v16.1.7 the table is still in writable .data. The 2003 build is x86, so its target register is EIP. The three-IOCTL jump was run on 17.0.7.0.
| Version | Notes |
|---|---|
| 2003 x86 | Same index formula and dead 0x200 check. Target register is EIP |
| 11.0.0.0 x64 (2005) | Same index formula and dead 0x200 check |
| 13.0.31.0 x64 (2009) | Same index formula, dead check, and jmp [rax+r10*8] |
| 16.0.1.0 / 16.1.5.0 / 16.1.7.0 (2012–2013) | Same trampoline. v16.1.7 table still in writable .data |
| 17.0.7.0 x64 (2015) | Three-IOCTL jump on Windows 10 21H2 |
The pre-17 comparison is static. The table layout can move between builds.
Status
| Check | Value |
|---|---|
| VirusTotal, this .sys | Inside signed ObserverSetup 17.0.7.0 (79348f5d…22e21a93) |
| VirusTotal, older builds | v11 / v13 / v16 and a 2003 x86 build, 0 detections |
| LOLDrivers | Not listed |
| Microsoft driver blocklist | Not listed |
| Vendor disclosure | Viavi emailed 2026-04-17, no reply |
| CVE | None assigned |
Bound the dispatch index to the real handler set before the lookup, and keep those pointers read-only. Mutable data has to stay out of the range a bad index will treat as code. Restrict the device to the callers that need it. An administrator can still load the driver. Patching only the store to qword_2D7B8 leaves the unbounded lookup, including the jump through context slot 12.