> For the complete documentation index, see [llms.txt](https://docs.redteamleaders.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.redteamleaders.com/offensive-security/windows-kernel-and-driver-exploitation/reversing-drivers-finding-ioctls-and-vulnerability-classes.md).

# 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:

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

<figure><img src="/files/qY2sJogI7dFIdCR9YX5d" alt=""><figcaption></figcaption></figure>

| 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 `switch`es 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

<figure><img src="/files/cz6000xRArGoi8o9nP2c" alt=""><figcaption></figcaption></figure>

The chain, in kernel terms:

1. User calls `DeviceIoControl(h, code, inBuf, inLen, outBuf, outLen, &ret, ...)`.
2. `ntdll!NtDeviceIoControlFile` → `nt!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 `switch`es 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:

```c
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchDeviceControl;
DriverObject->MajorFunction[IRP_MJ_CREATE]         = DispatchCreate;
DriverObject->MajorFunction[IRP_MJ_CLOSE]          = DispatchClose;
```

`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.

```asm
; typical DriverEntry epilogue in IDA
lea     rax, DispatchDeviceControl
mov     [rcx+0E0h], rax          ; MajorFunction[IRP_MJ_DEVICE_CONTROL]
lea     rax, DispatchCreateClose
mov     [rcx+70h], rax           ; MajorFunction[IRP_MJ_CREATE]
mov     [rcx+80h], rax           ; MajorFunction[IRP_MJ_CLOSE]
```

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:

```c
// Hex-Rays output, cleaned up
NTSTATUS DispatchDeviceControl(PDEVICE_OBJECT dev, PIRP irp) {
    PIO_STACK_LOCATION s = IoGetCurrentIrpStackLocation(irp);
    ULONG code   = s->Parameters.DeviceIoControl.IoControlCode;
    ULONG inLen  = s->Parameters.DeviceIoControl.InputBufferLength;
    ULONG outLen = s->Parameters.DeviceIoControl.OutputBufferLength;
    PVOID buf    = irp->AssociatedIrp.SystemBuffer;   // METHOD_BUFFERED

    switch (code) {
        case 0x80002048: handle_read(buf, inLen);  break;  // arb read
        case 0x8000204C: handle_write(buf, inLen); break;  // arb write
        case 0x80002050: handle_msr(buf);          break;  // msr r/w
        default: irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST; break;
    }
    // ... complete the IRP ...
}
```

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:

```python
# IDAPython: dump & decode candidate IOCTL codes from the dispatcher.
import idautils, idc

def find_dispatch():
    # Locate the function stored at driver_object+0xE0 in DriverEntry,
    # or fall back to a name heuristic.
    for ea in idautils.Functions():
        n = idc.get_func_name(ea)
        if "DeviceControl" in n or "Dispatch" in n:
            return ea
    return None

def dump_ioctls(func_ea):
    codes, ea, end = set(), func_ea, idc.find_func_end(func_ea)
    while ea < end:
        if idc.print_insn_mnem(ea) in ("cmp", "sub", "mov"):
            v = idc.get_operand_value(ea, 1)
            if 0 < v < 0xFFFFFFFF and ((v >> 16) & 0xFFFF) >= 0x8000:
                codes.add(v)
        ea = idc.next_head(ea, end)
    return sorted(codes)

methods = ["BUFFERED", "IN_DIRECT", "OUT_DIRECT", "NEITHER"]
for c in dump_ioctls(find_dispatch()):
    dev, acc, func, meth = (c>>16)&0xFFFF, (c>>14)&3, (c>>2)&0xFFF, c&3
    print(f"IOCTL 0x{c:08X}  dev=0x{dev:04X} func=0x{func:03X} "
          f"method={methods[meth]} access={acc}")
```

**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:

```
0x80002048 = 1000 0000 0000 0000  0010 0000 0100 1000
             └──── device 0x8000 ──┘ AA └─ func 0x812 ─┘ MM
  Access (AA) = 00  -> FILE_ANY_ACCESS   (reachable from any handle)
  Method (MM) = 00  -> METHOD_BUFFERED
```

#### Step 4 — Confirm reachability from user mode (fuzzing)

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

```c
// Brute-force reachable IOCTLs and note status codes.
// Educational — run ONLY in a snapshotted VM; a bad IOCTL can BSOD.
#include <windows.h>
#include <stdio.h>

int main(void) {
    HANDLE h = CreateFileW(L"\\\\.\\RTCore64", GENERIC_READ|GENERIC_WRITE,
                           0, NULL, OPEN_EXISTING, 0, NULL);
    if (h == INVALID_HANDLE_VALUE) { printf("open fail %lu\n", GetLastError()); return 1; }

    BYTE in[64] = {0}, out[64] = {0}; DWORD ret = 0;
    for (DWORD func = 0x800; func < 0x830; func++) {
        DWORD code = (0x8000u << 16) | (func << 2);   // ANY_ACCESS, BUFFERED
        BOOL ok = DeviceIoControl(h, code, in, sizeof in, out, sizeof out, &ret, NULL);
        DWORD e = GetLastError();
        if (ok || e != ERROR_INVALID_FUNCTION)        // 0x1F = not handled
            printf("IOCTL 0x%08lX  ok=%d err=%lu ret=%lu\n", code, ok, e, ret);
    }
    CloseHandle(h); return 0;
}
```

> **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.

```c
// Vulnerable handler pattern (RTCore64 / gdrv-style)
typedef struct { UINT64 PhysAddr; DWORD Size; DWORD Value; } RW_REQUEST;

void handle_write(RW_REQUEST *r) {
    PHYSICAL_ADDRESS pa; pa.QuadPart = r->PhysAddr;      // ATTACKER CONTROLLED
    PVOID map = MmMapIoSpace(pa, r->Size, MmNonCached);  // no range validation!
    if (map) {
        memcpy(map, &r->Value, r->Size);                 // arbitrary phys write
        MmUnmapIoSpace(map, r->Size);
    }
}
```

**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:

```c
// RTCore64-style: read a DWORD from an arbitrary kernel virtual address.
struct RTCORE_MEM { BYTE pad[8]; UINT64 Address; BYTE pad2[4]; DWORD Size; DWORD Value; BYTE pad3[16]; };
// IOCTL 0x80002048 -> reads *(Size bytes)* at Address into Value (returned to user)
// IOCTL 0x8000204C -> writes Value to 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).

```c
void handle_msr(MSR_REQUEST *m) {
    __writemsr(m->Register, m->Value);   // no filter on which MSR — LSTAR is writable
}
```

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.

```c
// Driver opens ANY process with full access on the caller's behalf.
void handle_open(OPEN_REQ *o, PHANDLE out) {
    CLIENT_ID cid = { (HANDLE)o->Pid, 0 };
    OBJECT_ATTRIBUTES oa; InitializeObjectAttributes(&oa,0,0,0,0);
    ZwOpenProcess(out, PROCESS_ALL_ACCESS, &oa, &cid);   // kernel mode => no ACL check
}
```

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.

```c
// Capcom-style: call an attacker-supplied pointer in kernel mode.
void handle_exec(void (*callback)(PVOID)) {
    // (Capcom cleared SMEP in CR4 around this call)
    callback(&some_kernel_helper);   // <- attacker controls callback => ring0 RCE
}
```

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 checks** → `if (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:

```
[ ] Is it signed with a still-valid / pre-2015 cert?           (loadable at all)
[ ] Is it already on the HVCI blocklist?                       (if yes, only helps on non-HVCI hosts)
[ ] Device created with IoCreateDevice (weak DACL)?            (reachable by low-priv?)
[ ] Any IOCTL with FILE_ANY_ACCESS + METHOD_NEITHER?           (prime bug territory)
[ ] Does the dispatcher reach MmMapIoSpace / __writemsr /
    Zw*Process / an indirect call on attacker data?            (capability)
[ ] Are physical/virtual addresses taken straight from the
    input buffer with no range validation?                     (arbitrary R/W)
[ ] Can you derive a *virtual* arb-read from what's exposed?   (KASLR defeat path)
```

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 →**
