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

FieldValue
FileNiDynDDR.sys
VendorNetwork Instruments, LLC (acquired by JDSU in 2014; JDSU became Viavi in 2015)
ProductNiDynDDR Device Driver for NI RMON Probe
Version17.0.7.0 x64
SHA-25671e69b87ef086cb9660eb017bf63bb2a3962cb3361b41b34afc06f11c8e2e8a1
Size133,352 bytes
Device\\.\NiDynDDR
SourceVirusTotal ObserverSetup.exe 17.0.7.0 (79348f5d…22e21a93)
Compile2015-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);
DriverEntry — device and symlink creation. No IoCreateDeviceSecure, no SDDL.

Load it as a kernel service:

sc create NiDynDDR type= kernel binPath= C:\Windows\System32\drivers\NiDynDDR.sys
sc start NiDynDDR
Kernel service. No extra hardware.

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
sub_231E0 — IRP_MJ_DEVICE_CONTROL handler. Index derived from the IOCTL code, then a bounds check that cannot fail.

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);
}
sub_23A30 — dispatch trampoline. In the binary this is jmp [rax+r10*8] at NiDynDDR+0x13A53.

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_2D5D0
0x1D5D0
Primary dispatch table
qword_2D630
0x1D630
Secondary table (overlaps the primary, +12 QWORDs)
qword_2D7B8
0x1D7B8
Adjacent global. Primary-relative entry 61, past populated slots 8–45
qword_2E3A0
0x1E3A0
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
Printed RVAs. Context slot 0 is primary-relative entry 442. Entry 61 is the adjacent global qword_2D7B8, not one of populated slots 8–45.

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
sub_231E0 — register path. Magic 0x89ABCDEF at offset 0; slot is the DWORD at offset 4. The allocation call passes PoolType 0 (NonPagedPool).

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 context slot 12. Context slot 0 is entry 442, which is index 443.

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
IOCTL 0x9C400024 — maps to index 9. qword_2D5D0[8] is sub_23930, the secondary dispatcher.

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;
}
sub_245B0 — write path gated on a3 == 42 (InputBufferLength - 2).

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
sub_27BF0 — store into qword_2D7B8, which an out-of-range lookup reads as entry 61.

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
Five QWORD stores in 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
IOCTL 0x9C4000F8 — maps to index 62. sub_23A30 jumps through the planted qword_2D5D0[61].

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
The three calls. The 44-byte length is the one the write path checks.

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:

WinDbg paused at NiDynDDR+0x13A53 with rax at NiDynDDR+0x1D5D0, r10 equal to 0x3d, and the qword at NiDynDDR+0x1D7B8 equal to 0x0000414141414141
Paused at 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.
WinDbg crash stack for nidynddr_v17_poc2.exe with KiBugCheck2 argument 0x139, nt!guard_icall_bugcheck, nt!KeCheckStackAndTargetAddress, and 0x0000414141414141 above NiDynDDR+0x1326a
Crash stack for 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]
Static dispatch shape. v11 x64 is the sequence shown. v13 and v16 x64 were observed with the same jmp [rax+r10*8] form. The 2003 sample is x86, so its target is EIP. After the mask, the 0x200 check cannot fire.

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.

VersionNotes
2003 x86Same 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

CheckValue
VirusTotal, this .sysInside signed ObserverSetup 17.0.7.0 (79348f5d…22e21a93)
VirusTotal, older buildsv11 / v13 / v16 and a 2003 x86 build, 0 detections
LOLDriversNot listed
Microsoft driver blocklistNot listed
Vendor disclosureViavi emailed 2026-04-17, no reply
CVENone 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.