前面在進行 Malware Analysis 時,我們很依賴 IDA Pro 等 Disassembler,把 Machine Code 轉換成比較容易閱讀的 Assembly。
但有一個問題:
我們在 IDA 看到的 Assembly,一定就是 CPU 真正執行的程式嗎?
答案是不一定。
Malware 作者可以刻意設計特殊的 Code 或 Data,欺騙 Disassembler,使它從錯誤的位置開始解析 Instruction,最後呈現出與實際執行流程不同的結果。
這就是這章要介紹的 Anti-Disassembly。
【聲明】由於時間問題本日文章由 AI 生成QQ
Anti-Disassembly 的目的不是讓程式「不能執行」,而是:
程式可以正常執行,但分析工具無法正確理解。
這是因為 CPU 和 Disassembler 看待程式的方式不同。
CPU 只需要按照實際 Control Flow 執行 Instruction;Disassembler 則必須預先判斷:
哪些 Byte 是 Code?
哪些 Byte 是 Data?
Instruction 從哪一個 Byte 開始?
Jump 之後應該分析哪裡?
只要其中一個判斷錯誤,後面的 Assembly 就可能全部跟著錯。
例如:
jmp loc_3
db 0E8h
loc_3:
push 2Ah
call Sleep
0xE8 是 call 的 Opcode,但實際執行時前面的 jmp 會直接跳過它。
如果 Disassembler 錯把 E8 當成 Instruction 起點,就會把後面的 Bytes 一起當成 call 的 Operand,導致真正的 push、Sleep 等 Code 被隱藏。
同一串 Bytes 因此可能產生完全不同的 Disassembly。
要理解 Anti-Disassembly,首先要知道 Disassembler 怎麼工作。
書中主要分成兩種:
Linear Disassembly
Flow-Oriented Disassembly
Linear Disassembly 最簡單。
基本概念就是:
讀取 Instruction
↓
取得 Instruction Length
↓
移動到下一個 Byte
↓
繼續解析
它不太考慮程式真正的 Control Flow,而是一路往下解析。
問題在於 PE 的 .text Section 裡不一定全部都是 Code,也可能包含:
Pointer
Jump Table
Data
Linear Disassembler 可能把這些 Data 當成 Instruction。
因此只要 Malware 刻意放入像:
E8 → CALL
E9 → JMP
這種 Multi-byte Instruction 的 Opcode,就可能讓後面的 Byte 被一起吃掉,使真正的 Code 消失。
所以 Linear Disassembly 最大的問題就是:
很難區分 Code 和 Data。
IDA Pro 使用的則是比較進階的 Flow-Oriented Disassembly。
它不會單純一路往下解析,而是觀察:
JMP
JZ / JNZ
CALL
RET
等 Control Flow Instruction,再決定接下來哪些 Address 可能被執行。
例如遇到 Conditional Jump:
test eax, eax
jz loc_A
Disassembler 會知道有兩條可能路徑:
Jump Taken
Jump Not Taken
遇到 Unconditional jmp 時,則知道下一個 Byte 不一定會被執行。
這比 Linear Disassembly 準確很多,但仍然存在問題:
Flow-Oriented Disassembler 必須對程式的執行流程做出假設。
Malware 就可以利用這些假設欺騙它。
如果 IDA 把 Data 認成 Code,或把 Code 認成 Data,可以手動修正。
最重要的是:
C → Convert to Code
D → Convert to Data
例如 IDA 把一串 ASCII String 認成 Assembly,就可以先按 D 將錯誤的 Instruction 轉回 Data,再到真正的 Instruction Address 按 C。
這兩個快捷鍵在處理 Anti-Disassembly 時非常重要。
接下來就是這章的核心:Malware 到底如何欺騙 Disassembler?
第一種也是很常見的方法:
jz loc_A
jnz loc_A
jz 和 jnz 條件完全相反。
因此不管 Zero Flag 是:
ZF = 1 → JZ 跳
ZF = 0 → JNZ 跳
最後一定會到 loc_A。
也就是說,兩個 Conditional Jump 合起來其實等於:
jmp loc_A
但 Disassembler 通常一次分析一個 Instruction,不一定知道這兩個 Branch 合起來代表「一定會跳」。
Malware 就可以在兩個 Jump 後面塞入一個假的 Opcode,例如:
E8
讓 IDA 誤以為這裡有 call。
這種根本不會被執行、純粹拿來欺騙 Disassembler 的 Byte 稱為:
Rogue Byte
如果 IDA 的 Cross-reference 指向某個 Instruction 的「中間」,也是值得注意的 Anti-Disassembly 跡象。
另一種方法是製造一個看起來有兩條路,但實際只有一條路的 Conditional Jump。
例如:
xor eax, eax
jz loc_A
xor eax, eax 一定會讓:
EAX = 0
ZF = 1
所以後面的 jz 一定成立。
表面看起來是:
True Branch
False Branch
實際上:
True Branch → 一定執行
False Branch → 永遠不會執行
Malware 可以在永遠不會執行的位置放 E8、E9 等 Rogue Byte,誘導 Disassembler 解析出假的 call 或 jmp。
分析時如果看到 Conditional Jump,可以往前追 Flag 是怎麼被設定的。有些「條件」其實早就已經確定。
前面的技巧至少還能靠 C、D 修正。
但更麻煩的是 Impossible Disassembly。
問題來自 x86 的 Variable-length Instruction:
同一個 Byte 有可能同時屬於兩個真正會執行的 Instruction。
教材的例子:
EB FF C0 48
第一個 Instruction 的 Jump Target 會跳進自己原本 Instruction 的中間,而 FF 又成為下一個 Instruction 的起點。
CPU 可以正常執行這種重疊 Instruction,但一般 Disassembler 通常只能把一個 Byte 歸屬於一個 Instruction。
所以不存在一個傳統 Assembly Listing 能完美表示真正的執行流程。
實務分析時不用硬把所有內容完美還原,可以:
把無意義區域改成 Data
或
直接 Patch 成 NOP
保留真正影響 Malware Behavior 的 Code 即可。
遇到大量 Anti-Disassembly Code 時,可以直接把沒有實際功能、只負責干擾分析的 Instruction Patch 成:
NOP
Opcode:
90
書中示範使用 IDAPython 的 PatchByte 將指定 Bytes 改成 0x90,甚至可以建立快捷鍵快速 NOP 掉目前的 Instruction。
這裡真正重要的觀念不是 Script 怎麼背,而是:
分析時不一定要忠實保留 Malware 原本的 Anti-Analysis Code。
如果已經確認某段 Code 只是 Obfuscation,可以 Patch 成邏輯等價但更容易閱讀的版本。
Anti-Disassembly 不只可以讓 Instruction 顯示錯誤,也可以讓 IDA 看不懂:
Function 之間到底怎麼互相呼叫。
如此一來,Call Graph、Cross-reference、Function Argument 等分析結果都可能受到影響。
例如正常呼叫:
call sub_4011C0
IDA 很容易建立:
Caller → sub_4011C0
但如果先把 Function Address 放進 Variable:
mov [var_4], offset sub_4011C0
call [var_4]
IDA 不一定能正確知道這個 call 最後會進入哪個 Function。
結果就是:
Cross-reference 遺失
Function Prototype 無法傳遞
Call Graph 不完整
Function Pointer 使用得越多,Static Analysis 就越困難。
必要時可以使用 AddCodeXref 手動補上 IDA 遺失的 Cross-reference。
一般來說:
call function
...
ret
ret 是用來從 Function 返回 Caller。
但實際上 ret 做的事情很單純:
從 Stack Pop Address
↓
Jump 到該 Address
因此 Malware 可以自己修改 Stack 上的 Return Address,再執行 ret:
修改 Return Address
↓
RET
↓
跳到 Malware 想去的位置
這樣 ret 就變成一種隱藏版的 jmp。
IDA 可能因此:
找不到真正的 Code XREF
提前判定 Function 已經結束
判斷錯誤的 Function Boundary
教材的例子利用 call $+5 先把目前 Address 放上 Stack,再修改該 Address,最後用 ret 跳到真正的 Function。
修正時可以 NOP 掉這些干擾 Instruction,再手動調整 Function Boundary。
更進階的方法是利用 Windows 的:
Structured Exception Handling(SEH)
正常情況下 SEH 是處理:
Invalid Memory Access
Divide by Zero
其他 Exception
每個 Thread 都有一條 SEH Chain,其中包含 Exception Handler。
Malware 可以自己註冊 Handler:
push ExceptionHandler
push fs:[0]
mov fs:[0], esp
接著故意製造 Exception,例如:
xor ecx, ecx
div ecx
因為:
ECX = 0
↓
Divide by Zero
↓
Exception
↓
執行 Malware 設定的 Handler
這樣 Malware 就利用 Exception 完成了一次隱藏的 Control Flow Transfer。
IDA 只看 Static Control Flow 時,可能完全看不到:
原本 Code → Exception Handler
的關係,甚至把真正的 Handler 當成 Data。
所以看到:
FS:[0]
SEH 操作
刻意產生 Exception
時,就要考慮它是不是在利用 SEH 隱藏 Control Flow。
IDA 不只分析 Control Flow,也會自動分析 Stack Frame,幫我們辨認:
Arguments
Local Variables
ESP / EBP
Function Prototype
Malware 也可以故意破壞這套分析。
例如設計一個 Conditional Branch,讓 IDA 認為:
ESP 可能 +4
ESP 也可能 +104h
IDA 必須選擇其中一種可能,選錯之後整個 Stack Frame 就會跟著錯。
教材的例子甚至讓 IDA 誤判 Function 有 62 個 Arguments,實際上它根本沒有 Argument,只有兩個 Local Variables。
這也可能進一步影響:
Hex-Rays Decompiler
因為 Pseudocode 很依賴正確的 Function 與 Stack Frame Analysis。
遇到 Stack Pointer 分析錯誤,可以使用:
ALT-K
手動修正 Stack Pointer,也可以直接 Patch 掉造成錯誤判斷的 Instruction。
Chapter 15 最重要的觀念其實只有一句:
不要完全相信 Disassembler 顯示給你的結果。
Disassembler 必須對 Code、Data、Control Flow、Function Boundary 和 Stack Frame 做出大量假設,而 Malware 作者就是利用這些假設進行 Anti-Disassembly。
這章介紹的主要技巧可以整理成:
Rogue Byte
→ 讓 Disassembler 從錯誤 Offset 開始解析
JZ + JNZ Same Target
→ 看似 Conditional,實際一定 Jump
Constant Condition
→ 製造永遠不會執行的 Fake Branch
Overlapping Instructions
→ 同一 Byte 屬於不同 Instruction
Function Pointer
→ 隱藏 Function Call 關係
Return Pointer Abuse
→ 用 RET 當作隱藏的 JMP
SEH Abuse
→ 用 Exception Handler 隱藏 Control Flow
Stack-Frame Manipulation
→ 讓 IDA 誤判 Arguments 與 Local Variables
因此遇到奇怪的 Assembly 時,不要立刻認為是自己看不懂。可以先檢查 Jump Target、Opcode Bytes、Cross-reference、Stack Pointer,以及實際 Runtime Control Flow。
IDA Pro 的 C、D、NOP Patch、手動補 Cross-reference 和 Stack Pointer Adjustment,都是處理 Anti-Disassembly 很重要的手段。
這章真正要建立的能力不是背下每一種 Trick,而是知道:當 Disassembly 看起來不合理時,要回到原始 Bytes 與真正的執行流程,判斷究竟是程式很奇怪,還是工具已經被騙了。 教材最後也強調,Anti-Disassembly 並不限於本章列出的技巧;只要 Disassembler 必須做出假設,就可能存在針對該假設的反分析方法。
