化:angr符號(hào)執(zhí)行的關(guān)鍵技巧)
1. 問(wèn)題起源為什么單獨(dú)把“malloc地址單元符號(hào)化”拎出來(lái)講寫這一篇純屬被坑出來(lái)的。前面幾篇筆記里我一直在處理“標(biāo)準(zhǔn)輸入符號(hào)化”“argv符號(hào)化”這類常規(guī)操作當(dāng)時(shí)覺得 angr 已經(jīng)很順手了直到遇到一個(gè)程序邏輯看起來(lái)非常簡(jiǎn)單就是讀入一段數(shù)據(jù)經(jīng)過(guò)一個(gè)校驗(yàn)函數(shù)對(duì)了就打印 flag。結(jié)果 angr 跑下來(lái)路徑倒是探索了一堆但求解器死活給不出一個(gè)合法的輸入。最后排查了半天問(wèn)題就出在 malloc 分配的地址內(nèi)容沒(méi)有被符號(hào)化上。這里先解釋一下背景。符號(hào)執(zhí)行的本質(zhì)是把輸入變成符號(hào)變量讓程序帶著符號(hào)跑碰到分支就把條件收集起來(lái)最后交給約束求解器去解。但這里的“輸入”是有范圍的你符號(hào)化的是 stdin、argv 還是某個(gè)全局緩沖區(qū)angr 只會(huì)把明確標(biāo)成 BVSBitVectorSymbolic的那部分內(nèi)存當(dāng)成“可變”的其余內(nèi)存內(nèi)容 angr 會(huì)按具體值處理。malloc 出來(lái)的堆區(qū)怎么處理就很微妙了。它不像棧上聲明的數(shù)組有明確的地址和大小malloc 返回的地址在符號(hào)執(zhí)行里是動(dòng)態(tài)生成的angr 默認(rèn)不知道你這個(gè)地址指向的內(nèi)存區(qū)域代表什么含義更不會(huì)主動(dòng)給這塊內(nèi)存灌入符號(hào)變量。結(jié)果就是程序從堆區(qū)讀數(shù)據(jù)、做比較angr 真以為堆里那些字節(jié)是“固定值”于是產(chǎn)生了大量不可能滿足的條件分支。這也就引出了本篇筆記要講的方案如何讓 malloc 分配出來(lái)的那塊地址空間其內(nèi)容也成為符號(hào)變量從而讓符號(hào)執(zhí)行真正覆蓋到程序的核心校驗(yàn)邏輯。這篇筆記適合兩類人看。一類是剛接觸 angr、只跑通過(guò)幾道入門題準(zhǔn)備挑戰(zhàn)真實(shí)程序的人malloc 符號(hào)化是繞不過(guò)去的一關(guān)。另一類是已經(jīng)會(huì)用 angr 但經(jīng)常遇到“求解無(wú)解”“分支全走死”這類問(wèn)題、排查無(wú)頭緒的人。本篇會(huì)從原理講到實(shí)操最后給出排查技巧核心是一個(gè)可復(fù)現(xiàn)的 Demo萬(wàn)金油思路。2. 能力鋪墊angr 符號(hào)執(zhí)行里“內(nèi)存狀態(tài)”的本質(zhì)2.1 從符號(hào)變量到約束求解angr 到底在做什么angr 跑一個(gè)程序時(shí)維護(hù)的是一個(gè)“符號(hào)狀態(tài)”。這個(gè)狀態(tài)可以理解成一張大表記錄了當(dāng)前執(zhí)行到的指令地址、寄存器的值、內(nèi)存中每個(gè)地址的內(nèi)容。這些“值”有兩種形態(tài)一種是確定的具體數(shù)值比如0x401000另一種是符號(hào)變量比如一個(gè) 32 位的BVS(input_0, 32)它不是一個(gè)固定值而是一個(gè)“待定值”。angr 在模擬執(zhí)行過(guò)程中遇到分支指令比如cmp; jz時(shí)會(huì)同時(shí)探索兩個(gè)方向每條路徑上都會(huì)記錄一個(gè)路徑條件。比如某條路徑可能記錄了input_0 0x41另一條路徑可能記錄了input_0 0x41。到最后你想讓某一條路徑走到 “success” 分支就把這條路徑上所有的路徑條件收集出來(lái)連同你自己手動(dòng)添加的其他約束一起交給底層求解器z3去解。z3 會(huì)返回一個(gè)具體的數(shù)值使得這些條件全部成立。這個(gè)數(shù)值就是讓程序走進(jìn)目標(biāo)分支的輸入。這個(gè)模型看似簡(jiǎn)單但注意一個(gè)關(guān)鍵點(diǎn)約束求解器只能基于符號(hào)變量去求解。如果一個(gè)值不是符號(hào)變量而是一個(gè)具體常量那 angr 是不可能“反過(guò)來(lái)算”出一個(gè)輸入去改變它的。說(shuō)得夸張一點(diǎn)如果你壓根沒(méi)把一個(gè)內(nèi)存位置標(biāo)記成符號(hào)那 angr 眼里它就永遠(yuǎn)是某個(gè)固定值哪怕程序邏輯上它初始依賴 stdin。所以 malloc 地址單元符號(hào)化本質(zhì)上就是在回答一個(gè)問(wèn)題堆上這塊內(nèi)存的“值”能不能隨輸入變化如果能angr 才有機(jī)會(huì)通過(guò)路徑條件反推出輸入如果不能那 angr 就只能看見一個(gè)死板的具體值路徑探索也戛然而止。2.2 malloc 在符號(hào)執(zhí)行里是“黑盒”問(wèn)題就出在這真實(shí)程序里 malloc 可不是拿來(lái)看的。常見模式是程序從標(biāo)準(zhǔn)輸入讀固定字節(jié)數(shù)存到 malloc 得到的緩沖區(qū)里然后對(duì)這個(gè)緩沖區(qū)做字節(jié)變換、查表、比較。比如下面這類簡(jiǎn)化邏輯char *buf malloc(10); read(0, buf, 10); if (buf[0] f buf[1] l buf[2] a buf[3] g) { puts(correct); }如果是在真實(shí)系統(tǒng)上跑read會(huì)從 stdin 往 buf 指向的地址填充數(shù)據(jù)。但在 angr 里問(wèn)題出現(xiàn)了angr 默認(rèn)的read系統(tǒng)調(diào)用如果遇到未知的文件描述符通常是提前處理后返回確定值或觸發(fā)異常具體行為取決于 angr 對(duì)read的模擬方式。即使你通過(guò)SimFile或者 hook 方式把 stdin 提供了也存在一個(gè)更深層的坑angr 的read模擬是寫在系統(tǒng)調(diào)用層面的它的“目標(biāo)地址”是 malloc 返回的那個(gè)地址。而 malloc 在 angr 的 SimHeap 里大多被 hook 成返回某個(gè)地址但這個(gè)地址指向的內(nèi)存并沒(méi)有被初始化為符號(hào)。于是你在狀態(tài)里做了state.solver.BVS(stdin, 10*8)以為萬(wàn)事大吉實(shí)際上 angr 執(zhí)行read時(shí)確實(shí)把符號(hào)變量“拷貝”到了 malloc 地址上但前提是你正確配置了 stdin 的文件描述符并且 angr 把這次 read 識(shí)別為讀符號(hào)數(shù)據(jù)。如果程序不是走read而是先從 stdin 讀到一個(gè)臨時(shí)棧緩沖再手動(dòng)memcpy到堆區(qū)那 angr 在 memcpy 時(shí)是逐字節(jié)復(fù)制符號(hào)變量的這種情況反而沒(méi)事。真正出問(wèn)題的場(chǎng)景是程序校驗(yàn)邏輯直接作用在 malloc 內(nèi)容上但 malloc 的內(nèi)容來(lái)源不是 angr 認(rèn)識(shí)的符號(hào)通道而是來(lái)自程序內(nèi)部的某種初始化邏輯。舉個(gè)典型例子程序從 stdin 讀入一串十六進(jìn)制字符串strtol轉(zhuǎn)換成整數(shù)再當(dāng)作種子去初始化一個(gè)偽隨機(jī)序列最后用這個(gè)序列去校驗(yàn)堆區(qū)的某個(gè)數(shù)據(jù)。strtol、srand、rand這些函數(shù)在 angr 里的模擬如果不做額外處理會(huì)產(chǎn)生具體數(shù)值所以堆區(qū)數(shù)據(jù)就不可能被符號(hào)化。這種情況下你必須跳過(guò)“生成鏈條”直接在 malloc 地址單元上做符號(hào)化——人為聲明“這塊內(nèi)存的內(nèi)容是符號(hào)變量”。2.3 為什么“符號(hào)化地址”不等于“符號(hào)化內(nèi)容”這里有必要區(qū)分兩個(gè)概念。一個(gè)是把 malloc 返回的指針值符號(hào)化一個(gè)是把 malloc 指向的內(nèi)存內(nèi)容符號(hào)化。很多入門教程講“malloc 符號(hào)化”時(shí)說(shuō)的是前者讓 malloc 的返回值成為一個(gè)符號(hào)變量這樣能讓程序走不同的堆分配分支比如檢查 malloc 是否失敗。字符串比較常見的一個(gè)坑是在分析帶堆噴的程序時(shí)需要讓 malloc 返回不同的地址來(lái)探索堆布局分支。但本文討論的“malloc 地址單元符號(hào)化”指的是后者malloc 返回的地址是確定的或可計(jì)算的但該地址上的內(nèi)存單元內(nèi)容是符號(hào)化的。打個(gè)比方你和朋友約好在一家餐廳見面地址是固定的但你不知道朋友在那家餐廳坐哪一桌內(nèi)容你想通過(guò)一定規(guī)則反推出來(lái)。符號(hào)化內(nèi)容就是給“坐哪一桌”這個(gè)變量建立數(shù)學(xué)模型讓求解器去算。兩者在 angr 里的具體操作位置也不同符號(hào)化返回值需要 hook malloc 本身符號(hào)化地址內(nèi)容需要在 malloc 之后拿到地址再用 store 操作往這個(gè)地址寫入 BVS。理解了這一點(diǎn)后面看別人代碼就不容易暈。3. 方案選型四種“讓 malloc 區(qū)域符號(hào)化”的方式對(duì)比3.1 方式一hook malloc拿到返回值后再 store BVS最直接的做法通過(guò)project.hook_symbol(malloc, hook_func)劫持 malloc 符號(hào)在 hook 函數(shù)里先返回一塊地址通常用state.heap相關(guān)接口或直接返回一個(gè)固定地址然后立刻在這個(gè)地址上 store 一個(gè) BVS。這樣程序后續(xù)對(duì)這塊地址的讀取就讀到了符號(hào)變量。import angr import claripy def malloc_hook(state): # 手動(dòng)指定一塊地址避開已有內(nèi)存區(qū)域 addr 0x5000000 # 只是一個(gè)示例地址實(shí)際要選堆區(qū) size 0x40 # 讓 malloc 返回這個(gè)地址 state.regs.rax addr # 在這塊區(qū)域?qū)懭?64 字節(jié)的符號(hào)值 sym claripy.BVS(heap_data, 8 * size) state.memory.store(addr, sym)這種方式的好處是直觀你能控制全流程。壞處也很明顯一個(gè)程序里不止一處調(diào)用 malloc如果你全都 hook 到一個(gè)統(tǒng)一處理所有 malloc 區(qū)域內(nèi)容都一樣同一個(gè) BVS邏輯就亂了。所以這種方式更適合目標(biāo)明確、malloc 調(diào)用點(diǎn)不多的情況一般實(shí)驗(yàn)里也就一兩個(gè)關(guān)鍵 malloc。3.2 方式二在輸入符號(hào)化階段同步填充堆區(qū)域不少初學(xué)者看到“malloc 地址單元符號(hào)化”第一反應(yīng)是我先不碰 malloc直接把符號(hào)變量寫到那個(gè)地址不就行了比如你知道 malloc 返回地址的規(guī)律像 glibc 的 fastbin 在固定偏移就硬編碼一個(gè)地址在initial_state里storeBVS。這種方式在很多 CTF 題目里很有效因?yàn)轭}目程序簡(jiǎn)單甚至不校驗(yàn) malloc 的返回值。不過(guò)這種方式有個(gè)前提你必須知道 malloc 返回地址到底是多少。angr 里 malloc 的默認(rèn) implementation 通常返回一塊固定的模擬堆地址每次運(yùn)行基本一致。但不同版本的 angrSimHeap 的地址策略不一樣你硬編碼的地址可能會(huì)踩到 angr 給?;蛉謪^(qū)域預(yù)留的空間。穩(wěn)妥起見可以在 setup 階段 hook 住 malloc打印返回地址再?zèng)Q定往哪填充。3.3 方式三利用 angr 的 SimHeap 自定義分配器這是“正統(tǒng)”做法。angr 內(nèi)部有SimHeap類體系比如SimHeapBrk、SimHeapLibc。通過(guò)自定義一個(gè)繼承SimHeap的分配器重寫_allocate方法在分配時(shí)自動(dòng)記錄所有 malloc 返回的地址并在返回前將地址寫入符號(hào)變量。這樣程序里所有 malloc 出來(lái)的區(qū)域內(nèi)容都是“全新符號(hào)”每個(gè) malloc 對(duì)應(yīng)一個(gè)獨(dú)立 BVS互不干擾。class SymHeap(angr.state_plugins.heap.SimHeapLibc): def _allocate(self, size, **kwargs): addr super()._allocate(size, **kwargs) sym claripy.BVS(fheap_{addr:x}, 8 * size) self.state.memory.store(addr, sym) return addr # 使用時(shí) state.heap SymHeap()這種方式約束力最強(qiáng)但復(fù)雜度也高。還有坑過(guò)度的自動(dòng)符號(hào)化會(huì)讓路徑條件爆炸。每個(gè) malloc 區(qū)域都有一整塊 BVS一旦程序?qū)@塊區(qū)域做逐字節(jié)比較angr 會(huì)為每個(gè)字節(jié)引入分叉狀態(tài)數(shù)呈指數(shù)級(jí)增長(zhǎng)。所以這種方式適合研究目的不適合直接對(duì)真實(shí)大型程序用。3.4 方式四高階玩法——用 concretization 和約束定向符號(hào)化前面幾種叫“寬泛符號(hào)化”還有更精細(xì)的定向符號(hào)化不讓整個(gè) malloc 區(qū)域一開始就是 BVS而是讓它先保持具體值等 angr 執(zhí)行到某個(gè)比較指令時(shí)發(fā)現(xiàn)該分支不可滿足再“回頭”把相關(guān)內(nèi)存地址符號(hào)化。angr 確實(shí)存在某種程度上“從失敗中學(xué)習(xí)”的機(jī)制——如果你在探索時(shí)設(shè)置了save_unconstrained常見的是保存未約束狀態(tài)而不是自動(dòng)回填符號(hào)。這里說(shuō)的“回頭符號(hào)化”通常要你手動(dòng)根據(jù)失敗路徑的特征在特定地址上補(bǔ)充約束。這種方式我已經(jīng)一年沒(méi)在實(shí)戰(zhàn)中用了因?yàn)槎鄶?shù)情況下方式一和方式二夠用。但了解這個(gè)概念有助于理解 angr 的局限符號(hào)執(zhí)行不是魔法它要在“盡可能多地探索”和“避免狀態(tài)爆炸”之間取舍。手動(dòng)符號(hào)化 malloc 區(qū)域本質(zhì)上就是一種人工引導(dǎo)這種取舍的行為。3.5 選型建議按判定條件選不按喜好選說(shuō)實(shí)話我在自己的實(shí)踐里沒(méi)用過(guò)一種包打天下的方案。我建議按下面這個(gè)標(biāo)準(zhǔn)來(lái)選場(chǎng)景特征推薦方式理由程序 malloc 調(diào)用點(diǎn)少關(guān)鍵區(qū)域只有一處方式一代碼短好控制調(diào)試直觀CTF 題目、程序結(jié)構(gòu)簡(jiǎn)單且 malloc 地址穩(wěn)定方式二寫起來(lái)最快能跑就行想系統(tǒng)分析某類程序需要讓所有堆區(qū)域都可變方式三自動(dòng)化程度最高但要防狀態(tài)爆炸求解出現(xiàn) unsat 或路徑走死需要精確定位方式四實(shí)際是排查局部符號(hào)化靈活但依賴經(jīng)驗(yàn)判斷本篇下面的實(shí)操用方式一做演示因?yàn)樽钅荏w現(xiàn)“malloc 地址單元符號(hào)化”的思路先拿到地址再填充符號(hào)。把這一步走通其他方式就是在它的基礎(chǔ)上做變通。4. 實(shí)操演示從零跑通“malloc 內(nèi)容符號(hào)化”完整流程4.1 準(zhǔn)備實(shí)驗(yàn)環(huán)境與模擬程序首先準(zhǔn)備好 angr 環(huán)境。我用的版本是 angr 9.2往下的 9.0 和往上的 10.x 在 API 上有細(xì)微差別但核心思路一致。安裝就是常規(guī)的pip install angr需要注意的是 angr 依賴的 z3-solver 版本可能跟系統(tǒng)其他包沖突建議建虛擬環(huán)境避免污染系統(tǒng) Python。然后是模擬程序。本文要分析的 Demo 用 C 語(yǔ)言寫成為了簡(jiǎn)化后面解釋原理我刻意寫得很“人工”#include stdio.h #include stdlib.h #include string.h int check(char *buf, int len) { if (buf[0] * buf[1] 0x861) { if (buf[2] buf[3] 0xcb) { if (buf[4] ^ buf[5] 0x5c) { return 1; } } } return 0; } int main(int argc, char **argv) { char *heap_buf (char *)malloc(0x20); // 模擬從某處填充數(shù)據(jù)關(guān)鍵是在符號(hào)執(zhí)行中這塊內(nèi)容沒(méi)有被正常符號(hào)化 memcpy(heap_buf, ABCDEF, 6); if (check(heap_buf, 6)) { puts(ok); } else { puts(bad); } return 0; }編譯gcc -o demo demo.c -no-pie -fno-stack-protector。這里關(guān)掉 PIE 不是為了別的只是后面分析時(shí)地址固定方便說(shuō)明問(wèn)題。真實(shí)復(fù)現(xiàn)時(shí)你可以保留 PIEangr 也支持。這個(gè) Demo 里check函數(shù)對(duì) heap_buf 的內(nèi)容做了幾組算術(shù)校驗(yàn)。如果按常規(guī)思路直接跑會(huì)看到 angr 走到check之后兩個(gè)分支都不可滿足因?yàn)閮?nèi)存里是固定的ABCDEF算不出滿足條件的解法。這正是我們要解決的。4.2 常規(guī)分析腳本與失敗現(xiàn)象記錄先寫一個(gè)最常規(guī)的 angr 腳本看看失敗長(zhǎng)什么樣import angr def main(): proj angr.Project(./demo, auto_load_libsFalse) state proj.factory.entry_state() sm proj.factory.simulation_manager(state) sm.explore(find0x4006a4, avoid0x4006ad) # 目標(biāo)是 ok 分支 if sm.found: print(Found!) print(sm.found[0].posix.dumps(0)) else: print(No found) # 看看活躍狀態(tài)情況 print(active:, len(sm.active)) for st in sm.active[:3]: print(st.solver.constraints[-3:]) if __name__ __main__: main()跑一下結(jié)果大概率是No found。這就說(shuō)明內(nèi)存內(nèi)容確確實(shí)實(shí)沒(méi)有被符號(hào)化程序執(zhí)行到比較時(shí)就沒(méi)有合法路徑了。只用看路徑活躍數(shù)會(huì)發(fā)現(xiàn)所有狀態(tài)都是 unsatisfiable在sm.active里檢查state.solver.satisfiable()會(huì)返回 False。這里要專門提醒一下在 angr 里一個(gè)狀態(tài)“活著”不代表它能繼續(xù)往后執(zhí)行。很多人在sm.explore沒(méi)找到結(jié)果時(shí)就打印sm.active里的狀態(tài)數(shù)發(fā)現(xiàn)數(shù)量不少就覺得是不是其他 bug。實(shí)際上這些 active 狀態(tài)很可能全是 unsat后續(xù)指令執(zhí)行全是死胡同。所以排查時(shí)第一件事就是檢查狀態(tài)的可滿足性。4.3 方案落地hook malloc 并符號(hào)化返回地址指向的內(nèi)容現(xiàn)在按方式一改造。先 hookmalloc符號(hào)讓 malloc 返回一個(gè)我們指定的地址并在這個(gè)地址上寫入 BVS。angr 的hook_symbol接受一個(gè)符號(hào)名和 hook 函數(shù)。hook 函數(shù)的輸入是state你可以在里面修改寄存器、內(nèi)存、求解器約束。import angr import claripy def setup_malloc_sym(state): # 選一個(gè)地址作為“堆區(qū)”注意避開已知區(qū)域 fake_heap 0x600000 size 0x20 sym_data claripy.BVS(malloc_0, 8 * size) state.memory.store(fake_heap, sym_data) # malloc 的返回結(jié)果放在 rax state.regs.rax fake_heap然后把 hook 掛到符號(hào)上。angr 里hook_symbol的第一個(gè)參數(shù)是字符串對(duì)應(yīng)程序里malloc在動(dòng)態(tài)符號(hào)表中的名字。def main(): proj angr.Project(./demo, auto_load_libsFalse) proj.hook_symbol(malloc, setup_malloc_sym, length0) # length 為 0 表示完全替換寫完這個(gè) hook 之后重新用同樣的 explore 跑就能在found里看到狀態(tài)了。這時(shí)候可以 dump 一下標(biāo)準(zhǔn)輸入如果你程序里有讀入或者直接查看malloc_0這個(gè)符號(hào)變量的值found_state sm.found[0] solver found_state.solver solution solver.eval(claripy.BVS(malloc_0, 8 * 6), cast_tobytes) print(solution)如果不出意外你得到的 bytes 就是滿足check條件的“目標(biāo)值”比如bKI\xe5TZ之類。驗(yàn)證方式很簡(jiǎn)單用這個(gè)字節(jié)串替換 Demo 里 memcpy 的數(shù)據(jù)重新編譯跑一下發(fā)現(xiàn)打印ok。這一步的關(guān)鍵點(diǎn)在于順序必須先 store BVS 到地址再讓 malloc 返回地址。如果反過(guò)來(lái)先設(shè)置 rax 再 storestore 的地址還是我們自己指定的理論上沒(méi)區(qū)別但如果你 store 的地址依賴 rax 的內(nèi)容那順序就很重要。另外store 的 size 要跟后面讀取的長(zhǎng)度匹配只 store 6 字節(jié)也行但為了穩(wěn)妥我會(huì)按整個(gè)分配大小 store 一整塊 BVS后面讀取哪一段都能命中。4.4 完整腳本與邊界參數(shù)說(shuō)明下面給一個(gè)完整的腳本可以直接替換地址、符號(hào)名跑別的程序用import angr import claripy fake_heap_base 0x200000 malloc_size 0x100 def malloc_hook(state): # 可以在這里打印調(diào)用棧或者分配大小便于調(diào)試 # state.inspect 相關(guān)用法可以拿到調(diào)用參數(shù)這里略過(guò) addr fake_heap_base sym claripy.BVS(heap_0, 8 * malloc_size) state.memory.store(addr, sym) state.regs.rax addr def main(): proj angr.Project(./demo, auto_load_libsFalse) proj.hook_symbol(malloc, malloc_hook, length0) state proj.factory.entry_state() sm proj.factory.simulation_manager(state) sm.explore(find0x4006a4, avoid0x4006ad) if sm.found: st sm.found[0] # 解析整個(gè)堆區(qū)域的具體解 heap_bytes st.solver.eval(claripy.BVS(heap_0, 8 * 6), cast_tobytes) print(Found, heap bytes:, heap_bytes) else: print(No found) for i, st in enumerate(sm.active[:5]): print(factive[{i}] satisfiable:, st.solver.satisfiable()) if __name__ __main__: main()說(shuō)幾個(gè)容易踩的坑。第一fake_heap_base一定要選一個(gè) angr 不會(huì)主動(dòng)使用的地址。前面我用 0x600000對(duì)大多數(shù)小型二進(jìn)制沒(méi)問(wèn)題但如果你程序本身加載地址就在這個(gè)范圍就要避開??梢杂胹tate.memory._get_region或者干脆打印state.regs.rsp、加載基地址后估算。第二malloc_size建議取你實(shí)際需要大小的兩倍以上留余量。第三hook_symbol的length參數(shù)如果不傳angr 默認(rèn)會(huì)以符號(hào)對(duì)應(yīng)的函數(shù)長(zhǎng)度做替換容易出問(wèn)題hook 我們自己的純 Python 函數(shù)時(shí)length0更安全。第四如果你的二進(jìn)制是靜態(tài)鏈接的可能沒(méi)有malloc動(dòng)態(tài)符號(hào)hookmalloc會(huì)失敗。處理方式是 hook 地址先通過(guò)proj.loader.main_object.get_symbol(malloc)拿到地址或者直接對(duì)proj.loader的 memory 地圖找到 malloc 所在的 PLT 入口用proj.hook(addr, hook_func)。4.5 設(shè)計(jì)點(diǎn)詳解為什么這里要“同時(shí)符號(hào)化整個(gè) size 塊”很多人在成功跑出一次結(jié)果后會(huì)有個(gè)疑問(wèn)為什么我要符號(hào)化整個(gè) 0x100 字節(jié)而不是只在 hook 到的 malloc size這里是 0x20對(duì)應(yīng)的空間上做符號(hào)化這有兩個(gè)層面的原因。一方面符號(hào)化整個(gè)保留區(qū)域的成本并不高。angr 的 BVS 在約束求解時(shí)只對(duì)實(shí)際參與比較的字節(jié)產(chǎn)生約束那些沒(méi)有被讀取的字節(jié)不會(huì)影響求解復(fù)雜度。你把整個(gè)區(qū)域標(biāo)成符號(hào)相當(dāng)于為后續(xù)可能發(fā)生的越界讀取或者各種偏移訪問(wèn)留了后路。另一方面真實(shí)程序里malloc 后面往往還跟著realloc、memcpy、strlen等各種操作你無(wú)法預(yù)測(cè)程序會(huì)讀取哪一段偏移。動(dòng)態(tài)規(guī)劃一步到位后續(xù)就不用反復(fù)回頭看。如果擔(dān)心狀態(tài)爆炸可以只符號(hào)化實(shí)際參與校驗(yàn)的那幾個(gè)字節(jié)。這需要你先摸清程序邏輯指向性更強(qiáng)。操作方式是一樣的把 BVS 長(zhǎng)度設(shè)成目標(biāo)字節(jié)數(shù)store 到對(duì)應(yīng)偏移即可。自由度在你自己手上。5. 實(shí)操過(guò)程與核心環(huán)節(jié)實(shí)現(xiàn)從一個(gè)更真實(shí)的例子看符號(hào)化代碼設(shè)計(jì)5.1 新案例程序接收字符串后存堆、校驗(yàn)且?guī)аh(huán)上面 Demo 非常簡(jiǎn)單主要是驗(yàn)證思路?,F(xiàn)在換一個(gè)稍微真實(shí)一點(diǎn)的例子。假設(shè)程序從 argv[1] 接收字符串長(zhǎng)度限制 0x20把它拷貝到 malloc 分配的緩沖區(qū)里隨后在一個(gè)循環(huán)中逐字節(jié)累加異或校驗(yàn)最后比較結(jié)果。直接對(duì) argv 符號(hào)化很簡(jiǎn)單但問(wèn)題來(lái)了程序把 argv 字符串拷貝到 malloc 區(qū)域后如果 angr 正確識(shí)別了 memcpy該區(qū)域的值仍是符號(hào)變量。關(guān)鍵難點(diǎn)在于循環(huán)結(jié)構(gòu)的處理。循環(huán)在符號(hào)執(zhí)行里容易造成路徑爆炸但如果校驗(yàn)邏輯是“無(wú)分支”的循環(huán)比如只是累加、異或而不根據(jù)內(nèi)存內(nèi)容跳轉(zhuǎn)angr 的執(zhí)行路徑不會(huì)分叉只是狀態(tài)會(huì)包含大量循環(huán)變量。不要被“循環(huán)”兩個(gè)字嚇到angr 并不是盲人騎瞎馬它會(huì)管理路徑的。對(duì)無(wú)分支循環(huán)執(zhí)行次數(shù)就算成百上千次也完全沒(méi)問(wèn)題。真正麻煩的是循環(huán)內(nèi)依賴內(nèi)容的條件跳轉(zhuǎn)。這種情況下malloc 地址單元符號(hào)化能幫你把循環(huán)條件變成符號(hào)約束而不是把條件里出現(xiàn)的每個(gè)具體值都固定死。比如程序里有if (buf[i] a)這樣的判斷angr 在 i 未知時(shí)會(huì)嘗試所有可能。如果你提前把整個(gè) malloc 區(qū)域符號(hào)化了angr 就能正確處理這種分支探索所有可能字符值。實(shí)戰(zhàn)里對(duì)這類循環(huán)條件一個(gè)非常有效的技巧是“約束內(nèi)生符號(hào)化”你在 hook 的 malloc 里不光 store BVS還可以主動(dòng) store 幾個(gè)額外的約束。比如你知道校驗(yàn)一定要求某個(gè)字節(jié)是字母就可以直接加state.solver.add(buf[i] A)減少無(wú)用分支。這類約束越早加狀態(tài)精簡(jiǎn)越明顯。5.2 結(jié)合“查找目標(biāo)地址”與“避免地址”的工程化腳本實(shí)戰(zhàn)中你一旦開始符號(hào)化 malloc就不能只關(guān)注找目標(biāo)那一支。很多程序校驗(yàn)失敗的分支會(huì)調(diào)用 exit 或者 abort這些地址如果不寫進(jìn)avoidangr 會(huì)為了探索它們消耗大量資源。我給一個(gè)稍微工程化的腳本框架def run_analysis(proj, start_addrNone, find_addrs[], avoid_addrs[]): if start_addr is None: state proj.factory.entry_state() else: state proj.factory.blank_state(addrstart_addr) # 初始化符號(hào)化堆 fake_heap_base 0x200000 sym_size 0x200 state.memory.store(fake_heap_base, claripy.BVS(heap_symbolic, 8 * sym_size)) # 探索 sm proj.factory.simulation_manager(state) sm.use_technique(angr.exploration_techniques.DFS()) # 深度優(yōu)先找路徑失敗后快速回溯 sm.explore(findfind_addrs, avoidavoid_addrs) if sm.found: for st in sm.found: # 按需提取 return st return None這個(gè)框架把 malloc 符號(hào)化的動(dòng)作和執(zhí)行解耦了你可以在任意起點(diǎn)狀態(tài)調(diào)用。注意我用的是DFS而不是默認(rèn)的BFS。找 target 時(shí)深度優(yōu)先往往更快但如果你希望覆蓋更全面的路徑BFS 更穩(wěn)。實(shí)際場(chǎng)景中我會(huì)根據(jù)程序復(fù)雜度切換小而精的程序用 DFS大而雜的程序用 BFS。另外blank_state配合start_addr是處理復(fù)雜反調(diào)試、自修改代碼的好姿勢(shì)。你可以先手動(dòng)分析程序邏輯直接跳到某個(gè)關(guān)鍵函數(shù)入口把上下文寄存器、棧設(shè)置好后再符號(hào)化堆區(qū)開始探索。5.3 參數(shù)計(jì)算符號(hào)化范圍與約束條件怎么寫更經(jīng)濟(jì)這個(gè)點(diǎn)我覺得很有必要詳細(xì)展開因?yàn)楹芏嗄_本卡死不是符號(hào)化太少而是符號(hào)化太多、約束太寬造成的。假設(shè)程序有 10 個(gè) malloc 調(diào)用點(diǎn)每個(gè)分配 0x100 字節(jié)。如果你統(tǒng)一 hook對(duì)每個(gè) malloc 區(qū)域都寫入一整塊全新的 BVS那 angr 需要同時(shí)跟蹤的可能符號(hào)變量就是 10 × 0x100 × 8 字節(jié) 12800 個(gè) bit。約束求解器面對(duì)這些符號(hào)光維護(hù)可達(dá)性關(guān)系就可能內(nèi)存爆炸。所以我給自己定了三條規(guī)矩只符號(hào)化必要地址。如果程序只校驗(yàn) malloc 區(qū)域的前 16 字節(jié)那 BVS 長(zhǎng)度就設(shè)成 16 字節(jié)甚至更小別貪多。同一時(shí)間只讓少數(shù)幾個(gè) malloc 區(qū)域“活著”。一個(gè)技巧是判斷 malloc 的調(diào)用點(diǎn)只有當(dāng)它是關(guān)鍵位置比如某個(gè)函數(shù)里的 malloc才寫入符號(hào)其他 malloc 返回具體地址內(nèi)容不管。盡早加約束。如果你從逆向分析或動(dòng)態(tài)調(diào)試已經(jīng)知道輸入字節(jié)屬于可打印字符那就提前 add 約束不要等求解器自己去探索 256 種可能。用生活例子來(lái)說(shuō)你讓一個(gè)快遞員送包裹給某個(gè)神秘收件人只給了他“地址在某某小區(qū)”這個(gè)信息他要跑遍整個(gè)小區(qū)你得讓他跑多久但如果你提前說(shuō)“收件人姓王”他直接去查門牌系統(tǒng)效率完全不一樣。符號(hào)執(zhí)行里的約束約等于這個(gè)“提前信息”。判斷調(diào)用點(diǎn)的姿勢(shì)在 hook 函數(shù)里用state.inspect的 call 事件拿到調(diào)用返回地址。注意 hook 時(shí)length0會(huì)完全替換函數(shù)你可能拿不到原始調(diào)用參數(shù)更好的辦法是用state.inspect在函數(shù)入口處監(jiān)聽寄存器參數(shù)def malloc_hook(state): size_arg state.regs.rdi # System V 下第一個(gè)參數(shù)在 rdi # 你要分析的那個(gè)關(guān)鍵 malloc 很可能 size 是一個(gè)特定常量或范圍 if state.solver.eval(size_arg) 0x100: # 粗略篩一下 addr fake_heap_base sym claripy.BVS(key_heap, 8 * state.solver.eval(size_arg)) state.memory.store(addr, sym) state.regs.rax addr else: state.regs.rax fake_heap_base 0x1000 # 其他 malloc 返回另一個(gè)地址不過(guò)要注意state.solver.eval(size_arg)如果你不去約束它它可能返回任意值。這個(gè)例子只是演示思路真正使用時(shí)建議先約束size_arg或者直接用state.solver.min、max判斷范圍。更穩(wěn)妥的做法是不依賴 size直接在調(diào)用點(diǎn)返回地址上做判斷這需要你從反匯編結(jié)果里查到 malloc 的調(diào)用方地址。5.4 現(xiàn)場(chǎng)問(wèn)題一符號(hào)化之后求解結(jié)果不對(duì)如何定位按上面的腳本執(zhí)行你可能成功 found 了但拿到的字節(jié)串塞回程序卻不對(duì)。這種問(wèn)題一般有三個(gè)來(lái)源。第一個(gè)來(lái)源是約束沒(méi)加全。有些校驗(yàn)邏輯不只有一層比較可能是算術(shù)運(yùn)算后再比較或者有可能通過(guò)查表實(shí)現(xiàn)。如果你的約束只覆蓋了表層判斷求解器給出的解可能只滿足表層條件內(nèi)層條件被繞過(guò)了。這時(shí)候需要結(jié)合反匯編確認(rèn)整個(gè)校驗(yàn)鏈有多少步。第二個(gè)來(lái)源是符號(hào)化地址選錯(cuò)了。你符號(hào)化的是 malloc 返回的地址但程序后面可能把數(shù)據(jù)拷貝到了另一塊地址或者 malloc 返回的地址被某個(gè)指針變量改動(dòng)過(guò)。用 angr 的state.memory.load檢查那個(gè)地址的內(nèi)容就能發(fā)現(xiàn)是不是符號(hào)變量。很多次我發(fā)現(xiàn)符號(hào)變量在但程序讀取的來(lái)源卻是一個(gè)別的地方因?yàn)槌绦蚶镉袀€(gè)中間變量保存了 malloc 結(jié)果后來(lái)又改了這個(gè)變量。第三個(gè)來(lái)源是內(nèi)存模型默認(rèn)的字節(jié)序問(wèn)題。x86 用的是小端序當(dāng)你把整個(gè) 8 字節(jié)的 BVS store 到地址上后續(xù)如果程序按字節(jié)讀取沒(méi)問(wèn)題但如果程序把這塊區(qū)域當(dāng)成一個(gè) uint64 來(lái)比較小端序會(huì)把 BVS 的低字節(jié)放在低地址與常規(guī)直覺相反。解決方式是統(tǒng)一用endnessstate.arch.memory_endness或者顯式使用state.memory.store(addr, sym, endnessIend_BE)具體按你程序里的讀取方式調(diào)整。5.5 現(xiàn)場(chǎng)問(wèn)題二符號(hào)化后路徑爆炸卡在循環(huán)出不來(lái)另一個(gè)高頻問(wèn)題是符號(hào)化成功后 angr 確實(shí)“活”了但它非常興奮把每條路徑都探索了個(gè)遍尤其是程序中存在循環(huán)結(jié)構(gòu)時(shí)幾秒鐘就爆出幾百萬(wàn)狀態(tài)。這種情況下不要慌第一步先看是不是循環(huán)條件依賴了符號(hào)內(nèi)容。比如for (i 0; buf[i] ! 0; i)一旦 buf 被符號(hào)化這個(gè)循環(huán)條件就變成了一個(gè)探索點(diǎn)buf[i] 是否等于 0angr 會(huì)同時(shí)探索“等于 0”和“不等于 0”的分支然后繼續(xù)執(zhí)行更深層的邏輯。對(duì)策有三個(gè)方向。第一手動(dòng)約束 buf[i] 的取值范圍比如可打印字符減少窮舉空間。第二限制路徑深度或使用veritesting技術(shù)它能把部分循環(huán)條件合并。angr 的Veritestingexploration technique 是對(duì)付這類情況的常用工具雖然原理復(fù)雜但調(diào)用很簡(jiǎn)單sm proj.factory.simulation_manager(state) sm.use_technique(angr.exploration_techniques.Veritesting()) sm.explore(find..., avoid...)第三直接對(duì)循環(huán)結(jié)構(gòu)下手用angr的SimProcedure替換循環(huán)函數(shù)。如果你的循環(huán)可以抽象成一個(gè)簡(jiǎn)單的數(shù)學(xué)函數(shù)直接 hook 循環(huán)入口計(jì)算完結(jié)果后放在指定內(nèi)存里能大幅加快分析。不過(guò)這需要你對(duì)目標(biāo)代碼結(jié)構(gòu)有足夠理解屬于高級(jí)玩法。我自己實(shí)戰(zhàn)中70% 的情況用約束DFS 能解決20% 情況用 veritesting 解決剩下 10% 只能換思路放棄完全符號(hào)化改用“混合符號(hào)執(zhí)行”把部分已知的具體值噴到堆區(qū)只符號(hào)化真正未知的部分。6. 常見問(wèn)題排查與避坑指南6.1 現(xiàn)象對(duì)照看到這些情況優(yōu)先懷疑 malloc 符號(hào)化問(wèn)題現(xiàn)象可能原因排查動(dòng)作sm.active全是 unsat沒(méi)有 found符號(hào)化范圍沒(méi)覆蓋關(guān)鍵內(nèi)存或約束過(guò)強(qiáng)打印活躍狀態(tài)約束檢查是否包含 address 對(duì)應(yīng)的內(nèi)存內(nèi)容explore 卡死內(nèi)存暴漲符號(hào)化范圍過(guò)大循環(huán)條件生成海量狀態(tài)收縮符號(hào)化大小添加取值范圍約束found 了但塞回程序不對(duì)約束缺失或符號(hào)化地址/字節(jié)序問(wèn)題反匯編比較鏈逐步驗(yàn)證malloc 返回值不對(duì)hook 方式錯(cuò)誤或 Return address 被覆蓋用state.inspect監(jiān)聽返回值同步檢查寄存器符號(hào)化后無(wú)法執(zhí)行到malloc后續(xù)可能 malloc 內(nèi)部還有分支或間接跳轉(zhuǎn)檢查 hook 是否完全替換了原實(shí)現(xiàn)length0確認(rèn)這個(gè)表既是我自己排查的索引也是寫給后面看筆記的自己的速查卡。不少問(wèn)題看起來(lái)五花八門根源指向同一個(gè)方向符號(hào)變量的位置、范圍、約束與真實(shí)程序邏輯不對(duì)齊。6.2 遇到 unsat 時(shí)如何拆解不少同學(xué)看到unsat就以為 angr 壞了。這里有個(gè)很重要的調(diào)試方法把活躍狀態(tài)的約束打印出來(lái)逐個(gè)檢查。for i, st in enumerate(sm.active[:5]): print(State, i, satisfiable:, st.solver.satisfiable()) if not st.solver.satisfiable(): for idx, c in enumerate(st.solver.constraints): print( Constraint, idx, :, c)逐個(gè)看約束列表你會(huì)發(fā)現(xiàn)很多 unsat 是因?yàn)槟闶謩?dòng) add 了范圍約束比如buf[0] A和buf[0] Z但程序本身的比較條件要求buf[0] -這就產(chǎn)生矛盾。如果是這種情況說(shuō)明你的全局約束跟程序固有邏輯沖突要放寬手動(dòng)約束。另一個(gè)常見原因是符號(hào)區(qū)域里有多個(gè)不同 BVS程序把它們做了乘法或除法操作導(dǎo)致某些非線性的算術(shù)關(guān)系不可滿足。z3 對(duì)非線性算術(shù)乘法、除法的支持并不好遇到就可能直接拋出 unsat 或返回 unknown。這種情況的解法是盡量在源頭把符號(hào)變量拆分避免一次符號(hào)化一整塊后讓程序做整體乘法校驗(yàn)。6.3 hook malloc 時(shí)被遞歸調(diào)用或被靜態(tài)鏈接坑hook 符號(hào)碰到“遞歸”問(wèn)題的時(shí)候不多但碰到一次就很折磨。如果你的 hook 函數(shù)內(nèi)部又調(diào)用了state.memory.store而某個(gè)后續(xù)指令再次調(diào)用 malloc你沒(méi)有在 hook 里做區(qū)分處理就會(huì)導(dǎo)致內(nèi)存被反復(fù)覆蓋。比如程序在一個(gè)循環(huán)里 malloc 三次每次 malloc 都返回同一個(gè) fake_heap_base那第二次 malloc 會(huì)把第一次符號(hào)化區(qū)域沖掉。解決方法是維護(hù)一個(gè)分配計(jì)數(shù)每次 store 一個(gè)不同地址。靜態(tài)鏈接的情況下hook_symbol(malloc)往往靜默失效。我排查這類問(wèn)題時(shí)就用proj.loader.main_object.symbols看有沒(méi)有 malloc 符號(hào)。沒(méi)有就改用 hook 地址方式從 IDA 或 Ghidra 里找到 malloc 函數(shù)的起始地址通常在 .text 段里直接proj.hook(addr, my_malloc_hook)。要注意地址在 PIE 開啟時(shí)是相對(duì)偏移angr 內(nèi)部處理加載基址時(shí)會(huì)換算你只要確認(rèn)地址與 IDA 里顯示的靜態(tài)地址一致就行。6.4 關(guān)于 angr 版本差異的提醒我一直強(qiáng)調(diào)這一點(diǎn)因?yàn)椴煌姹镜?angr API 變化太大。老版本 7.x 和現(xiàn)在 9.x/10.x在sim_manager、hook_symbol、BVS的位置上都有改動(dòng)。我筆記里的代碼基于 9.2如果你用的是其他版本跑不通時(shí)先看 API 變化其次才懷疑分析思路。尤其是 10.x 中SimState的插件體系和state.memory.store的默認(rèn)參數(shù)有調(diào)整BVS 包導(dǎo)入也從claripy.BVS遷移到state.solver.BVS這類方式。版本變了但“malloc 地址單元符號(hào)化”的思想不變確定地址、寫入符號(hào)、約束求解。7. 一點(diǎn)個(gè)人經(jīng)驗(yàn)收尾我在這篇筆記里寫的方法說(shuō)白了就是一套“給 angr 補(bǔ)內(nèi)存上下文”的思路。malloc 地址單元符號(hào)化只是這套思路里最常見的一種落地形式。實(shí)際分析中很多東西都能人為符號(hào)化棧上的未初始化變量、文件讀入的緩沖區(qū)、甚至全局對(duì)象內(nèi)部的某個(gè)字段。你只要記住一個(gè)原則凡是你希望求解器能“推算”出來(lái)的值就必須讓它成為一個(gè)符號(hào)變量并且最好提前加上已知約束。這樣想后面遇到各種奇奇怪怪的程序思路就不會(huì)亂。最后給一個(gè)新手的實(shí)用建議如果你剛開始學(xué) angr不要一上來(lái)就研究各種高深技巧。先把我這篇筆記里的 Demo 完整跑通然后試著改幾個(gè)參數(shù)比如改 malloc 返回地址、改符號(hào)化大小、加一個(gè)取值范圍約束看求解結(jié)果怎么變。等你親手撞過(guò)一次 unsat、親手把約束從矛盾里解放出來(lái)你對(duì)符號(hào)執(zhí)行的直覺會(huì)上一個(gè)臺(tái)階。這個(gè)領(lǐng)域沒(méi)有捷徑但每一次“哇原來(lái)是這里”的瞬間都會(huì)讓你離真正的約定求解玩家更近一步。