iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Security

《Nyahello!從零開始的 30 天 malware 學習日誌》系列 第 28

【Day 28】Anti-Disassembly:反反組譯技術

  • 分享至 

  • xImage
  •  

前言

前面在進行 Malware Analysis 時,我們很依賴 IDA Pro 等 Disassembler,把 Machine Code 轉換成比較容易閱讀的 Assembly。

但有一個問題:

我們在 IDA 看到的 Assembly,一定就是 CPU 真正執行的程式嗎?

答案是不一定。

Malware 作者可以刻意設計特殊的 Code 或 Data,欺騙 Disassembler,使它從錯誤的位置開始解析 Instruction,最後呈現出與實際執行流程不同的結果。

這就是這章要介紹的 Anti-Disassembly

【聲明】由於時間問題本日文章由 AI 生成QQ


Understanding Anti-Disassembly

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

0xE8call 的 Opcode,但實際執行時前面的 jmp 會直接跳過它。

如果 Disassembler 錯把 E8 當成 Instruction 起點,就會把後面的 Bytes 一起當成 call 的 Operand,導致真正的 pushSleep 等 Code 被隱藏。

同一串 Bytes 因此可能產生完全不同的 Disassembly。


Defeating Disassembly Algorithms

要理解 Anti-Disassembly,首先要知道 Disassembler 怎麼工作。

書中主要分成兩種:

Linear Disassembly
Flow-Oriented Disassembly

Linear 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。


Flow-Oriented Disassembly

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 Pro 的錯誤 Disassembly

如果 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 時非常重要。


Anti-Disassembly Techniques

接下來就是這章的核心:Malware 到底如何欺騙 Disassembler?

Jump Instructions with the Same Target

第一種也是很常見的方法:

jz  loc_A
jnz loc_A

jzjnz 條件完全相反。

因此不管 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 跡象。


A Jump Instruction with a Constant Condition

另一種方法是製造一個看起來有兩條路,但實際只有一條路的 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 可以在永遠不會執行的位置放 E8E9 等 Rogue Byte,誘導 Disassembler 解析出假的 calljmp

分析時如果看到 Conditional Jump,可以往前追 Flag 是怎麼被設定的。有些「條件」其實早就已經確定。


Impossible Disassembly

前面的技巧至少還能靠 CD 修正。

但更麻煩的是 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 即可。


NOP-ing Out Instructions with IDA Pro

遇到大量 Anti-Disassembly Code 時,可以直接把沒有實際功能、只負責干擾分析的 Instruction Patch 成:

NOP

Opcode:

90

書中示範使用 IDAPython 的 PatchByte 將指定 Bytes 改成 0x90,甚至可以建立快捷鍵快速 NOP 掉目前的 Instruction。

這裡真正重要的觀念不是 Script 怎麼背,而是:

分析時不一定要忠實保留 Malware 原本的 Anti-Analysis Code。

如果已經確認某段 Code 只是 Obfuscation,可以 Patch 成邏輯等價但更容易閱讀的版本。


Obscuring Flow Control

Anti-Disassembly 不只可以讓 Instruction 顯示錯誤,也可以讓 IDA 看不懂:

Function 之間到底怎麼互相呼叫。

如此一來,Call Graph、Cross-reference、Function Argument 等分析結果都可能受到影響。


The Function Pointer Problem

例如正常呼叫:

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。


Return Pointer Abuse

一般來說:

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。


Misusing Structured Exception Handlers

更進階的方法是利用 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。


Thwarting Stack-Frame Analysis

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 的 CD、NOP Patch、手動補 Cross-reference 和 Stack Pointer Adjustment,都是處理 Anti-Disassembly 很重要的手段。

這章真正要建立的能力不是背下每一種 Trick,而是知道:當 Disassembly 看起來不合理時,要回到原始 Bytes 與真正的執行流程,判斷究竟是程式很奇怪,還是工具已經被騙了。 教材最後也強調,Anti-Disassembly 並不限於本章列出的技巧;只要 Disassembler 必須做出假設,就可能存在針對該假設的反分析方法。

本日貓味

https://ithelp.ithome.com.tw/upload/images/20260923/20183878TkJ9Vocm0E.jpg


上一篇
【Day 27】Malware Behavior:常見惡意程式行為整理
系列文
《Nyahello!從零開始的 30 天 malware 學習日誌》28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言