Windows kernel drivers sit at the very bottom of the software stack, running with kernel privileges and direct access to hardware, memory, and every process on the machine. For a red teamer, a vulnerable driver is a skeleton key: drop a payload that calls the right IOCTL, and you can read or write arbitrary kernel memory, silence security products, and escalate to SYSTEM without ever setting off a userland alarm. The only obstacle is finding the right knobs to turn.
This post is a practical guide to that discovery phase - mapping the IOCTLs a driver exposes, understanding what each one does, and spotting the patterns that turn an obscure control code into a working exploit primitive. We cover both the static analysis path and the runtime tracing fallback, and look at how DriverDiscovery automates the tedious parts so you can focus on finding the bugs.
What is an IOCTL and why should you care?
User-mode code talks to a kernel driver through I/O Control codes (IOCTLs).
A process opens a handle to a device object the driver exposes, then calls
DeviceIoControl with a 32-bit control code, an input buffer, and an output buffer.
The kernel routes that request as an IRP (I/O Request Packet) to the driver's
IRP_MJ_DEVICE_CONTROL handler, which dispatches on the code and acts accordingly.
Every IOCTL is built with the CTL_CODE macro, which packs four fields into
those 32 bits: a 16-bit device type, a 12-bit function code, a 2-bit transfer method, and
a 2-bit access requirement. The method field is particularly interesting from an attacker's
perspective - it controls how the kernel moves data between user and kernel space, and
getting it wrong is one of the most common sources of memory safety bugs in driver code.
The challenge: finding IOCTLs in the wild
You might expect that reading the IOCTL list off a driver would be simple - find the
IRP_MJ_DEVICE_CONTROL dispatch routine, look for the switch statement, and
read off the case labels. In practice it is rarely that clean.
Compilers transform switch statements over large, sparse ranges into lookup tables and jump trees. A switch with a dozen IOCTL cases might compile to a binary search over a sorted array, a series of cascading comparisons, or a combination of both. The control codes themselves are not always string constants in the binary - many drivers compute them at runtime, mask off bits, or compare against transformed versions. A driver might right-shift the incoming code by two bits before comparing it against a table of function codes, making the raw code invisible in the disassembly without arithmetic reasoning.
Add in WDF and NDIS abstractions that wrap dispatch behind framework callbacks, minifilter drivers that communicate through FLT ports rather than device objects, and the occasional driver that genuinely obfuscates its dispatch logic, and manual enumeration quickly becomes a multi-hour exercise even for experienced researchers.
Static analysis: reading the dispatch table
The first step is identifying what kind of driver you are looking at. A raw WDM
driver sets its DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] pointer
directly in DriverEntry. A WDF driver calls framework functions that hide the
pointer assignment. An NDIS miniport registers an OID handler. A minifilter talks through
a filter communication port. Each framework has a characteristic code pattern around its
registration calls, and identifying the framework first tells you where to look for the
actual dispatch logic.
Once you have the dispatch routine, you follow it into the control-code switch. The first
analysis pass traces the routine's control flow, identifies the comparison points, and
extracts the literal values being compared. A straightforward switch compiles to a block of
cmp eax, 0x222000 / je handler_A / cmp eax, 0x22200C / je handler_B
sequences, which are easy to harvest. The harder case is a compiler-generated jump table:
the code subtracts the minimum code value, checks a range, then indexes into a pointer
array. You need to recover the base value, the table address, and the entry count to
reconstruct which codes are valid.
After extracting a code, the second step is decoding it: split it back into its device type,
function, method, and access fields using the CTL_CODE layout. This tells you
immediately whether the handler uses buffered I/O (safer), direct I/O (requires care), or
METHOD_NEITHER (the highest-risk transfer mode, where the driver receives raw
user-space pointers and is entirely responsible for safe access).
With the code decoded, the third step is tracing each handler to recover its buffer expectations: the minimum input size it checks for, the output size it promises, and the data structures it reads or writes. A handler that casts the input buffer directly to a struct without checking the buffer length first is a strong signal.
Runtime tracing: the fallback for what static analysis misses
Some dispatch paths resist static analysis entirely. A driver might compute its control codes from a configuration table loaded at startup, or branch on a value that is only known at runtime, or use indirect call thunks that hide the destination until the CPU executes them. In those cases, the only reliable way to build the IOCTL list is to watch the driver process real requests and record what codes actually arrive at the handler.
Windows Event Tracing for Windows (ETW) provides kernel-level hooks into IRP processing. Specific providers emit events when an IRP is created, dispatched, and completed, including the control code, the requesting process, and timing information. A tracer that subscribes to the right providers and filters for the target device can build a live log of every IOCTL the driver handles in real traffic - or in traffic you generate by running the driver's companion application, an installer, or an emulated client.
Runtime tracing complements static analysis rather than replacing it: static analysis gives you the complete picture of what the driver can accept (including codes that are never called in normal use), while ETW tracing confirms what codes are actually exercised and can surface codes that static analysis missed. The two together close the gap.
DriverDiscovery in practice
All of the analysis steps above - framework identification, dispatch routine recovery,
switch decode, arithmetic deobfuscation, buffer layout recovery, kernel call mapping, and
ETW runtime tracing - are what
DriverDiscovery
automates. You point it at a .sys file, and it walks the static analysis
pipeline described above, presenting the results in a table: each decoded control code,
its device type, access requirement, transfer method, function number, and the RVA of
the handler that processes it. Select a code in the table and the detail pane shows the
decoded fields, the buffer layout the handler expects, and the kernel functions that handler
calls - turning a raw hex code into an actionable description of what the driver will do
on your behalf.
For drivers where static analysis cannot fully recover the dispatch table, the built-in
ETW tracing mode watches live IRP traffic and fills in the gaps. Once a control code is
decoded, the tool generates ready-to-use pseudo code for the complete calling sequence -
opening the device handle, constructing the correct control code constant, building a struct
that matches the expected buffer layout, and issuing the DeviceIoControl call
with the right parameters. That pseudo code is output as a compilable C or C# stub so
there is no manual wiring. The tool also handles kernel debugging setup from within the
interface itself, for cases where you need to step through the handler under a debugger.
What to look for once you have the map
Having the full IOCTL list is the starting point, not the finish line. Here is what to focus on when triaging the results:
METHOD_NEITHER codes. Every code with a method of 3 is worth manual
review. The driver receives raw user-space pointers and is solely responsible for calling
ProbeForRead / ProbeForWrite before touching them. A driver that
skips those probes hands you a controlled kernel read or write at an address you supply.
This is the most common source of exploitable bugs in commodity drivers.
Unchecked buffer lengths. Handlers that cast the input buffer to a struct
without first verifying Parameters.DeviceIoControl.InputBufferLength are
candidates for out-of-bounds reads or for passing truncated data to downstream code that
expects a full structure. The buffer layout view in DriverDiscovery makes it easy to spot
handlers that use a fixed-size struct but never check the incoming length.
Kernel memory access IOCTLs. Some drivers expose administrative IOCTLs
that read or write physical or virtual memory on behalf of the caller - useful features
for their legitimate purpose and perfect primitives for an attacker. Check the kernel
calls each handler makes: calls into MmMapIoSpace, ZwMapViewOfSection,
or direct MoveMemory-style routines paired with a user-supplied address are
strong signals.
Open device names and missing access checks. An IOCTL that grants powerful capabilities is only dangerous if you can reach it. Check whether the driver's device object has a name in the object namespace (visible with a tool like WinObj), whether the ACL on that object restricts access to administrators, and whether the dispatch handler enforces its own caller identity check. A signed driver with a world-readable device name and no caller check is the foundation of a Bring Your Own Vulnerable Driver (BYOVD) attack.
Building the exploit call
Once you have identified a promising IOCTL, the tool generates ready-to-use pseudo code
for the entire calling sequence: the CreateFile call to open the device
handle, the correct control code constant, a struct definition matching the buffer layout
the handler expects, and the DeviceIoControl call wired to pass it in C or
C#. That pseudo code compiles directly - no manual wiring required. It is your starting
point for a proof of concept. From there, the work is filling in the right values to
trigger the vulnerable path and chaining the primitive into a useful capability - a read
to leak a kernel pointer, a write to overwrite a privilege token, or whatever your
engagement calls for.
Try it yourself
DriverDiscovery is available from the TrueCyber Software hub and comes with a 7-day free trial - enough time to run it against a few drivers from your target environment and see what the attack surface actually looks like. If you find something interesting, the analysis does not stop at the IOCTL list: the buffer layout recovery and kernel call mapping usually tell you enough to know whether an IOCTL is worth a deeper look without spending a full day in a disassembler first.
Kernel driver security is a discipline where the gap between what a driver exposes and what its developer intended is often significant. Systematic IOCTL discovery is how you find that gap before someone else does.