Featured image of post 能從執行檔還原出原始碼嗎?:反編譯(Decompilation)的原理、演算法與極限

能從執行檔還原出原始碼嗎?:反編譯(Decompilation)的原理、演算法與極限

為什麼將機器碼二進位檔(.exe或ELF)還原為C/C++原始碼被稱為「把炒雞蛋還原成生雞蛋」?深入解析線性掃描與遞迴下降反組譯、中間表示(IR)提升與靜態單賦值(SSA)轉換、基於格理論的型別推導、支配樹與無goto控制流結構化(DREAM)、編譯器最佳化偽裝、程式碼混淆(OLLVM/VMProtect)與大型語言模型神經反編譯。

1. 引言:能把「炒雞蛋」還原回「生雞蛋」嗎?

在日常軟體開發中,我們用 C、C++ 或 Rust 編寫人類易讀的高階語言原始碼,並透過編譯器(GCC、Clang/LLVM、MSVC)將其編譯為機器碼執行檔(Windows 的 .exe PE 檔或 Linux 的 ELF 二進位檔)。編譯過程將抽象語法樹(AST)、豐富的型別系統和具表現力的變數名稱翻譯為處理器直接執行的位元組流(如 x86-64 或 ARM64 指令集)。

那麼,當我們只有一份遺失了原始碼並剝離了符號表(strip)的二進位檔時——例如資安研究員分析勒索軟體樣本、工程師維護 20 年前的遺留系統、或漏洞挖掘人員審計閉源驅動程式——我們能否將機器碼輸入反編譯器(Decompiler),完整還原出最初的 C 語言原始碼?

逆向工程界有一句名言: 「編譯就像把雞蛋打碎炒成歐姆蛋(Omelet);而反編譯則是試圖把歐姆蛋重新還原成帶殼的生雞蛋。」

從數學與資訊理論的角度來看,編譯函數 $\mathcal{C}: \text{Source} \to \text{Binary}$ 是一個有損的「多對一(Many-to-One)」非單射映射。無數份變數名稱不同、註解不同、迴圈寫法不同(for 與 while)甚至行內函數結構不同的原始碼,在經過 -O2 或 -O3 最佳化編譯後,會生成完全相同的位元組序列。由於條件熵 $H(\text{Source} \mid \text{Binary}) > 0$,理論上不存在唯一的逆函數 $\mathcal{C}^{-1}$ 能百分之百恢復被抹去的資訊。更嚴峻的是,根據萊斯定理(Rice’s Theorem)與圖靈停機問題,精確區分程式碼與行內資料、靜態解析間接跳躍 jmp rax 的全部目標位址、以及判定任意指標別名(Pointer Aliasing),在數學上都是不可判定的(Undecidable)。

然而,在現代工業界,IDA Pro (Hex-Rays)、美國國家安全局(NSA)開源的 Ghidra、Binary Ninja 和 angr 等反編譯器每天都在創造奇蹟,將數百萬位元組的機器碼還原為結構清晰、可讀性極高的類 C 虛擬碼。本文將深入剖析現代反編譯器的六大核心演算法階段。


2. 編譯過程抹去了什麼?:正向編譯管線與資訊坍縮

  1. 前端(詞法/語法/語義分析 $\to$ IR):註解、#define 巨集名稱、typedef 別名、區域變數名稱以及 for/while 等語法糖在生成中間表示(LLVM IR)時首先消失。
  2. 中端最佳化器(-O2 / -O3):函數行內展開(Inlining)抹去了被呼叫函數的邊界;迴圈展開(Loop Unrolling)與 SIMD 向量化改變了控制流結構;強度折減(Strength Reduction)將直觀的整數除法 x / 10 替換為難以辨認的定點魔數乘法 imul 0x66666667。
  3. 後端暫存器分配與符號剝離(strip):無限個虛擬暫存器透過圖著色演算法映射到 x86-64 僅有的 16 個通用物理暫存器上。同一個物理暫存器 rax 在同一函數的不同階段被反覆複用於完全無關的整數、指標甚至結構體欄位;而 strip 命令則徹底刪去了 .symtab 符號表與 DWARF 除錯資訊。
  flowchart TD
    subgraph Forward ["正向編譯 (不可逆的資訊遺失)"]
        S1["高階語言原始碼 (.c / .cpp / .rs)<br/>變數名、結構體定義、結構化迴圈"]
        S2["中間表示 (LLVM IR)<br/>語法糖消除、函數行內展開、常數傳播"]
        S3["剝離符號的機器碼 (.exe / ELF)<br/>物理暫存器複用、型別擦除、平坦跳躍"]
        S1 -->|"前端"| S2 -->|"最佳化器與後端"| S3
    end

    subgraph Backward ["逆向反編譯 (演算法與推論重建)"]
        D1["1. 反組譯 (Disassembly)<br/>位元組流提取指令 (線性掃描 vs 遞迴下降)"]
        D2["2. IR 提升與 SSA 轉換<br/>顯式旗標位、暫存器生命週期拆分、Def-Use 鏈"]
        D3["3. 堆疊框分析與格型別推導<br/>恢復指標、結構體偏移、有號/無號型別"]
        D4["4. 控制流結構化 (AST 重建)<br/>支配樹與無 goto 區域摺疊 (DREAM)"]
        D1 --> D2 --> D3 --> D4
    end

    S3 ==>|"輸入反編譯器"| D1

3. 階段一:反組譯(Disassembly)——如何區分指令與資料?

在 x86-64 這樣指令長度從 $1$ 到 $15$ 位元組不等的變長指令集架構中,編譯器常在 .text 程式碼區段內部直接嵌入唯讀資料(如 switch 敘述的跳躍表 Jump Table)。一旦反組譯器錯把 4 位元組位址資料當成指令解碼,就會破壞後續真實指令的邊界同步(重疊指令問題)。

  1. 線性掃描法(Linear Sweep,如 objdump -d):從程式碼區段首位元組順序向後解碼,簡單快速,但會把程式碼中間嵌入的跳躍表錯誤解碼為偽指令。
  2. 遞迴下降法(Recursive Traversal,如 IDA Pro、Ghidra):從進入點(main、匯出函數)出發,沿著控制流分支指令(je, jmp, call)的目標位址進行圖遍歷解碼,自動跳過不會直接順序執行的跳躍表資料。為了解決間接跳躍(jmp [rbx + rcx*8]),反編譯器需結合後向程式切片(Backward Slicing)與值集分析(Value-Set Analysis, VSA),找到前方的邊界檢查 cmp rcx, 12; ja default 來推導出跳躍表的大小。

4. 階段二:中間表示提升(IR Lifting)與靜態單賦值(SSA)

原生組合語言指令充滿了隱式副作用(例如 x86 的一行 sub eax, ebx 會同時改寫 ZF, CF, SF, OF, PF, AF 六個狀態旗標位)。反編譯器首先將不同架構的機器碼翻譯(Lifting)為統一的微碼/中間表示(如 Ghidra 的 P-code 或 Hex-Rays 的 Microcode),隨後將其轉換為靜態單賦值形式(SSA Form)——每個變數僅被賦值一次,控制流匯合處插入 $\phi$ 函數 $\text{rax}_3 = \phi(\text{rax}_1, \text{rax}_2)$:

  • 死旗標位消除(Dead Flag Elimination):90% 以上未被後續條件跳躍引用的狀態旗標計算在 Def-Use 圖上出度為 $0$,可在 $O(1)$ 時間內全部清除。
  • 運算式傳播(Expression Propagation):將旗標定義 ZF_2 = (eax_2 == 0) 直接代入 if (ZF_2),還原出高階條件運算式 if (eax_2 == 0)。
  • 暫存器生命週期拆分(Live-Range Splitting):將複用同一個物理暫存器 rax 但互不連通的 SSA 定義-使用網(Webs)自動拆分為獨立的 C 語言區域變數 int v1; 和 struct Node *v2;!

5. 階段三:堆疊框重建與基於格(Lattice)的型別推導

雖然機器碼沒有顯式型別,但 CPU 指令本身的語義蘊含著嚴格的代數型別約束(Type Constraints):

  • 有號(Signed)與無號(Unsigned)判定:條件跳躍 jg / jl(Greater/Less)、有號除法 idiv、算術右移 sar 和符號擴展 movsxd 證明運算元為有號整數(int);而 ja / jb(Above/Below)、無號除法 div、邏輯右移 shr 和零擴展 movzx 則證明運算元為無號整數(unsigned int)。
  • 指標、結構體與陣列重建:記憶體解參考 [rbx] 產生指標約束 $\text{ptr}(\tau)$;對同一基址暫存器的多個常數偏移存取 [rbx + 0], [rbx + 8], [rbx + 16] 揭示出 rbx 是指向複合結構體 struct S * 的指標;而比例變址 [rbx + rcx*4] 則對應元素大小為 4 位元組的陣列。
  • 格理論與合一求解器(Lattice Type Inference):反編譯器(參考 Mycroft 1999 與 CMU TIE 系統)將所有型別組織為從頂元 $\top$(未約束位元向量)到底元 $\bot$(型別衝突/union)的偏序格 $(\mathcal{L}, \sqsubseteq)$,收集全函數 SSA 指令的子型別不等式並求最大下界(Meet $\sqcap$),從而全域一致地推導出每個區域變數與函數參數的型別。
  flowchart TD
    TOP["⊤ (Top: 原始 N 位元向量 bit64)"]
    NUM["num64 (整數或指標)"]
    FLT["float64 (倍精度浮點: addsd, mulsd)"]
    SINT["int64_t (有號整數: sar, idiv, jg, jl)"]
    UINT["uint64_t (無號整數: shr, div, ja, jb)"]
    PTR["ptr(τ) (指標型別: [reg] 記憶體解參考)"]
    STRUCT["struct S* (結構體指標: [reg + offset] 多偏移存取)"]
    BOT["⊥ (Bottom: 型別衝突 → 強制轉型或 union)"]

    TOP --> NUM
    TOP --> FLT
    NUM --> SINT
    NUM --> UINT
    UINT --> PTR
    PTR --> STRUCT
    SINT --> BOT
    STRUCT --> BOT
    FLT --> BOT

6. 階段四:控制流結構化(Control-Flow Structuring)——從 goto 圖還原 if 與 while

為了將平坦的控制流圖(CFG)摺疊回巢狀的高階語言抽象語法樹(AST),反編譯器(源自 Cristina Cifuentes 1994 年的奠基性工作)利用圖論中的支配樹(Dominator Tree)與後支配樹(Post-Dominator Tree):

  1. 自然迴圈識別:若邊 $n \to h$ 的目標節點 $h$ 支配源節點 $n$($h \text{ dom } n$),則該邊為回邊(Back-Edge),定義了以 $h$ 為頭節點的自然迴圈(出口在頭部為 while/for,出口在尾部為 do-while)。
  2. 分支合併點識別:條件分支節點 $b$ 的直接後支配者(Immediate Post-Dominator)恰好是 if-then-else 菱形區域的匯合點。
  3. 無 goto 結構化(DREAM 演算法):針對編譯器激進最佳化產生的多入口不可約圖(Irreducible CFG),現代 DREAM (“No More Gotos”, Yakdan et al. 2015) 演算法透過計算節點間的布林可達條件(Reaching Conditions)並進行邏輯化簡,能夠生成 100% 不含 goto 敘述的整潔 C 程式碼!

7. 反編譯的極限:最佳化偽裝、程式碼混淆與大型語言模型(LLM4Decompile)

程式特徵維度未最佳化帶除錯資訊 (-O0 -g)發行版最佳化剝離 (-O3 + strip)強程式碼混淆 (OLLVM / VMProtect)
函數名、變數名與型別名從 DWARF/PDB 100% 完整恢復永久擦除(退化為 sub_401000, v1)擦除 + 注入誤導性偽符號
常數除法(如 x / 10)直接對應 idiv 指令魔數乘法($\lfloor \text{0x66666667} \cdot x / 2^{34} \rfloor$)混合布林算術(MBA)多項式展開
控制流結構(if / while)完美摺疊為結構化 AST迴圈展開與 AVX-512 向量化變形控制流平坦化 (CFF) 變成單一 switch
指令集語義(ISA)原生 x86-64 / ARM64原生 x86-64 / ARM64虛擬化保護:隨機自訂位元組碼直譯器

惡意軟體與商業殼(如 OLLVM、VMProtect)透過控制流平坦化(Control-Flow Flattening)、數論不透明謂詞(Opaque Predicates)、混合布林算術(MBA,$x+y \equiv (x\oplus y) + 2(x\land y)$)以及將原生指令編譯為自訂隨機位元組碼的虛擬機器保護(Virtualization Obfuscation)來對抗靜態反編譯。而在 2020 年代,以 LLM4Decompile 為代表的神經反編譯器(Neural Decompilation)正將傳統形式化分析與大型語言模型相結合:利用數億對原始碼-二進位樣本訓練的模型,不僅能將晦澀的最佳化程式碼重述為自然的 C 慣用寫法,還能根據上下文高機率地「猜回」富有語義的函數名與變數名,將逆向工程推向全新的智慧時代。

comments powered by Disqus