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

Day26 - 幽影綴化術,暗黑鍛造工藝:自製簡易 Binary Packer(上)
CX330走在時代前沿的前言
哈囉大家,雙十節快樂!這兩天要來自製一個 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 的整體運作流程大約如下圖這樣,我們先看圖吧。
我來大概講解一下這個圖,我會把它分成兩個部分,Pack 部分跟執行 Pack 過的程式的部分(執行時會 Unpack)。
哦對了!在這邊檔案會有點多,且環環相扣,會有點複雜。但是要記住,Packer 本身是會被寫成一個 CLI 工具,而 Stub 才是那個最後會被嵌入原始執行檔的檔案;換句話說,Packer 只是用來處理輸入輸出的邏輯,而 Stub 本身才是會去處理加密解密、Drop 檔案等等邏輯的程式。
Pack 的過程
- 程式會先去產生一個待會要嵌入原始執行檔(要 Pack 的目標)的執行檔,我們將其稱作 Stub
- 接著會去加密我們的整支執行檔(在這裡的實作為簡單的 XOR)
- 下一步我們要把剛剛加密過的 Payload(在這裡指的是整支原始執行檔)嵌入到剛剛的 Stub 中,這過程中會把 Payload 的起始標誌、Payload 的長度、加密用的 Key 都一併放入
如此一來,就完成了我們 Pack 的過程,那我們來看看是如何 Unpack 並執行的吧。
執行的過程
我們可以先看一下坐下角黃黃的那坨,就是我們 Pack 過後的執行檔的樣子,那我們來看一下執行的流程。
- 一開始這個 Stub Binary 會先執行到,並去讀取到 Payload 的起始標誌,得知從哪裡開始為要讀取的資料
- 下一步會把 Payload 的長度跟要解密用的 Key 都讀取出來
- 接著解密原始執行檔
- 解密完成後,會把原始執行檔丟到某個暫時資料夾並執行
- 執行完後會刪除原始執行檔
以上就是整個 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.zig
跟 decryptor.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 本身的邏輯。
如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!