For the complete documentation index, see llms.txt. This page is also available as Markdown.

Reversing Drivers: Finding IOCTLs & Vulnerability Classes

Part 2 — Reversing Drivers: Finding IOCTLs & Vulnerability Classes

In this part: how to open a .sys in IDA/Ghidra, find DriverEntry, follow it to the IRP_MJ_DEVICE_CONTROL dispatch routine, recover every IOCTL code and its handler, fuzz for reachable ones, and recognize the bug patterns that turn an IOCTL into a kernel read/write primitive.


2.1 The IOCTL, byte by byte

Everything in a driver's attack surface funnels through a single 32-bit integer: the I/O control code. It's constructed with the CTL_CODE macro:

#define CTL_CODE(DeviceType, Function, Method, Access) \
    ( ((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) )

Field
Bits
Meaning
Why the attacker cares

Device Type

31–16

Vendor-chosen device class

Identifies the driver family; often 0x8000+ for third-party

Access

15–14

FILE_ANY_ACCESS(0) / READ(1) / WRITE(2)

FILE_ANY_ACCESS = reachable without R/W grant on handle

Function

13–2

The actual command number

This is what the driver switches on

Method

1–0

Buffer transfer method

Determines where your buffer lands and how much validation the OS did

The transfer method is a vulnerability oracle

The two low bits decide how your input/output buffers reach the driver — and therefore how much the I/O manager protected the driver from you:

  • METHOD_BUFFERED (0): the I/O manager allocates a kernel copy (Irp->AssociatedIrp.SystemBuffer) and copies data in/out. Length is validated by the OS. Safest, but bugs still happen in how the driver interprets the buffer contents.

  • METHOD_IN_DIRECT (1) / METHOD_OUT_DIRECT (2): the OS builds an MDL (Irp->MdlAddress) describing/locking the user pages for the direct buffer.

  • METHOD_NEITHER (3): the driver receives the raw user-mode pointers (Irp->UserBuffer and Parameters.DeviceIoControl.Type3InputBuffer) with no copying and no probing done for it. If the driver forgets ProbeForRead/ProbeForWrite, an attacker passes a kernel pointer and the driver dereferences it. METHOD_NEITHER is a giant red flag when triaging.

Rule of thumb when triaging: flag the recovered IOCTLs whose method is METHOD_NEITHER (code & 3 == 3) and whose access is FILE_ANY_ACCESS — those two properties disproportionately produce the good bugs.

2.2 From CreateFile to the handler — the dispatch path

The chain, in kernel terms:

  1. User calls DeviceIoControl(h, code, inBuf, inLen, outBuf, outLen, &ret, ...).

  2. ntdll!NtDeviceIoControlFilent!NtDeviceIoControlFile builds an IRP with major function IRP_MJ_DEVICE_CONTROL.

  3. The I/O manager calls DeviceObject->DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL].

  4. That handler retrieves the current stack location (IoGetCurrentIrpStackLocation), reads Parameters.DeviceIoControl.IoControlCode, and switches on it.

To map a driver's attack surface you find which function sits in MajorFunction[IRP_MJ_DEVICE_CONTROL] and then read its switch.

2.3 Reversing workflow: from DriverEntry to every IOCTL

Step 1 — Load and identify DriverEntry

Open the .sys in IDA or Ghidra. The PE entry point is DriverEntry (prototype NTSTATUS DriverEntry(PDRIVER_OBJECT, PUNICODE_STRING)). Inside it, hunt for the assignment of dispatch handlers:

IRP_MJ_DEVICE_CONTROL is index 14 (0xE). MajorFunction starts at +0x70 in DRIVER_OBJECT, each entry is 8 bytes, so the store is to [rcx + 0x70 + 14*8] = [rcx + 0xE0]. Finding the store to +0xE0 gives you the dispatch function's address directly. Memorize this offset.

Note the IoCreateDevice / IoCreateSymbolicLink calls here too — they give you the device name for CreateFile (e.g. \Device\RTCore64\\.\RTCore64) and reveal whether the secure variant IoCreateDeviceSecure (with a restrictive SDDL) was used — the DACL question from Part 1.

Step 2 — Decompile the dispatch routine

The idiomatic body extracts the IRP stack location and the control code:

In IDA, the switch on the control code usually compiles to a jump table (Hex-Rays reconstructs it cleanly); in pure disassembly look for a cmp/sub ladder against constants or an indexed jmp [rax*8 + table]. Each case constant is a callable IOCTL.

Step 3 — Recover the IOCTL table programmatically

Script it rather than eyeballing. An IDAPython sketch that dumps and decodes the immediate constants compared inside the dispatcher:

Ghidra equivalent: decompile the dispatch function, read the reconstructed switch directly, or run a small script over the PcodeOp constants feeding the switch. Ghidra's auto-analysis rebuilds the jump table reliably.

Decoding the RTCore64 read IOCTL 0x80002048 by hand:

Step 4 — Confirm reachability from user mode (fuzzing)

Static analysis says the IOCTLs exist; a quick sweep confirms which are reachable. A minimal harness:

Fuzzing caveat: blindly hitting write/MSR IOCTLs will corrupt kernel state and bugcheck. Real IOCTL fuzzers attach a kernel debugger, snapshot per iteration, and instrument the dispatcher to record which kernel APIs a given IOCTL reaches (MmMapIoSpace, Zw*, __writemsr) so they prioritize by capability instead of crashing blindly.

2.4 The vulnerability classes — pattern recognition

Almost every exploitable driver bug falls into one of a handful of families. Learn each from its decompiled shape.

Class 1 — Arbitrary physical memory mapping (MmMapIoSpace)

The most common BYOVD primitive. The driver takes an attacker-controlled physical address + length, maps it with MmMapIoSpace, and reads/writes it on the caller's behalf — with no allow-list of legitimate ranges.

Why it's game over: physical memory contains everything — including the kernel image and every EPROCESS. Combined with a physical-to-virtual translation (walk the page tables, whose base you get from CR3, or leak a known symbol) this yields a full arbitrary virtual read/write. Part 3 builds exactly this.

Class 2 — Direct virtual read/write (MmMapIoSpace on a known VA, or copy loops)

Some drivers expose an even more convenient primitive: pass a virtual address and they memmove to/from it, or map a virtual page. RTCore64's infamous IOCTLs read/write 1/2/4 bytes at an arbitrary virtual address:

This is the cleanest possible primitive: no page-table math required.

Class 3 — Model-Specific Register (MSR) read/write (__writemsr)

The driver lets you write an arbitrary MSR. The prize is LSTAR (MSR 0xC0000082) — the address the CPU jumps to on every syscall instruction. On non-HVCI systems, overwriting LSTAR to point at attacker-mapped code turns every syscall into a control-flow hijack primitive (this was the classic KPP-safe-ish trick before SMEP/HVCI made it much harder).

MSR write is also used for SMEP toggling (bit 20 of CR4 via __writecr4-style gadgets) in older exploits.

Class 4 — Over-privileged process handle (ZwOpenProcess / handle duplication)

Instead of raw memory, some drivers do favors with kernel authority. A driver that opens a caller-specified PID with PROCESS_ALL_ACCESS in kernel mode (PreviousMode == KernelMode bypasses access checks) and hands the handle back, or that terminates an arbitrary process, is a direct EDR-kill primitive — no memory corruption needed. procexp.sys, iqvw64e.sys, and various "system utility" drivers fall here.

With such a handle to a protected EDR process (or even a PPL, depending on the driver), the attacker can inject, suspend, or terminate it.

Class 5 — Arbitrary kernel pointer call (Capcom.sys archetype)

The most direct: an IOCTL that takes a user-supplied function pointer and calls it in ring 0. Capcom.sys famously did this — it even temporarily disabled SMEP so the callee could be a user-mode address. One IOCTL = arbitrary kernel code execution.

On HVCI/SMEP-hardened systems this exact pattern is largely dead (you can't point at user code, and you can't map new executable kernel code), but it's the canonical teaching example of "the driver did the dangerous thing for you."

Class 6 — Classic memory-safety bugs in the handler itself

Beyond intentionally-dangerous features, drivers also have plain bugs:

  • Missing ProbeForRead/ProbeForWrite on METHOD_NEITHER → pass a kernel pointer as the "user" buffer, driver reads/writes it.

  • Integer overflow in length checksif (len < MAX) with a signed/unsigned mixup lets an oversized copy through → pool overflow.

  • Unvalidated array index from the input buffer → OOB read/write into an adjacent pool allocation.

  • Double-fetch / TOCTOU on METHOD_NEITHER buffers → the driver reads a length, you change it in another thread before the driver reads the data.

These require more work to weaponize (pool grooming, KASLR defeat) than the "feature" bugs above, but they appear in drivers that tried to be safe.

2.5 Triage checklist — is this driver useful?

When you open an unknown signed .sys, run this mental checklist:

If you can answer "yes" to a capability row plus "no validation," you have a BYOVD primitive. Part 3 turns that primitive into SYSTEM.


Key takeaways for Part 2

  • The dispatch handler is stored at DriverObject+0xE0; find it, read the switch, and every case is an IOCTL.

  • METHOD_NEITHER + FILE_ANY_ACCESS are the highest-signal properties when hunting bugs.

  • Most primitives come from intended features (MmMapIoSpace, __writemsr, ZwOpenProcess, indirect calls) used without validation — not exotic memory corruption.

  • A physical R/W or virtual R/W primitive is the pivot; everything in Part 3 builds on it.

Continue to Part 3 — Weaponization, PoCs & Defense →

Last updated