Reversing Windows Drivers Using Ghidra – Part 2

As I mentioned in the previous post, I’m writing this series while preparing for Offensive Security Exploitation Expert (OSEE). In this post, we will use HackSys Extreme Vulnerable Driver (HEVD) which you can download from Github. Yes, HEVD is open source and its symbols are available, so at first that sounds like it defeats the point. But for this exercise, we’re going to ignore both the source code and symbols (PDBs).

The idea is to simulate a more realistic reverse engineering scenario. We have a third-party Windows driver binary but no source code or symbols. We will still want to figure out how it works.

For this post, our goal is to identify the real / developer-written DriverEntry(), find the important dispatch routines, and find the I/O Control Codes (IOCTLs) that we can use to talk to the driver from user mode. But why are we after all of these things?  Those are the parts that usually tell us how the driver is exposed externally and that path from DeviceIoControl() to IRP_MJ_DEVICE_CONTROL to the internal IOCTL switch is often the shortest path to the bug / vulnerability classes that actually matter. Think about local privilege escalation vulnerabilities where you can escalate privileges from a low privileged standard Windows user to ‘NT Authority\SYSTEM’ (simple demo here).

We could absolutely bring WinDBG into the picture and do dynamic analysis, and we will use it where it helps. But dynamic analysis gets a lot less useful when there’s no existing user-mode application actively sending requests to the driver. In that case, the ‘IRP_MJ_DEVICE_CONTROL’ path may never be exercised on its own, which means we need to recover the IOCTL interface mainly through static analysis. That is exactly what we will do here with Ghidra, a bit of WinDBG, and some help from Microsoft’s documentation on standard kernel structures (and some tips from Alex Ionescu’s OffensiveCon19 Keynote – Reversing Without Reversing!)

This is a harder way to approach the problem but I think that is also why its useful (and fun)! If we can recover the important parts of a driver without relying on source code, symbols, or a ready-made client application, we end up understanding the reversing process much more deeply (Pain + Suffering == Humble).

Windows Driver Basics

If you’re already comfortable with Windows driver internals, feel free to skip this section. Otherwise, I’d suggest reading the first part of this series first (link here). Connor McGarr’s blog is also a great resource if you’re learning Windows kernel exploitation.

Meet the Driver

Once you load this driver in Ghidra and let analysis finish, the first thing you see is a list of function names like FUN_140085c78(). The following screenshot from Ghidra’s symbol tree panel shows what that looks like.

Figure 1 - Functions in Ghidra after initial analysis

Figure 1 – Functions in Ghidra after initial analysis

At this stage, most of the function names are meaningless, apart from imported Windows APIs. We also do not know real function argument types yet, the structure types are still rough, and the decompiled code is not very readable.

For example, this is how the PE entry stub looks like in Ghidra:


void entry(longlong param_1)

{
  __security_init_cookie();
  FUN_14008a000(param_1);
  return;
}

That is our first clue.

Let’s Start With entry()

What exactly is this entry() function?

The screenshot below shows a side-by-side view of the same routine in Ghidra and IDA Free.

Figure 2 – Ghidra’s entry() and IDA’s DriverEntry label refer to the same PE entry stub, but the instruction flow shows it is only a wrapper that initializes the security cookie before transferring control to the real driver initialization routine.

I’m using IDA here as a secondary reference because its disassembly view is still relatively clean at this stage. Ghidra is already trying to interpret types and arguments, which is helpful sometimes, but it can also get in the way when we are still trying to reason from raw instructions.

At first glance, this entry() function (routine) may look like the real DriverEntry(), especially because IDA labels it that way. But if you look closely at the instructions, it is really just a small compiler-generated wrapper. It preserves the incoming RCX, and RDX register values (function arguments), calls ‘__security_init_cookie’, restores those values, and then forwards control to another function.

That other function is FUN_14008a000() (sub_14008a000 in IDA).

If you look at the assembly (Figure 2), the wrapper moves values into RCX and RDX registers before calling FUN_14008a000(). On x64 Windows, the first four arguments are passed in RCX, RDX, R8 and R9 (__fastcall calling convention), so this strongly suggests that FUN_14008a000() is being called with two arguments. However, Ghidra’s decompiler only shows one argument at this point.

That is our next problem to fix.

Figure 3 – Ghidra’s decompiler view of entry() shows FUN_14008a000() with only one visible argument

Now let’s look at FUN_14008a000() itself.

Figure 4 – Decompiled view of FUN_14008a000() in Ghidra

At this point Ghidra can already recognise some documented Windows APIs from the import table, but the code is still rough. We can at least see calls like RtlIntiUnicodeString(), IoCreateDevice(), and IoCreateSymbolicLink().

One unicode string is ‘\\Device\\HackSysExtremeVulnerableDriver’ and the other is ‘\\DosDevices\\HackSysExtremeVulnerableDriver’.

These two strings are important.

The first one is the kernel-mode device name used when the driver creates its device object (structure). The second is the DOS-visible symbolic link that user mode application can open as ‘\\.\HackSysExtremeVulnerableDriver’.

In other words:

  • IoCreateDevice() creates the actual device object (structure).
  • IoCreateSymblolicLink() creates the user-visible name that lets Windows APIs like CreateFile() open a handle to it.

That is a very strong sign that FUN_14008a000() is the real developer-written DriverEntry() routine, or at least the real initialization routine we care about.

So, so far the flow looks like this:

  1. entry() – compiler-generated wrapper that initializes the GS cookie and forwards the original arguments
  2. FUN_14008a000() – likely the real DriverEntry(), where the device creation and symbolic link setup happen

The next step is to make that second function readable enough to work with.

On A Mission To Fix Things

Now that we have a strong reason to believe FUN_14008a000() is the real DriverEntry(), the next step is to rename it and fix its function signature in Ghidra.

You can find its prototype on MSDN here or if you install Windows Driver Kit (WDK), you will find it there in one of the header files. More about that process in my another blog ‘Patch Diffing Microsoft Windows Wi-Fi Driver Vulnerability (CVE-2024-30078) – Part 1’ here.

NTSTATUS DriverEntry(
_In_ PDRIVER_OBJECT  DriverObject,
_In_ PUNICODE_STRING RegistryPath
);

So we know it takes two arguments:

  • a pointer to Driver_Object structure
  • a pointer to UNICODE_STRING

In Ghidra, we can try to fix this by going to the Decompiler view, right-clicking the function, and selecting Edit Function Signature.

Changing the return type to NTSTATUS usually works right away. The problem starts when we try to use kernel-specific types like PDRIVER_OBJECT. Out of the box, Ghidra 11.0.3 does not ship with the full set of WDK kernel datatypes, so these types often do not resolve until we import them ourself.

Also, Ghidra is happier with standard C pointer syntax than Microsoft pointer typedefs in some situations. So even if PDRIVER_OBJECT does not resolve, DRIVER_OBJECT may work once the base structure exists.

Figure 5 – Ghidra error when changing the datatype to PDRIVE_OBJECT

To fix that gap, there are two practical options:

  • The quick route: import a community-maintained kernel datatype archive like Cisco Talos’ ntddk_64.gdt
  • The more accurate route: generate our own datatype archive locally from the WDK headers using Ghidra’s scripts

For this exercise, I used the Cisco Talos GDT archive. After importing it into Ghidra, I was able to apply the proper kernel structure types and update the function signature.

Once I renamed the function, fixed its signature, and corrected some local variable names based on the documented APIs, the decompiled code became much more readable.

Figure 6 – Updated DriverEntry() decompiled view after fixing the signature and data types

It still is not perfect and that is worth remembering. Decompilers guess. Even after we improve the types, we still need to keep one eye on the real disassembly.

But at this point, the DriverEntry() routine is readable enough for what we need next.

The First Stint

So far, we have done a few useful things.

From part 1, we already knew the high-level goal: find the IOCTL path. In this post, we started applying that to HEVD.

We looked at how a user-mode application talks to a driver with CreateFile() and DeviceIoControl(), and how those requests become IRPs that get routed through the driver’s dispatch table.

We also looked at the entry path in HEVD, separated the compiler-generated wrapper from the real initialisation routine, and fixed the DriverEntry() signature so that Ghidra’s output became easier to follow.

Now we can use that DriverEntry() routine to find the dispatch function that handles IOCTLs.

Dissecting The Actual DriverEntry()

Looking at the updated DriverEntry() decompiled code (Figure 6), we can see that after the device is created, the driver starts populating the MajorFunction array. That is the dispatch table that tells the I/O manager which routines handle which IRP major functions.

A simplified chunk of the code looks like this:

  if (iVar1 < 0) {
    if (pDeviceObject != (PDEVICE_OBJECT)0x0) {
      IoDeleteDevice();
    }
    pcVar4 = "[-] Error Initializing HackSys Extreme Vulnerable Driver\n";
  }
  else {
    ppuVar3 = pDriverObject->MajorFunction;
    for (lVar2 = 0x1c; lVar2 != 0; lVar2 = lVar2 + -1) {
      *ppuVar3 = &LAB_14008574c;
      ppuVar3 = ppuVar3 + 1;
    }
    pDriverObject->MajorFunction[0] = &LAB_140085058;
    pDriverObject->MajorFunction[2] = &LAB_140085058;
    pDriverObject->MajorFunction[0xe] = &LAB_140085078;
    pDriverObject->DriverUnload = &LAB_140085000;
    pDeviceObject->Flags = pDeviceObject->Flags | 0x10;
    pDeviceObject->Flags = pDeviceObject->Flags & 0xffffff7f;
    iVar1 = IoCreateSymbolicLink(&pusDestinationString,&pusDeviceName);

The key line for us is:

pDriverObject->MajorFunction[0xe] = &LAB_140085078;

So the next question is simple: what does index 0xE mean?

That value comes from the WDK headers. In ‘wdm.h’, IRP_MJ_DEVICE_CONTROL is defined as 0x0E.

That means:

pDriverObject->MajorFunction[0xe]

is the dispatch routine for IRP_MJ_DEVICE_CONTROL.

We can verify that in ‘wdm.h’:

C:\Windows\system32>type "C:\Program Files (x86)\Windows Kits\10\Include\10.0.26100.0\km\wdm.h" | find /i "Define the major function codes for IRPs"
// Define the major function codes for IRPs.
C:\Windows\system32>
C:\Windows\system32>type "C:\Program Files (x86)\Windows Kits\10\Include\10.0.26100.0\km\wdm.h" | find /i "irp_mj_"
#define IRP_MJ_CREATE 0x00
#define IRP_MJ_CREATE_NAMED_PIPE 0x01
#define IRP_MJ_CLOSE 0x02
#define IRP_MJ_READ 0x03
#define IRP_MJ_WRITE 0x04
#define IRP_MJ_QUERY_INFORMATION 0x05
#define IRP_MJ_SET_INFORMATION 0x06
#define IRP_MJ_QUERY_EA 0x07
#define IRP_MJ_SET_EA 0x08
#define IRP_MJ_FLUSH_BUFFERS 0x09
#define IRP_MJ_QUERY_VOLUME_INFORMATION 0x0a
#define IRP_MJ_SET_VOLUME_INFORMATION 0x0b
#define IRP_MJ_DIRECTORY_CONTROL 0x0c
#define IRP_MJ_FILE_SYSTEM_CONTROL 0x0d
#define IRP_MJ_DEVICE_CONTROL 0x0e
#define IRP_MJ_INTERNAL_DEVICE_CONTROL 0x0f
#define IRP_MJ_SHUTDOWN 0x10
#define IRP_MJ_LOCK_CONTROL 0x11
#define IRP_MJ_CLEANUP 0x12
#define IRP_MJ_CREATE_MAILSLOT 0x13
#define IRP_MJ_QUERY_SECURITY 0x14
#define IRP_MJ_SET_SECURITY 0x15
#define IRP_MJ_POWER 0x16
#define IRP_MJ_SYSTEM_CONTROL 0x17
#define IRP_MJ_DEVICE_CHANGE 0x18
#define IRP_MJ_QUERY_QUOTA 0x19
#define IRP_MJ_SET_QUOTA 0x1a
#define IRP_MJ_PNP 0x1b
#define IRP_MJ_PNP_POWER IRP_MJ_PNP // Obsolete....
#define IRP_MJ_MAXIMUM_FUNCTION 0x1b
#define IRP_MJ_SCSI IRP_MJ_INTERNAL_DEVICE_CONTROL

We can also confirm it dynamically in WinDBG with:

!drvobj HEVD 2

If you run that against a loaded HEVD instance (screenshot below), WinDBG will show that the device-control dispatch routine points to the same offset we saw in Ghidra.

Figure 7 – Installing and starting HEVD

Figure 8 – Force reloading modules in WinDBG to ensure it recognises HEVD

Figure 9 – DrvObj command showing IRP_MJ_DEVICE_CONTROL dispatch routine at offset 0xE

If you notice, the DrvObj command also confirms that the dispatch routine called IRP_MJ_DEVICE_CONTROL is at offset 0xE pointing to offset 0x85078 in HEVD.SYS (‘HEVD+0x85078’).

So now we know that LAB_140085078 is the handler we care about. At this point, it makes sense to rename it to something like DispatchDeviceControlRoutine() and fix its signature to match the documented prototype here:

NTSTATUS DispatchDeviceControlRoutine(
PDEVICE_OBJECT DeviceObject,
PIRP Irp
);

After applying that (fixing the function signature) in Ghidra, the decompiled code starts to look like this:


NTSTATUS DispatchDeviceControlRoutine(_DEVICE_OBJECT *pDeviceObject,_IRP *pIrp)

{
  uint uVar1;
  longlong lVar2;
  NTSTATUS NVar3;
  undefined8 uVar4;
  undefined4 *puVar5;
  ulonglong uVar6;
  char *pcVar7;
  
  lVar2 = *(longlong *)((longlong)&pIrp->Tail + 0x40);
  NVar3 = -0x3fffff45;
  if (lVar2 == 0) goto LAB_14008571b;
  uVar1 = *(uint *)(lVar2 + 0x18);
  if (uVar1 < 0x22203c) {
    if (uVar1 == 0x22203b) {
      NVar3 = thunk_FUN_14008695c();
    }
    else if (uVar1 < 0x222020) {
      if (uVar1 == 0x22201f) {
        uVar4 = FUN_140087a28(pIrp,lVar2);
        NVar3 = (NTSTATUS)uVar4;
      }
      else if (uVar1 == 0x222003) {
        uVar4 = FUN_140086594(pIrp,lVar2);
        NVar3 = (NTSTATUS)uVar4;
      }
      else if (uVar1 == 0x222007) {
        uVar4 = FUN_1400866c0(pIrp,lVar2);
        NVar3 = (NTSTATUS)uVar4;
      }
      else if (uVar1 == 0x22200b) {
        uVar4 = FUN_140085e58(pIrp,lVar2);
        NVar3 = (NTSTATUS)uVar4;
      }
      else if (uVar1 == 0x22200f) {
        uVar4 = FUN_140085f5c(pIrp,lVar2);
        NVar3 = (NTSTATUS)uVar4;
      }
      else if (uVar1 == 0x222013) {
        uVar4 = thunk_FUN_140087a44();
        NVar3 = (NTSTATUS)uVar4;
      }
      else if (uVar1 == 0x222017) {
        NVar3 = thunk_FUN_140087c70();
      }
      else {
        if (uVar1 != 0x22201b) goto LAB_1400855db;
        NVar3 = thunk_FUN_140087bb0();
      }
    }
    else if (uVar1 == 0x222023) {
      uVar6 = FUN_1400874f8(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar6;
    }
    else if (uVar1 == 0x222027) {
      uVar6 = FUN_140086b54(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar6;
    }
    else if (uVar1 == 0x22202b) {
      puVar5 = FUN_140087108(pIrp,lVar2);
      NVar3 = (NTSTATUS)puVar5;
    }
    else if (uVar1 == 0x22202f) {
      uVar4 = FUN_140087890(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
    else if (uVar1 == 0x222033) {
      uVar4 = FUN_140087738(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
    else {
      if (uVar1 != 0x222037) goto LAB_1400855db;
      uVar4 = FUN_140086800(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
  }
  else if (uVar1 < 0x222058) {
    if (uVar1 == 0x222057) {
      NVar3 = thunk_FUN_1400880d8();
    }
    else if (uVar1 == 0x22203f) {
      uVar4 = FUN_140086cc4((longlong)pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
    else if (uVar1 == 0x222043) {
      uVar4 = FUN_140086380(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
    else if (uVar1 == 0x222047) {
      uVar4 = FUN_140088220(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
    else if (uVar1 == 0x22204b) {
      uVar4 = FUN_14008616c(pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
    else if (uVar1 == 0x22204f) {
      uVar4 = FUN_140086ee4((longlong)pIrp,lVar2);
      NVar3 = (NTSTATUS)uVar4;
    }
    else {
      if (uVar1 != 0x222053) goto LAB_1400855db;
      uVar4 = thunk_FUN_140087eac();
      NVar3 = (NTSTATUS)uVar4;
    }
  }
  else if (uVar1 == 0x22205b) {
    NVar3 = thunk_FUN_140088018();
  }
  else if (uVar1 == 0x22205f) {
    uVar4 = FUN_140087e90(pIrp,lVar2);
    NVar3 = (NTSTATUS)uVar4;
  }
  else if (uVar1 == 0x222063) {
    uVar4 = FUN_140085a18(pIrp,lVar2);
    NVar3 = (NTSTATUS)uVar4;
  }
  else if (uVar1 == 0x222067) {
    NVar3 = FUN_140085e3c(pIrp,lVar2);
  }
  else if (uVar1 == 0x22206b) {
    NVar3 = FUN_140085c78(pIrp,lVar2);
  }
  else {
    if (uVar1 != 0x22206f) {
LAB_1400855db:
      NVar3 = -0x3ffffff0;
      goto LAB_14008571b;
    }
    NVar3 = FUN_140085b14(pIrp,lVar2);
  }
LAB_14008571b:
  (pIrp->IoStatus).Information = 0;
  (pIrp->IoStatus).field0_0x0.Status = NVar3;
  IofCompleteRequest(pIrp,0);
  return NVar3;
}

Actually that’s the stripped down version of DispatchDeviceControlRoutine(). It had lots of DbgPrintEx() and other statements that might have taken away all the fun (difficult/challenging) part! So, I’ve removed them from the decompiled code because as we discussed earlier, we want to make it a real hard exercise by simulating a real world driver where we don’t have access to source code or symbols. Let’s assume that the driver was compiled without these super friendly debug statements etc.

That is progress, but it also shows the next problem.

What is exactly is ‘pIrp->Tail + 0x40’ ? And why is Ghidra showing things like ‘(pIrp->IoStatus).field0_0x0.Status’?

This is where the datatype repair work starts.

The Next Blocker: Can We Trust Ghidra’s Structures?

At this point, the hard part is no longer finding the dispatch routine. The hard part is trusting what Ghidra thinks the IRP fields mean.

Ghidra had already recognized _IRP and some of its members, but the layout it was using did not match what WinDBG showed for the real 64-bit kernel structure.

To sanity-check it, I compared the _IRP layout in Ghidra against WinDBG.

Figure 10 – _IRP in WinDBG

Figure 11 – _IRP in Ghidra’s Data Type Manager

That comparison immediately showed a problem. The _IRP datatype currently applied in our Ghidra database was effectively laid out like a 32-bit structure, even though the driver itself was clearly 64-bit.

You can see it in the offsets.

In WinDBG, the x64 _IRP has fields like:

  • MdlAddress at +0x008
  • AssociatedIrp at +0x018
  • UserIosb at +0x048
  • UserEvent at +0x050
  • UserBuffer at +0x070
  • Tail at +0x078

In Ghidra, the same fields were showing up much earlier:

  • MdlAddress at 0x4
  • AssociatedIrp at 0xc
  • UserIosb at 0x30
  • UserEvent at 0x34
  • UserBuffer at 0x44
  • Tail at 0x48

That kind of compression only happens when the structure is effectively being laid out with 4-byte pointers instead of 8-byte ones.

So the issue was not with HEVD or WinDBG. The problem was that the _IRP datatype currently applied in Ghidra was wrong for this target. That could happen for a few reasons: a bad import, a stale local copy of the type, or a mismatch in the third-party archive.

Either way, the fix was the same: repair the structure manually until the relevant fields matched WinDBG.

Fixing _IRP Structure in Ghidra

The first step was to edit the _IRP structure in Ghidra and fix the top-level layout.

That meant:

  • adding AllocationProcessorNumber
  • adding Reserved
  • fixing the data types for fields like MdlAddress and Flags
  • making sure the offsets lined up with the x64 layout

Once I did that, the important part of the top-level _IRP layout matched what I saw in WinDBG, and Tail moved to the correct offset 0x78.

Figure 12 — _IRP structure in Ghidra after the top-level fix

That got the outer structure back into shape, but IRP –> Tail –> Overlay was still wrong.

Figure 13 – IRP structure members in WinDBG vs in Ghidra’s Data Type Manager

Fixing IRP –> Tail –> Overlay

When I compared the Tail union in WinDBG and Ghidra, the mismatch was obvious. The first member of Tail --> Overlay is not just a normal flat structure field. It is a union. The WDK header in WDM.h confirms that.

Figure 14 – WDM.h screenshot showing first member of Overlay structure is a Union

So in Ghidra, I created a new union type for that first branch. I called it Union_IRP_TAIL_OVERLAY but that still was not enough.

Figure 15 – Screenshot showing newly created Ghidra data type Union_IRP_TAIL_OVERLAY

After creating the union and using it as the first member of Overlay, the next field, Thread, still landed at offset 0x18 in Ghidra instead of 0x20 like it does in WinDBG.

Figure 16 – Thread is still at offset 0x18 in Ghidra

Why?

Because that new union was still too small.

The reason is in the WDK definition. As you can see from previous screenshot (Figure 14), one branch of that union contains:

PVOID DriverContext[4];

That means the union must be large enough to hold four 8-byte pointers on x64. So its size must be 0x20, not 0x18.

To fix that, I created a helper structure in Ghidra’s local datatype manager called DRIVER_CONTEXT_STRUCT. I added four consecutive PVOID members at offsets 0x0, 0x8, 0x10, and 0x18. I named them DriverContext0 through DriverContext3. That helper structure is not pretty, but it gives the branch the correct size of 0x20, which is what matters.

Figure 17 – Screenshot showing newly created data type DRIVER_CONTEXT_STRUCT of size 0x20

Then I replaced the undersized DriverContext member inside Union_IRP_TAIL_OVERLAY with this new DRIVER_CONTEXT_STRUCT. Once I did that, the union finally became 0x20 bytes, and Thread automatically moved to offset 0x20, which matched WinDBG.

Figure 18 – After the fix, Thread is at offset 0x20 in Ghidra

After fixing the relevant members of Tail and Overlay, the parts of the _IRP layout that mattered for this routine finally lined up with WinDBG.

Figure 14 – DRIVER_CONTEXT_STRUCT and corrected Union_IRP_TAIL_OVERLAY

This is not the cleanest way to fix it, but for this routine it was good enough. The important thing was getting the offsets and interpretations back in sync with the real kernel layout.

Quick Pit Stop

At this point, it is worth pausing and summarizing what changed.

So far, we have:

  • identified MajorFunction[0xE] as IRP_MJ_DEVICE_CONTROL
  • renamed the handler to DispatchDeviceControlRoutine()
  • fixed its function signature
  • repaired the _IRP layout in Ghidra
  • repaired IRP->Tail->Overlay
  • created helper datatypes like Union_IRP_TAIL_OVERLAY and DRIVER_CONTEXT_STRUCT

Now the question is whether those fixes actually improve the decompiler output in a useful way.

Back On Track

After all that datatype repair work, the decompiled view of DispatchDeviceControlRoutine() looked much better.

Figure 15 – Updated decompiled code for DispatchDeviceControlRoutine()

But it still had one big ambiguity. Ghidra was rendering part of the expression chain through:

pIrp->Tail.Apc.SystemArgument1

instead of:

pIrp->Tail.Overlay.CurrentStackLocation

At first that looks like another datatype bug, but it is not. This is a union problem.

The reason is that both of these are valid symbolic interpretations of the same byte offset inside the Tail union:

  • _IRP.Tail.Overlay.CurrentStackLocation
  • _IRP.Tail.Apc.SystemArgument1

Both land at the same final offset in the IRP:

  • _IRP.Tail starts at 0x78
  • Tail.Overlay.CurrentStackLocation is at 0x40
  • Tail.Apc.SystemArgument1 is also at 0x40

So both resolve to:

0x78 + 0x40 = 0xb8

That means the machine code access at [RDX + 0xB8] can be rendered through either branch of the union.

The important point is that the offset alone does not prove which interpretation is semantically correct but the surrounding context does.

In a dispatch routine handling an IRP, the normal thing a driver does is fetch the current _IO_STACK_LOCATION and then read things like the major function and IoControlCode. That makes Tail.Overlay.CurrentStackLocation the right interpretation here.

By contrast, the APC view of Tail is used when the IRP is being treated as a kernel APC object, which is not what is happening in a normal device-control dispatch path.

So the decompiler was not “wrong” at the raw offset level. It just picked the wrong legal union branch for display.

In Ghidra, you can force the preferred branch by right-clicking the field and selecting the appropriate union interpretation so that it uses Overlay instead of Apc.

Figure 16 – Forcing Ghidra to use Tail.Overlay instead of Tail.Apc

That fixed the next level of the expression chain.

Fixing _IO_STACK_LOCATON Structure

Once CurrentStackLocation was being interpreted properly, the next structure in the chain became _IO_STACK_LOCATION.

And again, Ghidra’s layout did not fully match WinDBG. In WinDBG, the Parameters union begins at offset 0x08 inside _IO_STACK_LOCATION. In Ghidra, it was showing up at 0x4.

Figure 17 – Side-by-side view of _IO_STACK_LOCATION structure in WinDBG and IDA

That had to be fixed, because every field inside the Parameters union depends on that offset being right.

The quick fix was to edit _IO_STACK_LOCATION in Ghidra and insert four undefined padding bytes between Control and Parameters. Once that was done, Parameters moved to offset 0x08, which matched WinDBG.

Figure 18 – _IO_STACK_LOCATION after adding padding so Parameters lands at 0x08

Now the structure lined up better:

Figure 19 – Decompiled view of DispatchDeviceIoControlRoutine() after fixing _IO_STACK_LOCATION struct

but there was still one more subtle issue. Ghidra started interpreting:

(lVar2->Parameters).Create.EaLength

for the field at [R14 + 0x18]

That looked cleaner than before, but it was still not the right semantic interpretation.

Why?

Because Parameters is also a union.

The union member that is valid depends on the IRP major function. In this routine, we already know we are in the IRP_MJ_DEVICE_CONTROL dispatch path. That means the correct active view of the union is Parameters.DeviceIoControl, not Parameters.Create.

This is how the _IO_STACK_LOCATON layout looks like in WinDBG:

0: kd> dt nt!_IO_STACK_LOCATION -r2
+0x000 MajorFunction : UChar
+0x001 MinorFunction : UChar
+0x002 Flags : UChar
+0x003 Control : UChar
+0x008 Parameters : <unnamed-tag>
+0x000 Create : <unnamed-tag>
+0x000 SecurityContext : Ptr64 _IO_SECURITY_CONTEXT
+0x008 Options : Uint4B
+0x010 FileAttributes : Uint2B
+0x012 ShareAccess : Uint2B
+0x018 EaLength : Uint4B
<! -- SNIP -->
+0x000 DeviceIoControl : <unnamed-tag>
+0x000 OutputBufferLength : Uint4B
+0x008 InputBufferLength : Uint4B
+0x010 IoControlCode : Uint4B
+0x018 Type3InputBuffer : Ptr64 Void
+0x000 QuerySecurity : <unnamed-tag>
+0x000 SecurityInformation : Uint4B
+0x008 Length : Uint4B
<! -- SNIP -->

From this WinDBG layout:

  • _IO_STACK_LOCATION.Parameters starts at offset 0x08
  • DeviceIoControl.IoControlCode is at offset 0x10 within that sub-structure

So:

0x08 + 0x10 = 0x18

That means [R14 + 0x18] is the IOCTL value.

So even though Ghidra initially rendered that field through another legal union member, the surrounding context told us which interpretation was actually valid.

That matters because once you recognise [R14 + 0x18] as Parameters.DeviceIoControl.IoControlCode, the rest of the function suddenly makes sense. All those comparisons against values like 0x22203B0x22201F0x222003, and so on are just IOCTL dispatch logic.

At that point, the structure is no longer mysterious. It is exactly what we expected to find: a classic device-control handler switching on incoming IOCTL codes and forwarding work to internal helper routines.

Validating The IOCTL Interpretation

There are a few good reasons to trust that interpretation. First, we already identified this routine from MajorFunction[0xE], and 0xE maps to IRP_MJ_DEVICE_CONTROL. Second, the assembly matches the structure math:

  • [RDX + 0xB8] resolves to CurrentStackLocation
  • [R14 + 0x18] resolves to Parameters.DeviceIoControl.IoControlCode

Third, the code immediately compares that value against a long list of constants and branches to different routines. That is exactly what an IOCTL dispatch function is expected to do.

Figure 20 – Analysing assembly in IDA

As you can see, the ‘cmp’ instruction ‘cmp r9d, eax' checks if the user or user-mode application provided value is less than 0x22203B and then if you scroll down through the Graph View in IDA or look at the stripped down version of the decompiled code I mentioned earlier, you will notice multiple branches with different hexadecimal codes there. It’s also another hint that the driver is comparing IOCTL provided and based on that calling different routines.

So by this point, the interpretation is no longer just a guess. It is strongly supported by calling context, disassembly, structure layout, and the shape of the control flow.

DeviceIoControl structure similar to what we did for other structures earlier and post that the the decompiled code in Ghidra looks accurate or correct:

And finally, those IOCTL values line up with what other HEVD research has documented, including Connor McGarr’s writeups here. Also, I used one of these IOCTLs in my HEVD exploits, which you can find on my Github repo here.

Wrapping Up

This post covered a lot more than just “find the IOCTL handler.”

We started with a stripped, no-symbols view of HEVD in Ghidra and worked our way through the real entry path, identified the actual DriverEntry(), fixed its signature, and used the MajorFunction table to find the IRP_MJ_DEVICE_CONTROL dispatch routine.

Then came the part Ghidra does not magically solve for you: fixing the broken kernel datatypes. That meant repairing _IRP, repairing IRP->Tail->Overlay, creating helper types where needed, fixing _IO_STACK_LOCATION, and checking everything against WinDBG and raw disassembly instead of trusting the decompiler blindly.

That extra work paid off. Once the structures and union interpretations lined up with reality, the dispatch routine started to read the way we would expect: fetch the current stack location, extract the IOCTL, compare it against a set of known values, and route execution to the appropriate internal routine.