化實(shí)戰(zhàn):從符號(hào)執(zhí)行到逆向約束求解)
前幾篇筆記把 angr 的環(huán)境搭建、Project 加載、符號(hào)執(zhí)行的基本流程都過了一遍今天這篇單獨(dú)寫一個(gè)特別容易踩坑、但又特別出效果的點(diǎn)寄存器符號(hào)化。很多剛接觸符號(hào)執(zhí)行的朋友會(huì)有一個(gè)困惑我用 angr 跑一個(gè)二進(jìn)制明明已經(jīng)拿到了控制流為什么條件分支里的變量還是“看不懂”或者我想分析某個(gè)函數(shù)的輸入輸出關(guān)系但程序是從文件、網(wǎng)絡(luò)、硬件里讀數(shù)據(jù)參數(shù)根本不在內(nèi)存里我怎么讓 angr 知道“這里有一個(gè)未知值”答案就是寄存器符號(hào)化。把 CPU 寄存器從“具體的 0 或 1”變成“一個(gè)待求解的符號(hào)表達(dá)式”讓 angr 在模擬執(zhí)行時(shí)把這個(gè)符號(hào)一路攜帶、一路參與運(yùn)算、一路約束下來最后用求解器反推出滿足條件的取值。這篇筆記就圍繞這個(gè)主題展開適合已經(jīng)跑通 angr 基礎(chǔ) demo、但想深入做函數(shù)級(jí)分析的人。1. 寄存器符號(hào)化到底在解決什么問題1.1 具體執(zhí)行和符號(hào)執(zhí)行的差別先從一個(gè)最樸素的視角看起。普通調(diào)試器執(zhí)行程序時(shí)寄存器里裝的一定是具體數(shù)字比如eax 0x7fffffffe2a0、rcx 3。每一步運(yùn)算都會(huì)產(chǎn)生一個(gè)確定的新值我們順著執(zhí)行流走一遍能看到的只是“某一條路徑”上的結(jié)果??扇绻绦蛴?100 個(gè)條件分支每次輸入不同走的路徑就不同用具體執(zhí)行去試那真是大海撈針。符號(hào)執(zhí)行的思路完全不同它允許寄存器里裝的是一個(gè)“未知數(shù)”。比如我新建一個(gè)狀態(tài)把rdi設(shè)成符號(hào)變量x之后 CPU 執(zhí)行add rdi, 5時(shí)angr 不會(huì)去算出一個(gè)具體數(shù)字而是記下“此時(shí) rdi 的表達(dá)式是 x 5”。等執(zhí)行到某個(gè)判斷指令cmp rdi, 100時(shí)angr 會(huì)分別往兩個(gè)分支走一個(gè)分支里額外記錄約束x 5 100另一個(gè)分支里記錄約束x 5 ! 100。這個(gè)過程很像我們?cè)诟咧袛?shù)學(xué)里“帶著未知數(shù)做題”只不過符號(hào)執(zhí)行把每一步的表達(dá)式、每個(gè)分支的約束都自動(dòng)化地累加了下來最后交給 SMT 求解器angr 默認(rèn)用 Z3去解。寄存器符號(hào)化就是整個(gè)鏈條的起點(diǎn)告訴模擬器“這個(gè)寄存器里是一個(gè)我還沒定下來的值”。1.2 寄存器符號(hào)化的典型應(yīng)用場景寄存器符號(hào)化最常用的場景我總結(jié)成三類。第一類是逆向分析中的函數(shù)級(jí)分析。你拿到一個(gè)二進(jìn)制在 IDA 或 Ghidra 里看到一個(gè)關(guān)鍵函數(shù)sub_401120它的參數(shù)來源是程序前面復(fù)雜的計(jì)算或者干脆是外部設(shè)備的輸入。你不想把整個(gè)程序都跑一遍只想單獨(dú)分析這個(gè)函數(shù)的行為那就在函數(shù)入口處新建一個(gè)狀態(tài)把參數(shù)寄存器符號(hào)化然后只分析這個(gè)函數(shù)的邏輯。第二類是路徑條件還原。程序里有一段加密、校驗(yàn)或者關(guān)鍵判斷邏輯你希望知道“滿足什么條件才能走到某個(gè)分支”。這種情況下符號(hào)化的參數(shù)會(huì)被帶到分支判斷中angr 自動(dòng)收集路徑約束你最后用 solver 求出滿足條件的值這就是約束還原。第三類是物聯(lián)網(wǎng)固件、驅(qū)動(dòng)、Shellcode 這類“無操作系統(tǒng)上下文”的分析。這類樣本經(jīng)常直接從寄存器里拿數(shù)據(jù)比如從硬件寄存器讀取狀態(tài)、從某個(gè)約定的寄存器接收參數(shù)內(nèi)存里的狀態(tài)幾乎不可依賴。這時(shí)候寄存器符號(hào)化幾乎是唯一干凈地建立抽象輸入的方法。1.3 為什么偏偏是“寄存器”不是普通變量你可能會(huì)問符號(hào)執(zhí)行不是也能符號(hào)化內(nèi)存變量嗎為什么單獨(dú)把寄存器拎出來講因?yàn)榧拇嫫魇?CPU 與程序邏輯交互的最短路徑。函數(shù)調(diào)用約定規(guī)定參數(shù)先放寄存器系統(tǒng)調(diào)用通過寄存器傳遞調(diào)用號(hào)和參數(shù)硬件狀態(tài)通過寄存器暴露給軟件。你想給一個(gè)函數(shù)“喂輸入”本質(zhì)就是在給它“喂寄存器”。而內(nèi)存符號(hào)化通常要處理地址空間、大小端、棧布局稍不留神就和真實(shí)執(zhí)行對(duì)不上寄存器符號(hào)化則簡單得多一個(gè)state.regs.rax BVS就能解決。另一個(gè)實(shí)踐層面的原因是angr 對(duì)寄存器符號(hào)化的支持非常完善state.regs這個(gè)屬性可以按架構(gòu)自動(dòng)映射所有通用寄存器的名字和位寬還會(huì)處理別名關(guān)系比如rax和eax。這比手動(dòng)去構(gòu)造內(nèi)存地址、手動(dòng)讀寫棧要可靠得多。后面我會(huì)具體展開這塊。2. 動(dòng)手之前angr符號(hào)化必備基礎(chǔ)2.1 claripy與BVS/BVVangr 的符號(hào)表達(dá)式底層由名為 claripy 的庫實(shí)現(xiàn)。你不需要把 claripy 的所有 API 都背下來但兩個(gè)最基礎(chǔ)的概念必須清楚。第一個(gè)是BVS全稱 BitVectorSymbol用來創(chuàng)建一個(gè)符號(hào)位向量。它有兩個(gè)核心參數(shù)名稱和位寬。名稱只是給這個(gè)符號(hào)起的字符串名字通常是給人看的比如flag、arg0不一定需要唯一但實(shí)踐中建議起成有含義的名字因?yàn)楹罄m(xù)調(diào)試 AST 時(shí)你會(huì)靠名字辨認(rèn)變量。位寬必須是整數(shù)常見的有 8、16、32、64。創(chuàng)建一個(gè) 32 位符號(hào)變量寫法是import claripy x claripy.BVS(x, 32)第二個(gè)是BVV全稱 BitVectorValue用來創(chuàng)建一個(gè)具體值的位向量。比如claripy.BVV(10, 32)表示一個(gè) 32 位的具體數(shù)值 10。你可能覺得這不是多余嗎直接用 Python 的int不就行了不行。因?yàn)樵?claripy 的表達(dá)式體系里符號(hào)變量是 AST 節(jié)點(diǎn)具體值也要以 AST 節(jié)點(diǎn)形式參與運(yùn)算才能統(tǒng)一進(jìn)行位寬檢查和表達(dá)式化簡。如果你把一個(gè) Python int 直接塞給 angr 的某個(gè)寄存器可能會(huì)得到類型錯(cuò)誤或者在運(yùn)算時(shí)出現(xiàn)隱式位寬不匹配。類型名稱用途示例BVSBitVectorSymbol符號(hào)變量代表未知值claripy.BVS(x, 32)BVVBitVectorValue具體值節(jié)點(diǎn)claripy.BVV(0x10, 32)這兩個(gè)概念是所有符號(hào)化操作的基石。在 angr 的SimState里你通??梢灾苯佑胹tate.solver.BVS來創(chuàng)建符號(hào)變量它其實(shí)就是 claripy.BVS 的一層封裝效果完全一樣。2.2 SimState與state.regsangr 模擬執(zhí)行的核心對(duì)象是SimState中文常翻譯成“狀態(tài)”。一個(gè)狀態(tài)里包含了 CPU 寄存器、內(nèi)存、文件描述符、求解器、路徑約束等一大堆東西。你可以把狀態(tài)理解成“某一條執(zhí)行路徑在當(dāng)前時(shí)刻的完整快照”。操作寄存器的入口是state.regs。這是一個(gè)類似字典的屬性對(duì)象按寄存器名訪問或賦值。例如在 x86-64 架構(gòu)下state.regs.rax claripy.BVS(input, 64)這一行執(zhí)行后rax就變成了一個(gè) 64 位的符號(hào)變量。接下來任何指令只要讀取rax拿到的都是這個(gè)符號(hào)表達(dá)式的后續(xù)演化結(jié)果。讀取時(shí)也可以直接取state.regs.rax返回的是一個(gè) AST 對(duì)象它的類型是claripy.ast.BV。state.regs真正強(qiáng)大的地方在于它自動(dòng)處理了架構(gòu)差異。同樣是“棧指針”x86 里叫espARM 里叫spangr 都會(huì)映射到對(duì)應(yīng)架構(gòu)的寄存器集合里。你不需要記“這個(gè)架構(gòu)到底用哪個(gè)寄存器存返回值”只要用state.regs.eax或者state.regs.raxangr 會(huì)按照架構(gòu)的寄存器名約定進(jìn)行訪問。如果你的目標(biāo)架構(gòu)比較偏門還可以用state.arch.registers查看到底有哪些寄存器名可用。2.3 最小符號(hào)化骨架代碼一個(gè)最基礎(chǔ)的寄存器符號(hào)化流程可以壓縮成四步加載程序、創(chuàng)建狀態(tài)、符號(hào)化寄存器、求解。下面的代碼是一個(gè)可以照著改的最小骨架import angr import claripy proj angr.Project(./target_binary, auto_load_libsFalse) state proj.factory.blank_state(addr0x401120) state.regs.rcx claripy.BVS(arg0, 64) simgr proj.factory.simulation_manager(state) simgr.explore(find0x401156, avoid0x40116a) if simgr.found: found simgr.found[0] solution found.solver.eval(found.regs.rcx) print(hex(solution))這段代碼里有一個(gè)很多人一開始不理解的細(xì)節(jié)為什么用blank_state(addr...)而不是entry_state()因?yàn)閑ntry_state是從程序入口開始執(zhí)行你很少能直接控制入口處的寄存器值而blank_state允許你指定“從哪個(gè)地址開始模擬”并自由設(shè)置初始寄存器內(nèi)容。做函數(shù)級(jí)分析時(shí)blank_state配合一個(gè)函數(shù)入口地址是最干凈的組合。不過這個(gè)骨架非常粗糙真實(shí)場景里你會(huì)發(fā)現(xiàn)很多坑比如find地址找不準(zhǔn)、符號(hào)變量在某個(gè)指令里被偷偷截?cái)嗔?、求解器給出的解和預(yù)期不符等等。下面我用一個(gè)完整可復(fù)現(xiàn)的例子帶你一步步搞清楚寄存器符號(hào)化到底應(yīng)該怎么寫。3. 寄存器符號(hào)化的完整實(shí)操流程3.1 準(zhǔn)備一個(gè)可復(fù)現(xiàn)的目標(biāo)程序紙上談兵沒意思我寫一個(gè)足夠小、但包含真實(shí)邏輯的程序來演示。假設(shè)我們有這樣一個(gè) C 文件// mystery.c #include stdio.h int mystery(int a, int b) { if (a * b 30 a b 11) { return 1; } return 0; } int main(int argc, char **argv) { int a 3, b 7; printf(%d\n, mystery(a, b)); return 0; }這個(gè)mystery函數(shù)就是我們的分析目標(biāo)參數(shù)a、b通過寄存器傳入函數(shù)內(nèi)部有一個(gè)復(fù)合條件。我們希望做的是不修改程序、不手動(dòng)試值讓 angr 自己把滿足條件的a和b求出來。編譯時(shí)建議關(guān)掉優(yōu)化和 PIE方便找地址gcc -O0 -fno-pie -no-pie -o mystery mystery.c編譯后用nm mystery找到mystery函數(shù)的地址。在我本機(jī)上是0x401126你環(huán)境里的地址可能不同以后面nm輸出為準(zhǔn)。再反匯編看一眼結(jié)構(gòu)確認(rèn)兩個(gè)分支分別落在哪里。為了敘述方便我假設(shè)反匯編后的布局是這樣的0x401126mystery函數(shù)入口0x401156條件滿足時(shí)設(shè)置eax 1并返回的基本塊0x40116a條件不滿足時(shí)設(shè)置eax 0并返回的基本塊實(shí)際分析時(shí)請(qǐng)用objdump -d mystery確認(rèn)不要照抄我的地址。3.2 把未知參數(shù)寫進(jìn)寄存器在 angr 里給函數(shù)傳參除了最直接的state.regs.rdi a還有更規(guī)范的做法call_state。這個(gè)接口能按照目標(biāo)架構(gòu)的函數(shù)調(diào)用約定自動(dòng)把參數(shù)放到對(duì)應(yīng)的寄存器里并且自動(dòng)初始化返回地址和棧幀。對(duì) x86-64 來說mystery的兩個(gè)整數(shù)參數(shù)會(huì)分別放到rdi和rsi。import angr import claripy proj angr.Project(./mystery, auto_load_libsFalse) a claripy.BVS(a, 64) b claripy.BVS(b, 64) state proj.factory.call_state(0x401126, a, b)為什么符號(hào)變量用 64 位而不是 32 位因?yàn)樵?x86-64 調(diào)用約定里即使 C 語言函數(shù)參數(shù)是int參數(shù)寄存器也使用完整的 64 位寄存器rdi和rsi只是讀取時(shí)只使用低 32 位。如果你用 32 位符號(hào)變量賦給state.regs.rdiangr 會(huì)做隱式零擴(kuò)展后面求解時(shí)會(huì)出現(xiàn)一些不好理解的約束。先統(tǒng)一用 64 位符號(hào)變量配合int語義是相對(duì)省心的選擇。這里插一句我自己的經(jīng)驗(yàn)如果你不想關(guān)心調(diào)用約定也可以直接用blank_state加手動(dòng)賦值state proj.factory.blank_state(addr0x401126) state.regs.rdi a state.regs.rsi b兩種方式最終的效果差不多但call_state會(huì)自動(dòng)把返回地址等信息準(zhǔn)備好后續(xù)如果你想分析函數(shù)返回后的行為會(huì)方便很多。本篇以call_state為主線。3.3 設(shè)置探索目標(biāo)和規(guī)避地址符號(hào)化好參數(shù)寄存器之后我們讓 angr 從mystery函數(shù)入口開始跑。但這里有個(gè)關(guān)鍵點(diǎn)我們關(guān)心的不是“程序最終走到哪”而是“走到某個(gè)目標(biāo)地址時(shí)參數(shù)應(yīng)該滿足什么條件”。用符號(hào)執(zhí)行引擎自帶的路徑探索功能我們可以讓 angr 同時(shí)探索所有可能路徑并篩選出我們關(guān)心的那兩條路徑。find參數(shù)指定目標(biāo)地址avoid參數(shù)指定要避開的地址simgr proj.factory.simulation_manager(state) simgr.explore(find0x401156, avoid0x40116a) if simgr.found: found_state simgr.found[0] print(found state at, hex(found_state.addr))find和avoid都可以是地址列表也可以是帶addr屬性的函數(shù)。用函數(shù)會(huì)更靈活比如“當(dāng)eax 1時(shí)停下”但在我們的簡單例子里直接按地址找最直觀。走到這一步angr 內(nèi)部已經(jīng)發(fā)生了很多事情它沿著二進(jìn)制指令一條條模擬執(zhí)行每次遇到條件跳轉(zhuǎn)就分叉出一個(gè)新狀態(tài)每個(gè)狀態(tài)各自保存自己的路徑約束。mystery函數(shù)里那個(gè)復(fù)合條件會(huì)被 angr 拆成多個(gè)分支約束。我們找到的那個(gè)found_state里面的求解器已經(jīng)攜帶了“走到地址 0x401156 必須滿足的所有條件”。3.4 把符號(hào)解回具體值現(xiàn)在到了最關(guān)鍵也最讓人興奮的一步用求解器把符號(hào)變量解成具體數(shù)字。sol_a found_state.solver.eval(a) sol_b found_state.solver.eval(b) print(a , sol_a, b , sol_b)solver.eval的作用是在滿足當(dāng)前狀態(tài)所有約束的前提下給符號(hào)變量求出一個(gè)可行的具體解。在我的環(huán)境里輸出的結(jié)果是a 5、b 6正好滿足5 * 6 30且5 6 11。不過要注意eval只返回一個(gè)解而滿足條件的解可能不止一個(gè)。比如把函數(shù)條件改成a b 5那么解就有無數(shù)多個(gè)eval會(huì)隨便挑一個(gè)。如果你想拿到所有有代表性的值可以用eval(expr, n)一次求多個(gè)解或者用min、max求極值min_a found_state.solver.min(a) max_a found_state.solver.max(a)每個(gè)符號(hào)執(zhí)行狀態(tài)里默認(rèn)帶有一個(gè)solver它內(nèi)部維護(hù)著該狀態(tài)的全部路徑約束。你也可以用found_state.solver.add_constraint(...)手動(dòng)追加約束比如你額外知道“a 必須大于 0”就追加一條再重新求解。追加約束不會(huì)抹掉之前的路徑條件只是讓可行域縮小。到這里一個(gè)最完整的寄存器符號(hào)化流程就跑通了。你可能會(huì)覺得這個(gè)例子這么簡單手動(dòng)心算也能出結(jié)果。沒錯(cuò)但同樣的流程放到幾 MB 的二進(jìn)制、幾十個(gè)嵌套分支里手動(dòng)算是不可能的而寄存器符號(hào)化依然能穩(wěn)定地把約束收集起來這就是它的價(jià)值所在。4. 進(jìn)階玩法從單寄存器到整機(jī)狀態(tài)4.1 寄存器別名與高低位處理真正寫代碼時(shí)會(huì)遇到一個(gè)繞不開的細(xì)節(jié)x86-64 的寄存器別名。rax、eax、ax、al都指向同一塊物理寄存器只是位寬不同。angr 在state.regs里也模擬了這個(gè)行為但有一些細(xì)節(jié)必須注意。如果你先執(zhí)行state.regs.rax claripy.BVS(x, 64)然后讀取state.regs.eaxangr 會(huì)返回x的低 32 位表達(dá)式記作Extract(31, 0, x)。這沒問題。但反過來就有講究了如果你先執(zhí)行state.regs.eax claripy.BVS(y, 32) state.regs.rax ...x86-64 的硬件語義是寫 32 位寄存器會(huì)清零高 32 位angr 在大多數(shù)架構(gòu)下也模擬了這一點(diǎn)。這意味著你寫eax后再讀rax得到的并不是“一個(gè)符號(hào)變量 y 另一個(gè)保留的 rax 高位”而是“y 的零擴(kuò)展”。如果沒意識(shí)到這一點(diǎn)后面求解出來的值可能和你預(yù)期的完全不一樣。實(shí)操建議是在一個(gè)分析中對(duì)同一個(gè)物理寄存器始終使用同一種位寬訪問。如果你主要關(guān)注函數(shù)參數(shù)就都用 64 位符號(hào)變量如果你主要關(guān)注返回值的低 32 位就始終用 32 位讀取。混用別名是很多“莫名其妙”問題的根源。4.2 多寄存器協(xié)同約束單個(gè)寄存器符號(hào)化只是第一步。真實(shí)程序里一個(gè)函數(shù)的多個(gè)參數(shù)往往不是獨(dú)立的它們會(huì)共同參與同一個(gè)判斷條件。我們的mystery例子就是這樣——a和b必須同時(shí)滿足兩個(gè)等式。當(dāng)多個(gè)參數(shù)都是符號(hào)變量時(shí)angr 的路徑約束會(huì)自動(dòng)把它們的關(guān)聯(lián)關(guān)系帶上。你不需要手動(dòng)寫a b 11這樣的額外約束因?yàn)槟M執(zhí)行到cmp指令時(shí)angr 已經(jīng)自動(dòng)把a(bǔ) b 11這條約束加進(jìn)當(dāng)前路徑的求解器了。你要做的只是確保每個(gè)參數(shù)的符號(hào)變量在創(chuàng)建后沒有重復(fù)使用。這里有一個(gè)很容易踩的坑符號(hào)變量的“名字復(fù)用”。很多人會(huì)這樣寫param1 claripy.BVS(p, 64) param2 claripy.BVS(p, 64) state.regs.rdi param1 state.regs.rsi param2兩個(gè)符號(hào)變量名字都叫p但它們是兩個(gè)完全不同的 AST 對(duì)象。angr 不會(huì)因?yàn)樗鼈兠窒嗤驼J(rèn)為它們相等。如果你誤以為同名的符號(hào)變量一定相等最終求解結(jié)果會(huì)讓你困惑很久。正確的做法是給每個(gè)參數(shù)不同的名字并且全程復(fù)用同一個(gè)對(duì)象。4.3 與內(nèi)存符號(hào)化配合使用說完了寄存器再說說寄存器和內(nèi)存的連接。函數(shù)經(jīng)常先把參數(shù)寄存器存到棧上之后再從棧里取出來判斷代碼經(jīng)過編譯優(yōu)化后尤其常見。angr 在執(zhí)行mov [rbp-0x4], rdi時(shí)會(huì)自動(dòng)把符號(hào)表達(dá)式寫入對(duì)應(yīng)的內(nèi)存位置之后從該地址讀出的仍然是同一個(gè)符號(hào)表達(dá)式。這是符號(hào)執(zhí)行非常優(yōu)雅的一點(diǎn)符號(hào)值在寄存器和內(nèi)存之間搬運(yùn)時(shí)不會(huì)丟失其“身份”。更進(jìn)一步你完全可以混合使用寄存器符號(hào)化和內(nèi)存符號(hào)化。比如你想模擬一個(gè)結(jié)構(gòu)體第一個(gè)字段由寄存器傳入的某個(gè)值經(jīng)過計(jì)算得到第二個(gè)字段是固定的內(nèi)存值你就可以stack_var state.stack_load(8, claripy.BVS(stack_data, 64))這里stack_load會(huì)從當(dāng)前棧指針偏移 8 的位置讀入符號(hào)值并把這個(gè)地址標(biāo)記為已初始化。這樣內(nèi)存和寄存器同時(shí)帶著符號(hào)執(zhí)行起來就和真實(shí)程序一樣自然。4.4 還原入口參數(shù)約束的完整思路有了前面的基礎(chǔ)我分享一個(gè)真正在做逆向分析時(shí)非常有用的思路還原某個(gè)關(guān)鍵函數(shù)的入口參數(shù)約束。假設(shè)你有一個(gè)加密函數(shù)輸入是rdi指向的緩沖區(qū)、rsi是長度、rdx是密鑰你手里只有密文和程序想知道密鑰是什么。你可以這樣做找到加密函數(shù)入口地址用call_state創(chuàng)建狀態(tài)把rdi設(shè)成一個(gè)指向符號(hào)緩沖區(qū)的指針rsi設(shè)成具體的密文長度rdx設(shè)成符號(hào)密鑰再準(zhǔn)備一塊符號(hào)化的緩沖區(qū)往里面填入密文字節(jié)作為約束讓 angr 從函數(shù)入口執(zhí)行到函數(shù)返回在返回后的狀態(tài)里檢查緩沖區(qū)內(nèi)容是否等于明文把這個(gè)等號(hào)作為額外約束加入求解器用solver.eval(rdx)求出密鑰。這套方法聽起來很神奇但本質(zhì)上就是“寄存器符號(hào)化 內(nèi)存符號(hào)化 路徑約束 求解”的組合拳。你在自己的分析任務(wù)里也可以把“哪些輸入是已知的”設(shè)成具體值把“哪些輸入是未知的”設(shè)成符號(hào)變量剩下的事情交給 angr 自動(dòng)完成。5. 常見問題與避坑速查5.1 為什么符號(hào)化寄存器后程序直接崩了/狀態(tài)不可行符號(hào)化寄存器之后最常見的報(bào)錯(cuò)是unsat也就是狀態(tài)不可行。原因不外乎三類。第一類是初始約束就矛盾。比如你從一個(gè)blank_state開始前面已經(jīng)手動(dòng)加了state.regs.rax 10后面又符號(hào)化覆蓋了rax之前的約束還掛在求解器里新的符號(hào)對(duì)象和舊約束之間就會(huì)出現(xiàn)矛盾。遇到這種情況清理掉不需要的約束或者干脆新建狀態(tài)。第二類是參數(shù)位寬不匹配。你給一個(gè) 64 位寄存器賦了 32 位符號(hào)變量angr 自動(dòng)擴(kuò)展成零擴(kuò)展而程序指令卻按符號(hào)擴(kuò)展或者按有符號(hào)比較來用導(dǎo)致求解得到的值完全對(duì)不上。解決辦法是統(tǒng)一位寬。第三類是call_state調(diào)用方式不對(duì)。call_state(addr, arg1, arg2)這種寫法必須讓 arg 數(shù)量、類型和函數(shù)真實(shí)簽名匹配。如果函數(shù)實(shí)際有三個(gè)參數(shù)你只給了兩個(gè)angr 會(huì)拿未初始化的寄存器去湊這時(shí)候求解結(jié)果就會(huì)神游四方。用objdump看一眼函數(shù)開頭的幾條指令數(shù)一下到底用了哪些參數(shù)寄存器再回頭檢查調(diào)用。5.2 求解結(jié)果千奇百怪甚至符號(hào)消失了有時(shí)候你明明符號(hào)化了某個(gè)寄存器最后求出來的值卻像是一個(gè)固定常量或者多跑幾步就不見了。這通常不是 angr 的鍋而是某條指令把符號(hào)值“具體化”了。最常見的元兇是系統(tǒng)調(diào)用和庫函數(shù)調(diào)用。如果你分析的程序調(diào)用了外部函數(shù)而 angr 沒有對(duì)應(yīng)實(shí)現(xiàn)它可能返回一個(gè)未約束的具體值符號(hào)變量就斷了。所以我在例子里一直強(qiáng)調(diào)auto_load_libsFalse把外部庫加載關(guān)掉讓不認(rèn)識(shí)的調(diào)用直接返回未約束值而不是嘗試去執(zhí)行庫代碼。另一種情況是state.regs.rax state.solver.BVS(...)之后又被某條mov指令覆蓋了。比如程序開頭就有一條xor eax, eax你的符號(hào)變量在第一條指令就被清零了。排查這類問題時(shí)最有效的工具是打印 ASTprint(state.regs.rax) print(state.regs.rax.args)看輸出結(jié)果里你的符號(hào)變量名字是否還保留著。如果 AST 里已經(jīng)是一個(gè)常量說明在某一步符號(hào)被覆蓋了如果 AST 里出現(xiàn)了Extract、ZeroExt等節(jié)點(diǎn)說明有隱式位擴(kuò)展。很多時(shí)候問題一眼就能看出來。5.3 求解器跑得很慢怎么優(yōu)化符號(hào)求解的耗時(shí)是另一個(gè)大問題。我的經(jīng)驗(yàn)是約束越多、符號(hào)變量越多、位寬越大求解就越慢。尤其當(dāng)程序里循環(huán)次數(shù)多路徑約束會(huì)指數(shù)級(jí)累積一個(gè)很小的函數(shù)也可能讓 Z3 卡上幾十分鐘。幾個(gè)實(shí)用優(yōu)化手段盡量減少符號(hào)變量的數(shù)量。能具體化的輸入就具體化別偷懶全設(shè)成符號(hào)縮小寄存器位寬。比如int類型的參數(shù)用 32 位符號(hào)變量就行非要塞 64 位約束空間大一圈求解成本也上去用explore的avoid參數(shù)砍掉不關(guān)心的分支路徑越少狀態(tài)越少整體求解資源消耗就越低對(duì)單個(gè)狀態(tài)的求解操作可以加超時(shí)控制。angr 的 solver 支持timeout參數(shù)比如eval(expr, timeout10000)超時(shí)返回一個(gè)已知解或拋異常至少不會(huì)讓腳本無限卡死。5.4 我的寄存器值為什么對(duì)不上最后一種常見困惑是“我用 angr 解出來的值放到真實(shí)程序里跑結(jié)果不對(duì)”。我把它列成速查表現(xiàn)象可能原因排查方向求解出的值和預(yù)期差很大有符號(hào)/無符號(hào)不匹配檢查代碼里的條件跳轉(zhuǎn)是jg還是ja目標(biāo)分支找到了但參數(shù)解少得離譜函數(shù)入口地址找錯(cuò)了用nm或反匯編確認(rèn)入口是函數(shù)頭不是中間指令多個(gè)解互相沖突符號(hào)變量名或?qū)ο笾貜?fù)檢查是否復(fù)用了同一個(gè) BVS 對(duì)象解在多次運(yùn)行中不穩(wěn)定求解器選了不同可行解不用糾結(jié)用min/max或追加約束縮小范圍寄存器值總是在中途歸零某條指令覆蓋了寄存器打印相關(guān)地址的反匯編逐條檢查這套表是我在實(shí)際分析中一點(diǎn)點(diǎn)攢下來的基本上覆蓋了大部分新手會(huì)踩的坑。真正遇到詭異問題時(shí)先別懷疑 angr先懷疑自己的地址和約束。最后再分享一個(gè)小技巧我個(gè)人在實(shí)際操作中的一個(gè)體會(huì)是做寄存器符號(hào)化分析建議先別急著直接上完整的大二進(jìn)制而是自己動(dòng)手寫一個(gè)帶三四個(gè)嵌套分支的小程序編譯后用這個(gè)流程反復(fù)練。小程序的每個(gè)地址、每個(gè)分支都是你自己設(shè)計(jì)的遇到問題你能立刻判斷是 angr 的鍋還是自己思路的鍋。等小例子跑順了再拿 CTF 題或者真實(shí)逆向樣本去試會(huì)順手非常多。另外調(diào)試時(shí)一定要多習(xí)慣看 AST。很多人把state.regs.rax當(dāng)成普通 Python 值打印一下就是一個(gè)對(duì)象地址于是放棄。實(shí)際上print(state.regs.rax)會(huì)輸出一棵清晰可讀的表達(dá)式樹比如(x 5)、(Extract(31, 0, x))。學(xué)會(huì)讀這棵樹比看任何日志都管用。寄存器符號(hào)化這個(gè)主題入門看教程深入靠調(diào) AST動(dòng)手實(shí)操幾次就能找到感覺。