Day26 - 幽影綴化術,暗黑鍛造工藝:自製簡易 Binary Packer(上)

走在時代前沿的前言

哈囉大家,雙十節快樂!這兩天要來自製一個 Binary Packer!如果不知道 Packer 是什麼或是想跟著一起實作的話,就讓我們繼續看下去吧!

對了,這個專案叫做 ZYRA,有放在我的 GitHub 上,連結我會丟在下面,歡迎 PR。

(ZYRA 的全名是 ZYRA: Your Runtime Armor,沒錯又是遞迴 XD)

完整程式碼可於此處找到https://github.com/CX330Blake/ZYRA

疊甲

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

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

Zig 版本

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

Packer 簡介

Packer 是一個用來包裝執行檔的技術,核心的目的在於隱藏原始程式內容,增加逆向工程師的分析難度。這個過程中可能涉及加密、混淆、壓縮、編碼等技術。

在安全領域中,Packer 被廣泛應用於兩個面向:合法用途與惡意用途。合法情境如軟體保護、授權機制與反逆向工程,能防止他人輕易分析或竄改程式;惡意情境則常見於惡意軟體中,用來逃避防毒偵測與靜態分析

沒錯,以上這坨話是由 GPT 生成的,不過它說得對,挺沒錯的。總之 Packer 就是這樣的一個東西,比較常見的有包含 UPX、Themida、VMProtect 等等的。

ZYRA 的工作流程

而我們就是要在這兩天使用 Zig 程式語言來實作一個 Packer。這個 Packer 的整體運作流程大約如下圖這樣,我們先看圖吧。

ZYRA Workflow

我來大概講解一下這個圖,我會把它分成兩個部分,Pack 部分跟執行 Pack 過的程式的部分(執行時會 Unpack)。

哦對了!在這邊檔案會有點多,且環環相扣,會有點複雜。但是要記住,Packer 本身是會被寫成一個 CLI 工具,而 Stub 才是那個最後會被嵌入原始執行檔的檔案;換句話說,Packer 只是用來處理輸入輸出的邏輯,而 Stub 本身才是會去處理加密解密、Drop 檔案等等邏輯的程式

Pack 的過程

  1. 程式會先去產生一個待會要嵌入原始執行檔(要 Pack 的目標)的執行檔,我們將其稱作 Stub
  2. 接著會去加密我們的整支執行檔(在這裡的實作為簡單的 XOR)
  3. 下一步我們要把剛剛加密過的 Payload(在這裡指的是整支原始執行檔)嵌入到剛剛的 Stub 中,這過程中會把 Payload 的起始標誌、Payload 的長度、加密用的 Key 都一併放入

如此一來,就完成了我們 Pack 的過程,那我們來看看是如何 Unpack 並執行的吧。

執行的過程

我們可以先看一下坐下角黃黃的那坨,就是我們 Pack 過後的執行檔的樣子,那我們來看一下執行的流程。

  1. 一開始這個 Stub Binary 會先執行到,並去讀取到 Payload 的起始標誌,得知從哪裡開始為要讀取的資料
  2. 下一步會把 Payload 的長度跟要解密用的 Key 都讀取出來
  3. 接著解密原始執行檔
  4. 解密完成後,會把原始執行檔丟到某個暫時資料夾並執行
  5. 執行完後會刪除原始執行檔

以上就是整個 Packer 的運作流程啦!那就開始來寫程式吧!

架構分辨器

因為我們會需要支援多種不同架構的 Binary(在本例中支援 PE 格式以及 ELF 格式的執行檔),所以勢必須要一個架構的分辨器。除了分辨檔案格式是 PE 還是 ELF 之外,還需要分辨 CPU 架構是 x86 還是 x64,因為這會影響到我們的 Stub 的編譯。

所以我們先來寫幾個 Enum 和 Struct 來定義這些格式和架構吧!

pub const FileFormat = enum {
    elf,
    pe,
    unknown,
};

pub const Arch = enum {
    x86,
    x64,
    unknown,
};

pub const BinType = struct {
    format: FileFormat,
    arch: Arch,
};

pub const FileType = enum {
    elf_x86,
    elf_x86_64,
    pe_x86,
    pe_x86_64,
    unknown,
};

定義好了這些之後,就可以來開始實作我們的架構分辨器了。我們會寫一個 Public 的函數叫做 identifyExecutableFormat,我們來看一下這個函數的實作吧!

pub fn identifyExecutableFormat(path: []const u8) !BinType {
    var file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    var buffer: [64]u8 = undefined;
    const read_len = try file.readAll(&buffer);

    // ELF detection
    if (read_len >= 20 and std.mem.eql(u8, buffer[0..4], "\x7FELF")) {
        const class = buffer[4]; // 1 = 32-bit, 2 = 64-bit
        const machine = @as(u16, buffer[18]) | (@as(u16, buffer[19]) << 8);

        if (class == 1 and machine == 3) {
            return BinType{ .format = .elf, .arch = .x86 };
        } else if (class == 2 and machine == 62) {
            return BinType{ .format = .elf, .arch = .x64 };
        } else {
            return BinType{ .format = .elf, .arch = .unknown };
        }
    }

    // PE detection
    if (read_len >= 0x40 and buffer[0] == 'M' and buffer[1] == 'Z') {
        // PE header offset
        const pe_offset = @as(u32, buffer[0x3C]) | (@as(u32, buffer[0x3D]) << 8) | (@as(u32, buffer[0x3E]) << 16) | (@as(u32, buffer[0x3F]) << 24);
        try file.seekTo(pe_offset);
        var pe_hdr: [6]u8 = undefined;
        _ = try file.readAll(&pe_hdr);

        if (std.mem.eql(u8, buffer[0..4], &[_]u8{ 'P', 'E', 0, 0 })) {
            return BinType{ .format = .unknown, .arch = .unknown };
        }
        const machine = pe_hdr[4] | (@as(u16, pe_hdr[5]) << 8);
        if (machine == 0x14c) {
            return BinType{ .format = .pe, .arch = .x86 };
        } else if (machine == 0x8664) {
            return BinType{ .format = .pe, .arch = .x64 };
        } else {
            return BinType{ .format = .pe, .arch = .unknown };
        }
    }

    return BinType{ .format = .unknown, .arch = .unknown };
}

這個函數接收一個參數,是執行檔的路徑。函數一開始會去讀取該文件的前 64 個 Bytes,這只是用來判斷 ELF 或是 PE 的 Magic number。

我們先來看一下判斷是 PE 或是 ELF 的邏輯。在第一個 If 中,它會去判斷文件的前面 4 個位元組是不是 \x7FELF,這是 ELF 文件的 Magic bytes。如果比較為相同,則代表該文件是個 ELF 文件。

在第二個 If 中,去判斷 MZ Header(DOS Header)是否存在,換句話說,也就是判斷文件的前兩個位元組是否為 MZ,若相同,則為 PE 格式。

接著,如果文件為 ELF,則去判斷第 5、19、20 個位元組(buffer 的索引 4、18、19)。ELF Header 的第 5 個位元組是 e_ident[EI_CLASS],它會標示檔案為 32 位元或是 64 位元。而第 19 和 20 個位元組則是 16 位元的 e_machine,它代表著機器的架構,當它的值為 3 時,代表 Intel 80386,而當它的值為 62 時,代表 AMD x86-64。透過這個,我們就可以判斷其為 x86 或是 x64 了。關於以上這段的其他值,以及 ELF 文件的詳細內容可以參考 Linux Foundation 中關於 ELF 文件的規格書

而如果文件為 PE,我們要做的也差不多。還記得我們之前在第 8 天講過的 e_lfanew 嗎,它標示了真正的 PE Header,也就是 IMAGE_NT_HEADERS 的位置。所以我們會先去讀 buffer[0x3C..0x3F] 的總共 4 個 Bytes,並用小端序把它們組成一個 32-bit 的無號整數 pe_offset。換句話說,它會把 4 個 Bytes 按照小端序組成一個 u32,而這個 u32 就是 e_lfanew,也就是 PE Header 的偏移量。

接著我們使用 file.seekTo(pe_offset) 把檔案指針移動到 e_lfanew 所指向的位置,接著再讀取出第 5 跟第 6 個位元,組成 16-bit 的 machine,它會指示出系統的 CPU 架構。它們的值有一個列表,可以在這個微軟的官方文檔找到。它的值如果是 0x14c,就代表是 Intel 386,如果是 0x8664,則是代表 x64。

如果對於 PE 的檔案架構不是那麼熟悉,可以回去複習第 8 天的內容!如此一來,執行檔的格式和 CPU 架構都已經被我們解析出來了,這個函數也就完成啦!

加密與解密函數

接著,我們會寫一個 encryptor.zigdecryptor.zig 的模組,負責加密和解密我們傳入的目標執行檔。不過由於是 XOR 加密,它們的邏輯其實是一樣的,我會寫成兩個檔案是為了便利於後續增加其他非對稱式的加密方式(雖然到現在都還沒實作,有點忙 XD)。我們來看一下它的邏輯吧。

const std = @import("std");

pub fn xorEncrypt(allocator: std.mem.Allocator, input_bin: []const u8, key: u8) ![]u8 {
    const encrypted = try allocator.alloc(u8, input_bin.len);
    for (input_bin, 0..) |byte, i| {
        encrypted[i] = byte ^ key;
    }
    return encrypted;
}

它會導出一個 xorEncrypt 的函數,會接受一個叫做 input_bin 的參數,這個參數是整個 Binary 的所有位元組。然後函數中會用一個 For 迴圈去迭代每個位元組,去把它跟 Key 做加密,並回傳加密過的內容。

解密的邏輯也是一模一樣,不過我還是把程式碼放上來吧。

const std = @import("std");

///return the decrypted payload
pub fn xorDecrypt(allocator: std.mem.Allocator, encrypted_input_bin: []const u8, key: u8) ![]u8 {
    const decrypted = try allocator.alloc(u8, encrypted_input_bin.len);
    for (encrypted_input_bin, 0..) |byte, i| {
        decrypted[i] = byte ^ key;
    }
    return decrypted;
}

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

今天先到這邊!明天的下篇,我們會來介紹其他的組件,並完成這個專案!

我們已經把這個 Packer 的運作原理介紹完畢,也寫了兩個組件!明天我們會來看一下如何構建每個架構、每個執行檔格式的 Stub,還有如何撰寫我們的 build.zig 和 Packer 本身的邏輯。

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