You load a Windows driver into Ghidra, wait for analysis to finish, and then stare at what looks like nonsense: unnamed functions, broken types, generic variables, and no obvious sign of where the real driver logic begins.
If you do not have private symbols (.pdb files), this is normal. In this post, we will see how to get past that initial wall and recover the first pieces of structure that actually matter during driver reversing. The goal is not to fully understand the entire binary in one shot but to reliably find the parts of the driver that form its external attack surface:
- Compiler-generated entry stub
- Developer’s real DriverEntry()
- Dispatch routine that handles IRP_MJ_DEVICE_CONTROL
- Internal logic that switches on specific I/O Control Codes (IOCTLs)
That path is often enough to take a driver from “opaque blob” to “something I can reason about.”
I’m writing this as I’m learning Windows kernel exploitation in order to prepare for my Offensive Security Exploitation Expert (OSEE) exam. So I’ll try to keep the explanations easy and practical. We’ll use Ghidra, but the same ideas apply in IDA and similar tools.
What This Post Covers
This post is the conceptual foundation for reversing Windows drivers without symbols. Before opening a driver in Ghidra, it helps to know what we are trying to recover and why those pieces exist in the first place.
This is what we’re going to do:
- Understand how a user-mode app talks to a driver
- Understand what DriverEntry() really is
- Understand how I/O dispatching works
- Build a mental checklist for what to look for in the disassembler
In the next post, that roadmap becomes a hands-on reversing workflow.
Why Missing Symbols Hurt So Much
When Microsoft public symbols are available, disassemblers and decompilers can usually recognise documented kernel APIs, some standard structures, and many imported routines. That is helpful, but only up to a point.
What we do not get are the developer’s private names such as internal helper functions, driver-defined structures, custom Enums, IOCTL handler names, meaningful local variable names, or comments or source-level intent.
That means the most important parts of the driver still show up as anonymous code. The job of reversing is to rebuild just enough structure by hand so that the program becomes readable again. In practice, that usually means fixing function signatures, correcting pointer types, applying structure definitions, creating Enums for constants, and renaming functions etc. as we prove what they do.
But before doing any of that, it helps to know what control-flow skeleton we are looking for.
The Big Picture: How User Mode Reaches the Driver
At a high level, a Windows driver is a loadable kernel-mode module, usually shipped as a .sys file. It is still a Portable Executable (PE), like an .exe or .dll, but it runs in kernel mode rather than user mode.
From an exploitation perspective, that matters because a mistake in a kernel driver is often much more powerful than a mistake in a normal application. If a user-controlled input reaches vulnerable kernel code, the impact can include privilege escalation, arbitrary kernel read/write, or full system compromise.
So how does that input reach the driver? A simplified flow looks like this:
- User-mode application opens a handle to the device using CreateFile() API
- Application sends an IOCTL with DeviceIoControl()
- I/O manager packages that request into an KKKI/O Request Packet (IRP) structures
- I/O manager routes the
IRPs to the driver’s registered dispatch routine - Driver inspects the
IOCTLand performs the requested action
The following flow diagram shows the entire flow.

Step 1: User Mode Opens the Device
A user-mode client usually begins with CreateFile(), using a Win32 path such as, ‘\.\MyDriver’. If that succeeds, the application gets a handle that can later be used with DeviceIoControl().
Step 2: User Mode Sends an IOCTL
Once the handle is open, the application can call DeviceIoControl() which takes device handle, an IOCTL code, an optional input buffer, and an optional output buffer. This is usually the most interesting primitive from a reversing and exploitation perspective, because custom IOCTLs often expose privileged functionality to untrusted callers.
Step 3: The I/O Manager Builds an IRP
Windows does not hand the driver raw Win32 API calls. Instead, the I/O manager converts the request into an I/O Request Packet, or IRP. For example – CreateFile() commonly results in IRP_MJ_CREATE and DeviceIoControl() results in IRP_MJ_DEVICE_CONTROL.
The major function code is stored in the current IO_STACK_LOCATION, and for device-control requests the actual IOCTL value is stored in ‘Parameters.DeviceIoControl.IoControlCode‘. This detail matters a lot during reversing, because if you find code reading that field, you are usually close to the driver’s IOCTL handling path.
Step 4: The I/O Manager Dispatches the Request
This part is easy to describe loosely and slightly wrong, so it is worth being precise. The driver registers dispatch routines in the DRIVER_OBJECT-->MajorFunction table. But at runtime, it is the I/O manager that uses that table to call the correct handler for a given IRP major function.
So when a user-mode caller invokes DeviceIoControl(), the I/O manager builds an IRP_MJ_DEVICE_CONTROL request and calls the dispatch routine that the driver registered for that slot. For reversing, this is important because one of the first useful tasks is to find where DriverEntry() populates MajorFunction[IRP_MJ_DEVICE_CONTROL]. That assignment often leads directly to the driver’s main user-reachable handler.
Step 5: The Driver Handles the IOCTL
Inside that dispatch routine, the driver usually does some combination of the following:
- Obtains the current stack location
- Reads Parameters.DeviceIoControl.IoControlCode
- Extracts input and output buffers
- Switches on the incoming
IOCTL - Forwards control to internal helper functions
That final switch or branch tree is usually what exploit developers care about most, because it maps the external interface to the internal functionality.
What We Are Actually Looking for While Reversing
When opening a driver without symbols, it helps to go in with a short list of high-value targets. The usual early targets are:
- PE entry point
- Real DriverEntry() – This is the one that driver developer writes
- Assignments intoDriverObject–>MajorFunction
- Handler for IRP_MJ_DEVICE_CONTROL
- Internal
switchor comparison chain on IoConrolCode()
If we can recover those five things, we usually have enough structure to keep moving.
Understanding Entry Points
One of the first confusing things we see in a disassembler is that the apparent entry point is often not the developer’s handwritten DriverEntry(). That is expected.
The PE Entry Point vs. the Real Driver Initialization Routine
Like any PE file, a driver has an ‘AddressOfEntryPoint’ in its PE header. That is where execution begins after the image is loaded. But for many drivers, especially those built with Microsoft toolchains, the address stored there points to a compiler-generated or framework-generated stub rather than directly to the developer’s own DriverEntry().
So in practice, the first function we see is often just a wrapper. That wrapper is still useful, because it usually leads directly to the real initialization code.
Common Entry Wrappers We May See
GsDriverEntry() in Traditional WDM Drivers
For many non-framework drivers, the PE entry point lands in a compiler-generated stub often identified as GsDriverEntry() or just a generic entry label in the disassembler. Its job is usually simple – Initialize the GS security cookie and call the developer’s real DriverEntry(). Conceptually, it often looks like this:
NTSTATUS GsDriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { __security_init_cookie(); return DriverEntry(DriverObject, RegistryPath); }
The exact decompilation will vary, but the basic pattern is common. For reversing, this means weshould not stop at the first entry-like function wefind. Follow the call into the next routine and check whether that second function looks like a real driver initializer.
FxDriverEntry() in KMDF Drivers
If the driver was built on KMDF, the initial entry path often runs through FxDriverEntry(). In broad terms, this framework stub initializes the framework and then calls the driver’s DriverEntry(), which in turn usually calls WdfDriverCreate(). So if you see framework-related imports and a call path involving WDF initialization, that indicates that it’s a KMDF driver rather than a classic hand-wired WDM-style driver.
For reversing, this distinction matters because the control flow we recover later will look different. In a classic WDM-style driver, we often see the MajorFunction table populated directly. In KMDF, more behavior is registered through framework configuration and callbacks instead.
Deep Dive: The Real DriverEntry()
The developer’s DriverEntry has the standard prototype:
NTSTATUS DriverEntry( In PDRIVER_OBJECT DriverObject, In PUNICODE_STRING RegistryPath );
The two incoming arguments are extremely important:
- DriverObject points to the system-created Driver_OBJECT
- RegistryPath points to the driver’s service key in the registry
When we are reversing a driver in Ghidra, correctly typing these parameters early can make the decompilation much easier to read.
Why DriverObject Matters So Much
In many drivers, DriverEntry() fills out parts of the DRIVER_OBJECT, especially the dispatch table in MajorFunction. This is often the first strong structural clue in the entire binary. If we see code that writes function pointers into indices corresponding to IRP_MJ_CREATE, IRP_MJ_CLOSE, and IRP_MJ_DEVICE_CONTROL then we are probably in the right place.
That is why DriverEntry() is such a high-value reversing target: it often reveals the driver’s externally reachable entry points in one place.
What a Traditional Driver Initialization Routine Often Does
For a traditional driver, DriverEntry often performs some mix of the following tasks:
- Register an unload routine in DriverObject –> DriverUnload
- Populate one or more entries in DriverObject –> MajorFunction
- Initialize global state
- Create or prepare device objects
- Expose a user-reachable interface
- Return an NTSTATUS
That said, there is one nuance worth calling out clearly.
A Note on IoCreateDevice and IoCreateSymbolicLink
It is common in tutorials, exploit labs, and many simple sample drivers to see DriverEntry call IoCreateDevice() and IoCreateSymbolicLink(). That pattern absolutely exists, especially in older, legacy, non-PnP, or intentionally simple drivers. However, it should not be described as the universal WDM pattern.
In general, WDM drivers do not name device objects and should not use IoCreateSymbolicLink() for the normal PnP case. Microsoft documentation recommends using IoRegisterDeviceInterface() instead for WDM drivers that need a user-visible interface.
That means if we are reversing vulnerable training drivers or older third-party code, we may still see the classic named-device-plus-symbolic-link pattern all the time. But if we are writing about Windows drivers in general, it is better to present that as a common legacy or simplified pattern rather than the default rule for all WDM drivers.
The KMDF Alternative
Framework-based drivers still have a DriverEntry, but the initialization style is different. Instead of manually wiring large parts of the DRIVER_OBJECT, a KMDF driver typically initializes a WDF_DRIVER_CONFIG structure and calls WdfDriverCreate(). The framework then manages much of the boilerplate and invokes callback routines such as EvtDriverDeviceAdd. hat difference matters during reversing.
If we are expecting to find a neat block of MajorFunction[…] = SomeHandler assignments and instead see framework setup code, we may be looking at KMDF. In that case, the next useful targets are often framework callbacks rather than raw WDM dispatch routines.
A Practical Reversing Checklist
At this point, we can turn the theory into a practical checklist. When we open an unknown third-party driver without symbols, we want to answer these questions in roughly this order:
1. What does the PE entry point call?
Is it a thin wrapper like GsDriverEntry or a framework path like FxDriverEntry?
2. Where is the real DriverEntry?
Can I identify the function that receives DriverObject and RegistryPath and starts initializing the driver?
3. Does DriverEntry populate MajorFunction?
If yes, which slots does it fill? The most interesting early slots are often IRP_MJ_CREATE, IRP_MJ_CLOSE, and IRP_MJ_DEVICE_CONTROL
4. Which function handles IRP_MJ_DEVICE_CONTROL?
That is often the main bridge between user mode and the driver’s privileged logic.
5. Where does that function read the incoming IOCTL?
Look for access to the current stack location and reads from the device-control parameters.
6. Is there a switch or branch tree on IoControlCode?
If yes, that is usually where the attack surface starts becoming concrete. This is the point where unnamed helper functions start becoming nameable. Once we know which function handles which IOCTL, the rest of the binary becomes much easier to organize.
Why This Matters for Exploitation
From a purely reversing perspective, identifying the dispatch path is already a big win. From an exploitation perspective, it is even more valuable. A third-party driver’s IOCTL handler often exposes operations that:
- Trust caller-controlled sizes
- Dereference user-supplied pointers
- Copy buffers without proper validation
- Perform privileged actions on behalf of user mode
- Implement custom functionality with very little defensive hardening
So the path from DeviceIoControl to IRP_MJ_DEVICE_CONTROL to the driver’s internal IOCTL switch is not just a navigation exercise. It is often the shortest path to the bug classes that actually matter.
Closing Thoughts
When a driver has no private symbols, the first challenge is not understanding every function. The first challenge is rebuilding the driver’s skeleton well enough that the important code stops looking random. That usually begins with four anchors:
- Entry stub
- Real DriverEntry()
- IRP_MJ_DEVICE_CONTROL dispatch routine
- Internal IOCTL dispatch logic
Once we recover those, the rest of the binary becomes much less mysterious. In the next post, we will take this roadmap into Ghidra and walk through the actual workflow of turning an unnamed third-party driver into a readable interface map: fixing signatures, identifying dispatch routines, and locating the IOCTL handlers that matter most.