(繁中)F*ck Disassemblers - A Deep Dive into Anti-Disassembly Techniques

🧛 [ 繁體中文 | English version ]

前言

反組譯器的出現,對於逆向工程師而言是一大福音,但對於惡意程式開發者以及商業軟體的開發商而言則未必。因此,他們也開始研究反組譯器的算法以及如何針對反組譯器的算法進行攻擊或混淆,使反組譯器失去正常的工作能力。這樣的技術,我們便稱之為「反反組譯(Anti-Disassembly)」。

本文旨在探討各種反反組譯的原理以及其應對方案。在這過程中,我們會使用 Binary Ninja 和 IDA 兩種商業反組譯器(反編譯器)[1] 來觀察我們反反組譯的成效。

反組譯器的工作原理

反組譯器的目標,就是透過讀取 Machine code 並將其轉換回組合語言,使得逆向工程師可以更好地理解程式碼。

其中有兩種比較常見的反組譯算法,線性掃描(Linear Sweep)和遞迴下降(Recursive Descent)。我們將會深入兩種不同的反組譯器算法,並討論各自的優缺點。

Linear Sweep

正如同字面意思,線性掃描的算法就是線性的去遍歷一個執行檔的所有機器碼,並將它們轉回組合語言。一些比較簡單小巧、基礎的反組譯器就是使用這種算法,例如 objdump

我們先來看一小段程式碼來幫助我們更好的理解他的算法吧!

char buffer[BUF_SIZE];
int position = 0;

while (position < BUF_SIZE) {
    x86_insn_t insn;
    int size = x86_disasm(buf, BUF_SIZE, O, position, &insn);

    if (size != 0) {
        char disassembly_line[1024];
        x86_format_insn(&insn, disassembly_line, 1024, intel_syntax);
        printf("%s\n", disassembly_line);
        position += size;
    } else if {
        /* invalid/unrecognized instruction */
        position++;
    }
}
x86_cleanup ();

程式中,會去不斷地使用 x86_disasm 這個函數來進行反組譯,並且會使用 size 判斷是否有反組譯的程式碼,若沒有,則遞增 position,反之則會加上反組譯的程式碼的大小。總之,我們可以看到程式就是透過一個迴圈去「線性的」掃描整個機器碼,因此被稱之為「線性掃描」。

因為它只需要掃描整個機器碼一次便能完成工作,故其時間複雜度為 O(n)O(n),能較快速的完成任務。但它的缺點也同樣很明顯,那就是它會反組譯過多的程式碼。比如說,某個控制流程的指令只會讓某個分支執行,但是它還是會一直反組譯下去直到結束。另一個更為致命的缺點是它沒有辦法區分程式碼和資料。雖然在 PE 格式中,.text 區段被設計用來存放程式碼,但是編譯器常常因為效率或其他原因,把一些資料也放在這個區段(常見的一個例子是指針)。如此一來,線性掃描的算法便沒辦法區分他看到的位元組是資料還是程式碼,就會導致反組譯的不準確。

舉例而言,當有一段 switch-case 的程式碼像是下面這樣的程式碼時:

#include <stdio.h>

int main() {
    int i;
    scanf("%d", &i);

    switch (i) {
        case 0: puts("case 0\n"); break;
        case 1: puts("case 1\n"); break;
        case 2: puts("case 2\n"); break;
        case 3: puts("case 3\n"); break;
        case 4: puts("case 4\n"); break;
        case 5: puts("case 5\n"); break;
        default: break;
    }
}

編譯器可能會建立一個 Jump table 資料結構。這個結構本質上是一個陣列,裡面存放每個 case 對應的程式碼的指針,如下圖中 Binary Ninja 解析的這樣:

image.png

在實際執行的時候,它會使用 switch 的 value 去作為索引去查這個表,拿到對應的地址並 jmp 過去,會比逐一比較還要更快。

如果編譯器把這個 jump table 放在程式碼區段中,那線性掃描的反組譯器將會把這坨資料當成程式碼解讀,那就會產生錯誤的組合語言程式碼。所以我們需要介紹另一種算法,Recursive Descent。

Recursive Descent

遞迴下降法,也被稱作 Recursive Traversal 或是 Flow-Oriented。它和線性掃描最不一樣的點是它會在反組譯過程中,解析每一條指令並追蹤該指令的控制流程,為每個分支建立一份「待反組譯列表」。反組譯器先沿著其中一條控制流分支進行反組譯,當該分支走到終點後,再回到列表取出尚未處理的分支繼續反組譯。

舉例而言,針對以下程式碼:

 test eax, eax
 jz   label_1
 push failed_str
 call printf
 jmp  label_2

failed_str: db 'Fail', 0
label_1:
 xor  eax, eax
label_2:
 retn

在這個例子中,第一行的 test 指令後面會跟著一個條件跳轉指令 jz,當遞迴下降反組譯器遇到 jz 的時候,就會把 label_1 加入待反組譯列表。

並且因為這是一個條件跳轉,第三行的 push 也有可能被執行,所以反組譯器就會優先去處理第三行與第四行的反組譯。接著,遇到了無條件的跳轉 jmp,反組譯器就會把 label_2 加入待反組譯列表,並且忽略 jmp 之後的程式碼(因為會被跳過不執行)。

下一步,它會回到列表中,先拿出第一個要處理的地址,也就是 label_1 進行反組譯,再處理 label_2。我們可以發現,在這整個過程中 failed_str 從未被當成指令去處理,所以會產生更精確的反組譯程式碼。如果是線性掃描的反組譯器,就會一路反組譯到尾端,並把這段資料也當成程式碼。

這種條件式的語句會給遞迴下降反組譯器兩個不同的分支要處理:True 分支和 False 分支。而反組譯器往往會優先處理 False 分支。這是因為往往在程式中,False 分支會包含真正行為的程式碼,舉例來說,我們常常可以看到下變這樣的程式碼:

if (error_condition) {
    handle_error();
    exit();
}
do_something();

編譯器常常會將其編譯為這樣的組合語言:

    test eax, eax
    jz   handle_error_label

 ; false branch
    call do_something
    jmp  end_label

handle_error_label:
    call handle_error
end_label:
    ret

這就是為什麼反組譯器會更傾向於「信任」False 分支,這是基於編譯器行為的一個假設。

不過遞迴下降的反組譯器也有其缺點,那便是時間複雜度較高,因為必須對於每個分支都做處理,所以會花費較線性掃描更久的時間。

反反組譯與反反反組譯

反反組譯的技術就是透過精心構造的某一些位元組或程式碼,使得反組譯器在反組譯的時候會被騙去顯示與實際執行不一樣的程式碼。反反組譯技術的運作方式,就是利用反組譯器本身的假設與限制。例如,反組譯器必須假設程式中的每一個位元組,同一時間只能屬於某一條指令的一部分。如果讓反組譯器從錯誤的 offset 開始解碼,它就可能被誘導,導致某條真正有效的指令被隱藏起來、不被看到。

接下來我們會來看一些反反組譯的技術。

Jump Instructions with the Same Target

我們先來看一段組合語言,然後再來比對其反組譯的結果。以下這段程式就是一個 Hello world 的程式:

; nasm -f elf32 -o hello.o hello.asm
; ld -m elf_i386 -o hello hello.o
; strip hello

global _start
section .data
msg:    db "Hello, world!", 10
len:    equ $ - msg

section .text

_start:
    ; --- Anti-disassembly trick start ---
    jz  real_code
    jnz real_code
    db 0xE8  ; fake byte

real_code:
    ; Linux write(1, msg, len)
    mov eax, 4
    mov ebx, 1
    mov ecx, msg
    mov edx, len
    int 0x80

    ; exit(0)
    mov eax, 1
    xor ebx, ebx
    int 0x80

注意到 _start 標籤的部分會用 jzjnz 同時跳轉到同一個地方,並且在後面緊緊跟隨一個 db 0xE8。這會塞入一個垃圾位元組進來程式中,詳細的內容我們待會再來講解,我們先來看一下它用 objdump 反組譯出來的程式碼片段(Binary 是 Stripped 的):

> objdump -d -M intel hello

hello:     file format elf32-i386


Disassembly of section .text:

08049000 <.text>:
 8049000:       74 03                   je     0x8049005
 8049002:       75 01                   jne    0x8049005
 8049004:       e8 b8 04 00 00          call   0x80494c1
 8049009:       00 bb 01 00 00 00       add    BYTE PTR [ebx+0x1],bh
 804900f:       b9 00 a0 04 08          mov    ecx,0x804a000
 8049014:       ba 0e 00 00 00          mov    edx,0xe
 8049019:       cd 80                   int    0x80
 804901b:       b8 01 00 00 00          mov    eax,0x1
 8049020:       31 db                   xor    ebx,ebx
 8049022:       cd 80                   int    0x80

可以看到這邊在 jejne 之後的那個垃圾位元組 0xE8 被當成指令跟後面的那坨一起被反組譯器解析成為 call 0x80494c1 了。我們換用 IDA 來觀察一下,可以發現它也同樣成功的被我們的技巧給混淆了。

image.png

這代表無論是 Linear Sweep 的 objdump 還是 Recursive Descent 的 IDA 都同樣會受到這樣的混淆。

這邊所利用到的原理,便是前面提及過的:反組譯器會優先信任 False 分支的內容,所以它會對 jnz 後面的緊鄰的位元組做反組譯,如此一來,便會把我們插入的惡意位元組 0xE8 給當成程式碼去解析。整體的感覺大概像下圖這樣:

image-20251202103917855

這邊想要稱讚一下 Binary Ninja,它即便會被混淆到,還是可以把後面的內容正確反組譯出來。仔細看了一下,發現它即便已經把 B8 04 00 00 00 BB 01 00 00 00 這幾個位元組用來反組譯成兩個不正確的指令,但還是會把可能不正確的地方標記,並重複使用這些位元組,把後面正確的程式碼給反組譯出來,如圖所示。

image.png

那逆向工程師在分析的時候要怎麼還原這個程式碼片段呢?答案是我們可以把那個 0xE8 設定為「資料」,如此一來,反組譯器就可以正常反組譯後面的程式碼了。IDA 中可以先按下 D 把 call 0x80494c1 整條指令的 Opcode E8 B8 04 00 00 轉成資料,再對 B8 按下 C 把 B8 04 00 00 轉為程式碼,如此一來,就成功把 E8 轉回了資料而不會被反組譯器解析為程式碼。

而 Binary Ninja 可以使用它們的 Guided Analysis 功能。這個功能的官方說明如下:

Guided Analysis provides granular control over which basic blocks are included or excluded from analysis. It is especially useful for analyzing obfuscated code, troubleshooting analysis issues, or focusing on specific execution paths while excluding irrelevant code.
Guided Analysis 提供對哪些基本塊被包含或排除在分析之外的粒度控制。對於分析被混淆的程式碼、排除分析問題、或在排除不相關的程式碼的同時聚焦於特定執行路徑特別有用。

具體來說的用法,就是在這邊反組譯出 jne 0x8049005 的地方右鍵並點選「Halt Disassembly」即可將該位元組隔離,結果如下:

image.png

可以看到圖中的 e8 就被成功的隔離開來,而接下來的反組譯也都會是正確的!

(註:在與 Binary Ninja 的開發人員們討論的過程中,我還得知官方有專門寫了 一個插件 來做這件事,不過我還沒有使用過,但看起來很酷!)

A Jump Instruction with a Constant Condition

第二個要介紹的技巧和上述的很類似,我們一樣先看程式碼:

; nasm -f elf32 -o hello.o hello.asm
; ld -m elf_i386 -o hello hello.o
; strip hello

global _start
section .data
msg:    db "Hello, world!", 10
len:    equ $ - msg

section .text

_start:
    ; --- Anti-disassembly trick start ---
    xor eax, eax
    jz real_code
    db 0xE9  ; fake byte

real_code:
    ; Linux write(1, msg, len)
    mov eax, 4
    mov ebx, 1
    mov ecx, msg
    mov edx, len
    int 0x80

    ; exit(0)
    mov eax, 1
    xor ebx, ebx
    int 0x80

和第一個比較不同的地方在於,這邊跳到 real_code 之前是用一個 xor eax, eax 並且 再使用 jz 跳過去,其餘部分都是相同的。那接著我們看一下 objdump 跟 IDA 被混淆的結果。

> objdump -d -M intel hello

hello:     file format elf32-i386


Disassembly of section .text:

08049000 <.text>:
 8049000:       31 c0                   xor    eax,eax
 8049002:       74 01                   je     0x8049005
 8049004:       e9 b8 04 00 00          jmp    0x80494c1
 8049009:       00 bb 01 00 00 00       add    BYTE PTR [ebx+0x1],bh
 804900f:       b9 00 a0 04 08          mov    ecx,0x804a000
 8049014:       ba 0e 00 00 00          mov    edx,0xe
 8049019:       cd 80                   int    0x80
 804901b:       b8 01 00 00 00          mov    eax,0x1
 8049020:       31 db                   xor    ebx,ebx
 8049022:       cd 80                   int    0x80

image.png

這邊的原理,是利用 xor eax, eax 來強制把 ZF 設置為 1,再這種情況下的 jz 會變成無條件跳轉,而非有條件跳轉。因此當反組譯器在解析到 jz 後,並不知道這是一個無條件的跳轉,所以它依然會優先去處理 False 分支,也就是緊鄰著 jz 的下個位元組。而下個位元組是我們插入的惡意資料 0xE9,這就會導致反組譯器將 E9 B8 04 00 00 反組譯為 jmp 0x80494c1

至於分析時如何還原的方式,也和上面的類似,把那個垃圾資料位元組設為資料即可。

Impossible Disassembly

我們前面討論的兩個反組譯技巧,都是利用在條件跳轉後面塞入一個惡意的資料位元組,藉此讓從那個位元組開始的反組譯結果錯亂,進而使後面的真正指令無法被正確反組譯。之所以會這樣,是因為所插入的那個位元組,本身是某個多位元組指令的 Opcode。我們把這種位元組稱為 Rogue Byte,因為它並不是程式碼中的一部份,他只是資料位元組,所以在執行的時候這些 Rogue Byte 都可以被忽略不計。

但如果今天這個 Rogue Byte 不能被忽略呢?如果它也是我們在正常執行程式的時候會用到的某個合法指令的一部份呢?這就會介紹到我們接下來要討論的技巧,Impossible Disassembly。在這種技術之下,同一個位元組可能同時屬於多條實際會被執行的指令的一部份。

下面的圖我是引用自《Practical Malware Analysis: The Hands-On Guide to Dissecting Malicious Software》這本書中關於此技巧的章節(十分推薦閱讀此書)。在這個例子中,這段 4-byte 的序列中,第一條指令是一條 2-byte 的 jmp 指令。這條跳躍指令的目標位址,正好落在自身的第二個位元組上。這不會造成錯誤,因為這個位元組 FF 同時也是下一條 2-byte 指令(inc eax)的第一個位元組。

image.png

所以這段指令實際上會往回跳並且自增 eax 再遞減 eax,相當於是一個多位元組的 nop 指令,可以被插入在程式碼中的任意地方來混淆反組譯器。我們來看一段組合語言吧!

; nasm -f elf32 -o hello.o hello.asm
; ld -m elf_i386 -o hello hello.o
; strip hello

section .data
    msg: db "Hello, world!", 10
    msg_len equ $ - msg

global _start

section .text
    _start:
        db 0x66, 0xB8, 0xEB, 0x05, 0x31, 0xC0, 0x74, 0xFA, 0xE8

    real_code:
        mov eax, 4          ; sys_write
        mov ebx, 1          ; stdout
        mov ecx, msg
        mov edx, msg_len
        int 0x80

        ; exit
        mov eax, 1
        xor ebx, ebx
        int 0x80

這段程式的最一開始,我們就把一段 0x66, 0xB8, 0xEB, 0x05, 0x31, 0xC0, 0x74, 0xFA, 0xE8 的資料放進記憶體中,而這段資料的反組譯結果就如下圖,同樣是引用自該書的圖解。

image.png

可以看到,這段程式碼會把 0x5EB 放入 ax 寄存器,然後把 eax 清零,這會設置 ZF=1,因此接下來的 jz 必然會跳轉,所以它接著會往回跳 6 個位元組,再往前跳 5 個位元組到圖中的 Real Code。同樣的,因為 74 FAjz -6,對於反組譯器而言,這是個條件式跳轉,所以它會去處理 False 分支,也就會把緊鄰的 E8 反組譯成一個虛假的 call 指令。

我們把上面的程式拿去組譯並觀察一下 IDA 的反組譯結果吧!

image.png

可以看到,IDA 果然將其反組譯成爲了一個虛假的 call。而 Binary Ninja 又一次的成功將後面的程式碼正確反組譯出來了!

image.png

該書中其實有提及,『No disassembler currently on the market will represent a single byte as being part of two instructions(目前市面上的所有反組譯器都做不到讓「同一個位元組」同時出現在兩條指令中)』,不過現在看起來 Binary Ninja 成功地做到了,並且做得非常完美,向 Binary Ninja 開發團隊致上大大的敬意。

理解了上面的概念,所以我們就可以把這個東西寫成一個 C 的 Macro,重複使用在程式碼中的許多地方,以提升整支程式分析的複雜度。

#include <stdio.h>
#define IMPOSSIBLE_DISASM()   \
 __asm__ __volatile__( \
  ".byte 0x66, 0xB8, 0xEB, 0x05, 0x31, 0xC0, 0x74, 0xFA, 0xE8");

int main()
{
    IMPOSSIBLE_DISASM();
    printf("Hello, world\n");
}

當然,其他語言像是 Go、Rust 等也可以利用類似的技巧以達成反反組譯的效果。這邊示範使用 Zig 的一個 inline 函數來完成。

const std = @import("std");

inline fn impossible_disasm() void {
    asm volatile (
        \\ .byte 0x66, 0xB8, 0xEB, 0x05, 0x31, 0xC0, 0x74, 0xFA, 0xE8
    );
}

pub fn main() !void {
    impossible_disasm();
    std.debug.print("Hello, world\n", .{});
}

那至於對逆向工程師而言,該怎麼樣修復這樣的 Binary 呢?對於 IDA 使用者來說,可以使用 IDAPython 或是 IDC 呼叫 PatchByte 函式把 66 B8 E8 0574 FA E8 都改為 NOP 指令,如此一來,整段程式碼就變成了正常的執行流程。舉例而言,我們可以寫一個函數來幫助我們完成這件事。

def NopBytes(start, length):
    for i in range(0, length):
        PatchByte(start + i, 0x90)  # 0x90 for NOP
    MakeCode(start)

而至於 Binary Ninja 使用者,可以直接用 GUI 框選要轉為 NOP 的指令並且右鍵點選 Patch 然後選 Convert to NOP。當然,也可以用 Binary Ninja Python API 來完成這件事,如下。

def nop_bytes(start, length):
    for i in range(length):
        bv.write(start + i, b"\x90")
    bv.update_analysis_and_wait()

結語

深入研究反反組譯的技術對說是個很有趣的體驗,過程中會對於反組譯器如何運作以及如何針對他們的算法發展成一個可能的混淆方式都有更深的理解。雖然我認為這並不能阻擋任何「真正的逆向工程師」去理解這隻程式,不過至少可以減緩他們的速度。

同時這些技巧也很適合拿來出在 CTF 的題目裡面,對於學習組合語言也十分有幫助。

References


  1. 本文中所用到的 Binary Ninja 版本號為 5.2.8614-Stable;IDA Free 版本號為 9.0.241217 macOS 版。 ↩︎