戰(zhàn):從靜態(tài)數(shù)據(jù)流分析到fuzz harness的自動(dòng)化漏洞挖掘)
做漏洞挖掘這些年我最大的一個(gè)體會(huì)是fuzz 本身不缺速度缺的是一個(gè)聰明的入口。拿到一個(gè)陌生目標(biāo)傳統(tǒng)工作流往往是先花大半天讀代碼、找輸入點(diǎn)、手寫(xiě) harness、再開(kāi)始跑樣本等真正跑到崩潰可能已經(jīng)過(guò)去兩三天。靜態(tài)分析工具倒是快能掃出一大片“疑似風(fēng)險(xiǎn)點(diǎn)”可誤報(bào)率也高得讓人絕望每一條都去人工確認(rèn)的話時(shí)間開(kāi)銷比直接人工審計(jì)還大。pasta 框架要解決的恰好就是這個(gè)斷層它用靜態(tài)數(shù)據(jù)流分析幫你鎖定從外部輸入到危險(xiǎn)函數(shù)之間的候選路徑再自動(dòng)為這些 source-sink 路徑生成可執(zhí)行的 fuzz harness最后交給 fuzz 引擎和符號(hào)執(zhí)行工具去動(dòng)態(tài)驗(yàn)證。一句話總結(jié)就是它把“這個(gè)參數(shù)可能有 bug”推進(jìn)到了“這個(gè)參數(shù)真的能打崩程序”。這篇文章是我最近在一個(gè)模擬項(xiàng)目上從零跑通 pasta 的完整實(shí)戰(zhàn)記錄包含環(huán)境搭建、profile 配置、harness 生成、崩潰驗(yàn)證以及一堆翻車之后才總結(jié)出來(lái)的避坑細(xì)節(jié)。適合正在做二進(jìn)制漏洞挖掘、安全評(píng)估或者想把手頭“目標(biāo)多、人手少”的審計(jì)工作自動(dòng)化一部分的人。1. 項(xiàng)目概述與核心思路1.1 一個(gè)老問(wèn)題代碼審計(jì)和 fuzz 之間為什么永遠(yuǎn)差一截拿代碼審計(jì)舉例。人工審計(jì)的優(yōu)點(diǎn)是能理解語(yǔ)義知道哪些調(diào)用是真正可控的缺點(diǎn)是慢、累、依賴個(gè)人經(jīng)驗(yàn)自動(dòng)化靜態(tài)工具恰好相反它跑得快一條規(guī)則下去能掃出幾百個(gè)候選但這些候選里大頭都是不可達(dá)或者根本不可利用的路徑需要人工二次過(guò)濾。fuzz 則站在另一端它只能證明一個(gè)入口是否有問(wèn)題前提是你要給它一個(gè)能正確調(diào)用目標(biāo)邏輯的 harness而 harness 恰恰是自動(dòng)化流程里最難被泛化的一環(huán)。這種割裂導(dǎo)致一個(gè)很常見(jiàn)的尷尬場(chǎng)景靜態(tài)掃描報(bào)告里列著十個(gè)“高?!蹦闳プ粉櫚l(fā)現(xiàn)五個(gè)在 Release 版本里根本沒(méi)有調(diào)用鏈三個(gè)被上游白名單校驗(yàn)擋死剩下的兩個(gè)又不清楚外部輸入到底能不能到達(dá)。另一邊f(xié)uzz 報(bào)告顯示“跑了二十四小時(shí)零崩潰”但你不知道是不是因?yàn)?harness 根本沒(méi)覆蓋到真正的處理邏輯。pasta 這類框架出現(xiàn)的原因就是有人想把這套流程用工程化方式串起來(lái)讓靜態(tài)分析的結(jié)果直接變成動(dòng)態(tài)驗(yàn)證的輸入。1.2 pasta 解決痛點(diǎn)的思路從 source 到 sink 自動(dòng)打通整個(gè)框架的核心流程可以拆成五個(gè)階段我實(shí)際跑下來(lái)感覺(jué)每一步的定位都很清晰配置階段在 profile 里聲明目標(biāo)項(xiàng)目路徑、語(yǔ)言類型、構(gòu)建方式、需要關(guān)注的 source輸入點(diǎn)和 sink危險(xiǎn)函數(shù)。靜態(tài)分析階段pasta 基于類似 semgrep 風(fēng)格的規(guī)則對(duì)源碼做數(shù)據(jù)流掃描找出“外部輸入能夠流到危險(xiǎn)函數(shù)”的候選路徑。harness 生成階段針對(duì)每一條候選路徑自動(dòng)生成一個(gè)最小驅(qū)動(dòng)程序把模糊測(cè)試引擎的輸入喂給目標(biāo)代碼。動(dòng)態(tài)執(zhí)行階段調(diào)用 fuzz 引擎或者符號(hào)執(zhí)行工具對(duì)目標(biāo)做覆蓋率引導(dǎo)的變異測(cè)試和路徑約束求解。結(jié)果輸出階段收集崩潰樣本、調(diào)用棧和報(bào)告供人工復(fù)現(xiàn)確認(rèn)。這個(gè)閉環(huán)的意義在于它改變了“疑似漏洞”的置信度。沒(méi)有動(dòng)態(tài)驗(yàn)證的靜態(tài)結(jié)論只是方向方向可以被證偽而一旦某個(gè)候選路徑真的在 fuzz 階段觸發(fā)了 ASAN 報(bào)錯(cuò)你手里拿到的就是一條帶崩潰樣本的證據(jù)鏈。從工程效率看這也把人工從“從零找入口”中解放出來(lái)只需要去分析框架篩出來(lái)的 TopN 候選。1.3 這條流水線里的符號(hào)執(zhí)行到底扮演什么角色很多人提到符號(hào)執(zhí)行就頭大覺(jué)得它路徑爆炸、跑不動(dòng)確實(shí)如果你把整個(gè)程序丟進(jìn)去做完整符號(hào)執(zhí)行基本都會(huì)卡死。但 pasta 這類框架里符號(hào)執(zhí)行的定位不是“全程序勘探”而是“定向解鎖關(guān)鍵路徑”。我舉個(gè)具體場(chǎng)景。很多解析類程序入口都有 magic bytes 校驗(yàn)比如要求前兩個(gè)字節(jié)必須是0xCA 0xFE普通 fuzz 要靠隨機(jī)變異偶然碰對(duì)概率是 1/65536如果要連續(xù)過(guò)五六個(gè)字節(jié)的校驗(yàn)基本就等于是不可能事件。符號(hào)執(zhí)行遇到這類分支時(shí)會(huì)把條件記錄下來(lái)變成約束比如data[0]0xCA data[1]0xFE然后調(diào)用約束求解器直接解出一組滿足條件的輸入。這就是為什么我說(shuō)pasta 這類流水線真正的聰明之處是先用靜態(tài)分析把路徑規(guī)??s小再讓符號(hào)執(zhí)行去處理那些 fuzz 很難越過(guò)的高門(mén)檻分支最后交給 fuzz 繼續(xù)做深度變異。在這個(gè)組合里每一個(gè)工具都在干自己最擅長(zhǎng)的事靜態(tài)分析負(fù)責(zé)找方向符號(hào)執(zhí)行負(fù)責(zé)過(guò)魔法校驗(yàn)fuzz 負(fù)責(zé)大范圍覆蓋人工負(fù)責(zé)最后拍板。2. 環(huán)境準(zhǔn)備與安裝先把輪子跑起來(lái)2.1 依賴清單與推薦環(huán)境pasta 本身不是一個(gè)單一二進(jìn)制它更像一套把多個(gè)安全工具編排起來(lái)的框架所以安裝前最好先確認(rèn)下列組件在系統(tǒng)里是齊全的。我推薦的實(shí)驗(yàn)環(huán)境是一臺(tái)干凈的 Linux 機(jī)器可以是物理機(jī)也可以是虛擬機(jī)關(guān)鍵是不要混入太多無(wú)關(guān)依賴。組件作用備注Linux 環(huán)境基礎(chǔ)運(yùn)行環(huán)境建議主流的發(fā)行版內(nèi)核不需要特殊配置gcc / clang編譯目標(biāo)代碼和 harnessclang 對(duì)插樁支持更友好建議裝make / cmake構(gòu)建目標(biāo)項(xiàng)目很多目標(biāo)項(xiàng)目依賴這些工具鏈Docker可選隔離依賴目標(biāo)依賴很臟時(shí)強(qiáng)烈推薦Python / Rubypasta 可能有腳本依賴按倉(cāng)庫(kù) README 準(zhǔn)備即可fuzz 引擎動(dòng)態(tài)執(zhí)行階段的核心AFL、libFuzzer 都可以符號(hào)執(zhí)行工具可選處理魔法校驗(yàn)例如 angr、KLEE按需接入sanitizer崩潰復(fù)現(xiàn)檢測(cè)ASAN 是必備能直接定位內(nèi)存錯(cuò)誤這里要額外說(shuō)一句ASAN 一定要裝好而且要確認(rèn) clang 或者 gcc 能正常生成帶地址消毒的二進(jìn)制。很多新手拿到 pasta 報(bào)告里的崩潰樣本后跑本地的時(shí)候沒(méi)有開(kāi) ASAN結(jié)果什么都沒(méi)發(fā)生直接懷疑工具出了問(wèn)題。其實(shí)不是崩潰已經(jīng)在 fuzz 階段被攔截了你本地復(fù)現(xiàn)必須用同樣的 sanitizer 配置才看得見(jiàn)。2.2 安裝流程與目錄結(jié)構(gòu)因?yàn)椴煌姹景惭b方式略有差異我把我實(shí)際用的方式寫(xiě)出來(lái)你對(duì)照本地倉(cāng)庫(kù)的 README 調(diào)整即可git clone 倉(cāng)庫(kù)地址 pasta cd pasta ./scripts/setup.sh裝完后先確認(rèn)主入口是不是在 PATH 里pasta --help如果你看到輸出里有run、report之類的子命令就說(shuō)明安裝基本完成了。我見(jiàn)過(guò)一個(gè)常見(jiàn)問(wèn)題是 setup 腳本默認(rèn)安裝的 Python 依賴和系統(tǒng)全局環(huán)境沖突尤其是反復(fù)切換過(guò)多個(gè) Python 版本的環(huán)境特別容易翻車。建議給 pasta 單獨(dú)建一個(gè)虛擬環(huán)境不要直接裝在系統(tǒng) site-packages 里。pasta 的目錄結(jié)構(gòu)大體上包含這樣幾個(gè)重要模塊規(guī)則目錄存放 source/sink 掃描規(guī)則、配置目錄存放 profile 文件、harness 模板目錄決定生成出來(lái)的驅(qū)動(dòng)代碼樣式、報(bào)告輸出目錄存放崩潰樣本和 HTML/JSON 報(bào)告。跑熟悉了以后你會(huì)發(fā)現(xiàn)70% 的定制化需求都落在規(guī)則目錄和配置目錄里這是你真正要長(zhǎng)期維護(hù)的資產(chǎn)。2.3 自帶樣例驗(yàn)證一條必須走的“冒煙測(cè)試”不要上來(lái)就拿真實(shí)目標(biāo)跑第一次使用務(wù)必先跑一遍倉(cāng)庫(kù)自帶的樣例項(xiàng)目。這就像開(kāi)新車先熱車一樣能幫你把“環(huán)境問(wèn)題”和“配置問(wèn)題”分開(kāi)。自帶樣例通常是一個(gè)故意留下漏洞的小程序流程上你只需要找到它的示例 profile執(zhí)行pasta run --config examples/example_profile.yaml正常的現(xiàn)象是日志里先出現(xiàn)靜態(tài)分析階段掃描到的 sink 列表然后是 harness 生成成功的提示再往后是 fuzz 引擎開(kāi)始跑樣本的輸出最后在崩潰目錄里出現(xiàn) crash 文件或者一份自動(dòng)生成的報(bào)告。我第一遍跑的時(shí)候卡在了 dependency 檢查上提示缺一個(gè)系統(tǒng)庫(kù)裝上之后再跑就一路順暢了。踩過(guò)一次坑之后我才意識(shí)到自帶樣例的價(jià)值不只是驗(yàn)證安裝它還給了一個(gè)很好的“最低可復(fù)現(xiàn)基線”以后你改了任何配置或者升級(jí)了引擎先跑一遍這個(gè)樣例如果它都過(guò)不了就別急著怪目標(biāo)項(xiàng)目有問(wèn)題。3. 核心配置解析profile、source 與 sink 的設(shè)計(jì)3.1 一個(gè)最小可用的 profile 長(zhǎng)什么樣pasta 的入口配置我習(xí)慣叫 profile實(shí)際文件名在不同的版本里可能叫 config 或者 manifest本質(zhì)都是 YAML 或 JSON 來(lái)描述一個(gè)測(cè)試任務(wù)。下面這個(gè)例子是一個(gè)簡(jiǎn)化版字段名可能略有差異但思路是大致一致的project_name: mini_demo target_path: ./demos/mini_demo language: c build_command: make mini_demo sources: - id: FileReadSource pattern: fread(...) sinks: - id: Sink_Memcpy type: memory_copy pattern: memcpy(...) - id: Sink_Strcpy type: string_copy pattern: strcpy(...) fuzz_engine: name: aflpp sanitizer: address execution: timeout: 300 input_seeds: ./seeds/我不建議照抄這個(gè)字段因?yàn)椴煌姹镜?pasta 對(duì)配置項(xiàng)的命名確實(shí)有調(diào)整。正確的做法是先打開(kāi)倉(cāng)庫(kù)自帶的 example profile確認(rèn)你自己這個(gè)版本的字段叫什么再改寫(xiě)成自己的目標(biāo)。這個(gè)提醒不是敷衍我是真吃過(guò)虧照著網(wǎng)上的舊教程填字段結(jié)果框架直接報(bào)解析失敗白白浪費(fèi)一小時(shí)。3.2 source 和 sink 定義選對(duì)位置比選多更重要source 的選擇原則是“越靠外越好”。fd 的 read、網(wǎng)絡(luò)包接收、命令行參數(shù)、環(huán)境變量、配置文件解析入口這些都要優(yōu)先放進(jìn) source 列表。你如果把它定義得太深入比如某個(gè)解析函數(shù)內(nèi)部才讀到數(shù)據(jù)污點(diǎn)分析的起點(diǎn)就晚了前面可能漏掉好幾層可控性判斷。sink 的選擇要按“家族”覆蓋不要只盯著一個(gè)函數(shù)名。內(nèi)存拷貝族至少要有 memcpy、strcpy、strncpy注意這個(gè)也有坑、sprintf、vsprintf命令執(zhí)行族要有 system、popen、exec 系列SQL 安全評(píng)估還要考慮 query 拼接這在二進(jìn)制漏洞挖掘里相對(duì)少見(jiàn)但在 Web 類目標(biāo)里是核心。每個(gè)項(xiàng)目都有自己的內(nèi)部危險(xiǎn)接口比如某個(gè)內(nèi)部日志函數(shù)存在格式化字符串問(wèn)題你必須在規(guī)則庫(kù)里補(bǔ)上這個(gè)函數(shù)名否則 pasta 根本不知道它值得關(guān)注。我實(shí)際跑下來(lái)的一個(gè)經(jīng)驗(yàn)是不要把靜態(tài)分析規(guī)則做太多。規(guī)則越寬垃圾候選越多harness 生成的量也越大反而拖慢整個(gè)流程。先覆蓋最關(guān)鍵的危險(xiǎn)函數(shù)族跑完一輪看結(jié)果再?zèng)Q定要不要加規(guī)則比一次性堆滿幾十條規(guī)則高效得多。3.3 污點(diǎn)標(biāo)簽、約束條件與符號(hào)執(zhí)行的配合在靜態(tài)數(shù)據(jù)流分析里污點(diǎn)從 source 點(diǎn)開(kāi)始打標(biāo)簽一路沿著數(shù)據(jù)流傳播直到到達(dá) sink 點(diǎn)。真正重要的不是“有沒(méi)有到達(dá)”而是“到達(dá)時(shí)經(jīng)過(guò)了多少約束”。如果污點(diǎn)數(shù)據(jù)在中間被一個(gè)無(wú)條件長(zhǎng)度檢查限制住了路徑的安全性就相對(duì)高如果污點(diǎn)數(shù)據(jù)經(jīng)過(guò)了幾層復(fù)雜的變換和校驗(yàn)?zāi)沁@些變換和校驗(yàn)本身就是 fuzz 的難點(diǎn)也是符號(hào)執(zhí)行能介入的地方。我拿一個(gè)典型場(chǎng)景來(lái)說(shuō)明。目標(biāo)函數(shù)里有一句if (data[0] 0xCA data[1] 0xFE) { /* 進(jìn)入核心處理 */ }普通 fuzz 引擎在這個(gè)分支前的覆蓋率卡住因?yàn)殡S機(jī)變異很難同時(shí)猜中兩個(gè)魔數(shù)。符號(hào)執(zhí)行工具看到這個(gè)分支時(shí)會(huì)把條件壓進(jìn)求解器直接返回一組滿足條件的具體輸入。這就是我前面說(shuō)的“定向解鎖”它不求解整個(gè)程序的路徑只求解從當(dāng)前執(zhí)行點(diǎn)到關(guān)鍵分支的約束所以速度和可用性都大大提升。污點(diǎn)分析到這個(gè)層面還有個(gè)問(wèn)題標(biāo)簽傳播要足夠保守但不能過(guò)于保守。比如污點(diǎn)數(shù)據(jù)經(jīng)過(guò)哈希函數(shù)再到達(dá) sink理論上標(biāo)簽應(yīng)該繼續(xù)傳播但實(shí)際可利用性已經(jīng)很低了。pasta 這類框架通常會(huì)保持“寧可多報(bào)不可漏報(bào)”的策略把這種路徑繼續(xù)報(bào)出來(lái)由你在人工驗(yàn)證階段判斷。理解了這一點(diǎn)你就不容易因?yàn)閳?bào)告里有大量“低可利用性”候選而失望了。4. 實(shí)戰(zhàn)記錄pasta 跑通一個(gè)模擬目標(biāo)的完整過(guò)程4.1 目標(biāo)代碼長(zhǎng)什么樣我埋了哪些坑為了復(fù)現(xiàn)整個(gè)流程我寫(xiě)了一個(gè)極小的模擬目標(biāo)mini_demo它不是真實(shí)漏洞只是用來(lái)展示框架工作流的 Demo。這個(gè)程序的邏輯非常簡(jiǎn)單從文件讀入一段字節(jié)前兩個(gè)字節(jié)必須是0xCA 0xFE隨后兩個(gè)字節(jié)作為 name 長(zhǎng)度接著把數(shù)據(jù)復(fù)制到棧上一個(gè)固定大小的緩沖區(qū)里。#include stdio.h #include stdint.h #include string.h #define NAME_BUF_SIZE 16 int process_config(const uint8_t *data, size_t size) { char name[NAME_BUF_SIZE]; if (size 18) return -1; if (data[0] ! 0xCA || data[1] ! 0xFE) return -1; size_t name_len data[2] | (data[3] 8); /* 此處是故意保留的脆弱點(diǎn) name_len 未與 NAME_BUF_SIZE 做比較后續(xù) memcpy 會(huì)發(fā)生棧溢出。 */ memcpy(name, data 4, name_len); name[name_len] \0; if (strcmp(name, default) 0) { return 1; } return 0; } int main(int argc, char **argv) { FILE *fp; uint8_t buf[256]; size_t file_size; if (argc 2) return -1; fp fopen(argv[1], rb); if (!fp) return -1; file_size fread(buf, 1, sizeof(buf), fp); fclose(fp); return process_config(buf, file_size); }這個(gè) Demo 里有兩個(gè)典型難點(diǎn)第一個(gè)是 0xCA 0xFE 的魔法校驗(yàn)直接靠 fuzz 碰撞很困難第二個(gè)是memcpy復(fù)制長(zhǎng)度完全可控但沒(méi)有邊界檢查一旦繞過(guò)魔法校驗(yàn)就能觸發(fā)內(nèi)存越界。我選擇這樣的目標(biāo)就是因?yàn)樗芡瑫r(shí)展示 fuzz 的優(yōu)勢(shì)和瓶頸也方便理解符號(hào)執(zhí)行在流程中的真實(shí)價(jià)值。4.2 運(yùn)行 pasta 的完整過(guò)程與階段輸出把上面的代碼放進(jìn)demos/mini_demo/目錄寫(xiě)一個(gè)對(duì)應(yīng)的 profile比如configs/mini_demo.yaml然后執(zhí)行pasta run --config configs/mini_demo.yaml第一階段的日志會(huì)顯示靜態(tài)分析掃描到了 fread 這個(gè) source 和 memcpy 這個(gè) sink并生成一條從fread - buf - process_config - name - memcpy的可疑數(shù)據(jù)流。到這里你已經(jīng)能看出 pasta 不只是找函數(shù)名它還做了變量粒度的傳播追蹤。第二階段日志會(huì)顯示它生成了一份 harness 代碼內(nèi)部大致是這樣#include stdint.h #include stddef.h #include mini_demo.h int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { process_config(data, size); return 0; }這份 harness 直接使用 fuzz 引擎?zhèn)魅氲淖止?jié)流作為輸入繞過(guò)了真實(shí)程序里讀文件的步驟讓 fuzz 可以針對(duì)核心函數(shù)做密集測(cè)試。這是整個(gè)框架最省時(shí)間的地方人工寫(xiě)這么一份驅(qū)動(dòng)通常不難但框架可以同時(shí)生成幾十個(gè)不同 source-sink 路徑的驅(qū)動(dòng)這個(gè)規(guī)模是手寫(xiě)做不到的。第三階段是動(dòng)態(tài)執(zhí)行。我配置的引擎是 AFL日志里開(kāi)始出現(xiàn)執(zhí)行速率、路徑數(shù)量、覆蓋率的滾動(dòng)統(tǒng)計(jì)。因?yàn)槲夜室庠?seeds 目錄放了一個(gè)滿足0xCA 0xFE前綴的種子文件動(dòng)態(tài)執(zhí)行很快就跑到了 $name_len$ 很大的分支然后 ASAN 的告警出現(xiàn)了。第四階段pasta 把崩潰樣本收集到崩潰目錄文件名通常是crash-xxx或帶時(shí)間戳的形式。到這一步自動(dòng)化部分已經(jīng)完成了使命候選路徑被驗(yàn)證成一條真實(shí)的崩潰路徑接下來(lái)需要人工介入復(fù)核。4.3 用 ASAN 和 gdb 人工驗(yàn)證崩潰結(jié)果拿到崩潰樣本后第一件事不是直接看它為什么崩而是先用 ASAN 確認(rèn)它是可復(fù)現(xiàn)的。我用帶地址消毒的編譯選項(xiàng)重新編譯目標(biāo)程序clang -fsanitizeaddress -g -o mini_demo_asan mini_demo.c ./mini_demo_asan crash-xxx正常輸出會(huì)有一段類似這樣的信息ERROR: AddressSanitizer: stack-buffer-overflow on address ...看到stack-buffer-overflow基本就可以確認(rèn)是棧上的緩沖區(qū)溢出。接下來(lái)用 gdb 跟進(jìn)gdb ./mini_demo_asan run crash-xxx bt調(diào)用棧會(huì)顯示process_config里的memcpy或者strcmp幀結(jié)合反匯編就能定位越界復(fù)制發(fā)生的確切位置。這里有一個(gè)我在實(shí)際中經(jīng)常提醒自己的點(diǎn)崩潰發(fā)生在目標(biāo)代碼里和崩潰發(fā)生在框架生成的 harness 里意義是完全不同的。如果崩潰棧指向的是LLVMFuzzerTestOneInput或者某個(gè)容器/標(biāo)準(zhǔn)庫(kù)函數(shù)你要先懷疑是 harness 自身的未定義行為而不是目標(biāo)程序漏洞。這個(gè)人工驗(yàn)證步驟不能省因?yàn)樽詣?dòng)化工具報(bào)出來(lái)的崩潰里總有相當(dāng)比例是“假陽(yáng)性”比如目標(biāo)程序在特定輸入下拋出異常、harness 驅(qū)動(dòng)代碼自身的越界、甚至環(huán)境的內(nèi)存限制被誤判為崩潰。只有把棧和分析器輸出對(duì)齊才敢把這條結(jié)果寫(xiě)進(jìn)漏洞報(bào)告。4.4 這輪實(shí)驗(yàn)里符號(hào)執(zhí)行解決的真正難點(diǎn)回到我在 4.1 埋的魔法校驗(yàn)。假設(shè)我沒(méi)有提前準(zhǔn)備一個(gè)合法前綴的種子文件而完全依賴純 fuzz會(huì)發(fā)生什么AFL 確實(shí)有插樁和覆蓋率反饋它知道某個(gè)分支沒(méi)有被覆蓋也會(huì)嘗試變異數(shù)據(jù)去碰那個(gè)分支但0xCA 0xFE兩個(gè)字節(jié)都需要命中從全零狀態(tài)開(kāi)始撞中的概率是 1/65536運(yùn)氣好可能幾分鐘運(yùn)氣差可能幾小時(shí)。如果校驗(yàn)是四字節(jié)甚至八字節(jié) magic那就基本靠運(yùn)氣了。在我的實(shí)驗(yàn)流程里pasta 生成的路徑分析結(jié)果直接把這條分支約束交給了符號(hào)執(zhí)行組件。工具在data[0] ! 0xCA分支處記錄約束data[0]0xCA在data[1] ! 0xFE處追加約束data[1]0xFE求解器很快給出一個(gè)包含0xCA 0xFE前綴的具體輸入樣本。拿到這個(gè)樣本后fuzz 引擎把它當(dāng)成種子繼續(xù)變異很快又滿足了 $name_len$ 足夠大的條件最終觸發(fā)越界復(fù)制。所以這個(gè)實(shí)驗(yàn)的意義非常明顯符號(hào)執(zhí)行不是替代 fuzz而是給 fuzz 鋪路。它解決“高門(mén)檻分支過(guò)不去”的問(wèn)題fuzz 解決“過(guò)了門(mén)檻之后的大規(guī)模路徑發(fā)現(xiàn)”問(wèn)題兩者缺一不可。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 問(wèn)題速查表一張表先救急真實(shí)跑 pasta 的過(guò)程中報(bào)錯(cuò)的形態(tài)其實(shí)很有限。我把遇到的典型問(wèn)題整理成一張速查表你可以按圖索驥現(xiàn)象常見(jiàn)原因處置方法靜態(tài)掃描沒(méi)有找到 sink規(guī)則庫(kù)沒(méi)覆蓋目標(biāo)函數(shù)或者存在間接調(diào)用自定義規(guī)則補(bǔ)充函數(shù)名與調(diào)用鏈harness 生成失敗目標(biāo)代碼沒(méi)有可用的公開(kāi)入口依賴頭文件缺失先構(gòu)建靜態(tài)庫(kù)調(diào)整簡(jiǎn)單入口函數(shù)fuzz 覆蓋率長(zhǎng)期很低目標(biāo)存在復(fù)雜格式校驗(yàn)或魔法字節(jié)準(zhǔn)備種子文件、字典或引入符號(hào)執(zhí)行崩潰文件堆積如山未做調(diào)用棧去重多個(gè)樣本是同一缺陷按棧指紋和 PC 偏移去重符號(hào)執(zhí)行長(zhǎng)時(shí)間不返回路徑爆炸約束求解器壓力過(guò)大限制路徑數(shù)量設(shè)置執(zhí)行超時(shí)目標(biāo)頻繁 OOM 被殺fuzz 引擎內(nèi)存限制未配置在容器里限制內(nèi)存排除 OOM 誤報(bào)崩潰在本地復(fù)現(xiàn)不出來(lái)harness 編譯參數(shù)與 fuzz 階段不一致使用同樣的編譯器和 sanitizer 選項(xiàng)這張表不能代替完整排查但它能在你剛上手時(shí)節(jié)省大量“對(duì)著日志發(fā)呆”的時(shí)間。5.2 靜態(tài)規(guī)則不命中的幾種原因最常見(jiàn)的原因就是目標(biāo)程序和規(guī)則庫(kù)中的正則不匹配。C 項(xiàng)目里很多人喜歡用宏和封裝函數(shù)比如memcpy被包成safe_copy靜態(tài)分析如果只看memcpy這個(gè)函數(shù)名看到的就是一條經(jīng)過(guò)封裝后名稱完全不同的調(diào)用鏈。解決辦法很簡(jiǎn)單在規(guī)則庫(kù)里把safe_copy這類項(xiàng)目?jī)?nèi)部封裝也聲明成 sink 族甚至直接參考目標(biāo)項(xiàng)目頭文件里的函數(shù)清單來(lái)擴(kuò)展規(guī)則。間接調(diào)用是另一個(gè)頭疼的問(wèn)題。函數(shù)指針、虛表調(diào)用、回調(diào)機(jī)制都會(huì)讓數(shù)據(jù)流分析難以追蹤。pasta 類框架在處理這類代碼時(shí)會(huì)比人工審計(jì)更吃力因?yàn)槟阈枰~外配置“哪些指針是函數(shù)指針、它們可能指向哪些函數(shù)”這類信息。如果你發(fā)現(xiàn)某個(gè)候選路徑確實(shí)存在但靜態(tài)掃描沒(méi)抓到最直接的辦法是先用代碼搜索工具定位所有間接調(diào)用點(diǎn)再把可能的函數(shù)名補(bǔ)進(jìn)規(guī)則。還有一個(gè)容易被忽略的原因構(gòu)建系統(tǒng)沒(méi)有正確生成編譯數(shù)據(jù)庫(kù)。pasta 分析需要知道你給它的目標(biāo)是用什么編譯參數(shù)構(gòu)建出來(lái)的如果compile_commands.json缺失或者不完整很多帶宏展開(kāi)的代碼就無(wú)法進(jìn)入分析范圍。我習(xí)慣在配置階段先跑一次項(xiàng)目的構(gòu)建確認(rèn)產(chǎn)物和數(shù)據(jù)庫(kù)都生成完整再啟動(dòng) pasta。5.3 harness 生成失敗和輸入格式問(wèn)題harness 的本質(zhì)是一個(gè)“把任意字節(jié)流塞進(jìn)目標(biāo)函數(shù)”的驅(qū)動(dòng)。如果目標(biāo)程序是一個(gè)完整的可執(zhí)行文件主函數(shù)邏輯已經(jīng)占用了文件讀取和參數(shù)解析直接生成LLVMFuzzerTestOneInput就不一定有合適入口。這時(shí)候我通常的做法是先給目標(biāo)項(xiàng)目打補(bǔ)丁把核心解析邏輯抽成一個(gè)可復(fù)用的函數(shù)比如process_config然后讓 harness 只調(diào)用這個(gè)函數(shù)。依賴頭文件缺失也是高頻錯(cuò)誤。很多項(xiàng)目只在特定模塊里暴露了接口harness 想引用相關(guān)頭文件結(jié)果編譯時(shí)發(fā)現(xiàn)引用的結(jié)構(gòu)體定義分散在多個(gè)私有目錄里。處理手段很粗暴但有效先編譯成靜態(tài)庫(kù)把需要暴露的接口單獨(dú)寫(xiě)一個(gè)公共頭文件再讓 pasta 生成的 harness 去鏈接靜態(tài)庫(kù)。這樣程序集成員的可見(jiàn)性問(wèn)題就繞開(kāi)了。如果你發(fā)現(xiàn) pasta 生成的 harness 能編譯但一跑就崩先不要懷疑目標(biāo)程序先把 harness 放到一個(gè)最小環(huán)境里執(zhí)行一次確認(rèn)它是否在沒(méi)有目標(biāo)邏輯的情況下正常工作。這一步能很快區(qū)分“harness 自身 bug”和“目標(biāo)代碼漏洞”。5.4 符號(hào)執(zhí)行超時(shí)、路徑爆炸與內(nèi)存爆掉的處理符號(hào)執(zhí)行最經(jīng)典的老大難就是路徑爆炸。每遇到一個(gè)條件分支執(zhí)行路徑就會(huì)分裂真實(shí)程序動(dòng)輒幾十萬(wàn)條路徑如果全部探索內(nèi)存和時(shí)間的消耗都無(wú)法接受。我對(duì)付它有三個(gè)習(xí)慣第一給符號(hào)執(zhí)行設(shè)置單次執(zhí)行超時(shí)比如 30 秒超過(guò)就放棄當(dāng)前目標(biāo)第二限制最大路徑數(shù)量讓工具探索到一定數(shù)量后主動(dòng)停止第三把探索范圍限制在單個(gè)函數(shù)或單個(gè) source-sink 路徑上不要讓它從程序入口做全量勘探。還有一個(gè)容易被忽略的點(diǎn)約束求解器本身也不是無(wú)限能力。復(fù)雜的目標(biāo)可能大量使用浮點(diǎn)、非線性運(yùn)算這類約束很多求解器無(wú)解。遇到這種情況我的處理方式是放棄對(duì)這種路徑做符號(hào)執(zhí)行改用動(dòng)態(tài)插樁或者更豐富的字典來(lái)覆蓋。工具組合的價(jià)值就在于此這條路徑解不出來(lái)?yè)Q一條思路總有一條能推進(jìn)。如果目標(biāo)是大型網(wǎng)絡(luò)服務(wù)符號(hào)執(zhí)行基本沒(méi)法全程跑下去更適合把流量抓包轉(zhuǎn)換成語(yǔ)料再配合 fuzz 引擎做狀態(tài)化測(cè)試。pasta 的價(jià)值在輕量級(jí)解析類目標(biāo)上體現(xiàn)得最明顯遇到大型目標(biāo)時(shí)你要學(xué)會(huì)主動(dòng)調(diào)整預(yù)期。5.5 崩潰結(jié)果如何去重和篩掉假陽(yáng)性幾十個(gè)崩潰樣本并不代表幾十個(gè)漏洞它們往往指向同一個(gè)缺陷。去重最靠譜的依據(jù)不是文件內(nèi)容而是崩潰時(shí)的調(diào)用棧。用llvm-symbolizer或addr2line把 crash 地址轉(zhuǎn)成函數(shù)名加行號(hào)再用棧幀序列做指紋就能把同一缺陷的崩潰合并。篩假陽(yáng)性時(shí)我按兩個(gè)維度判斷。第一個(gè)維度是崩潰位置是否在目標(biāo)函數(shù)內(nèi)部。如果是在外部工具庫(kù)、harness 自身代碼里先排除。第二個(gè)維度是可控性??幢罎l(fā)生后寄存器中的越界地址和源數(shù)據(jù)是否直接來(lái)自 fuzz 輸入如果完全不可控可能只是目標(biāo)代碼的潛在質(zhì)量缺陷不一定具備安全影響。不可控的崩潰也值得記錄但優(yōu)先級(jí)排在可控內(nèi)存破壞之后。還有一個(gè)實(shí)戰(zhàn)技巧把目標(biāo)程序的 sanitizer 輸出保存下來(lái)然后用腳本批量讀取崩潰目錄只保留輸出里包含ERROR: AddressSanitizer的樣本很多框架自身生成的臨時(shí)文件或由超時(shí)導(dǎo)致的“偽崩潰”會(huì)被自動(dòng)濾掉效率比一個(gè)個(gè)手動(dòng)跑高得多。6. 實(shí)操心得與后續(xù)擴(kuò)展6.1 幾條讓 pasta 跑更快更穩(wěn)的經(jīng)驗(yàn)第一個(gè)經(jīng)驗(yàn)是先編譯成靜態(tài)庫(kù)。pasta 生成 harness 后如果每次都要重新全量編譯目標(biāo)項(xiàng)目時(shí)間損耗非常明顯。我通常先讓目標(biāo)項(xiàng)目出一個(gè)類似于.a的靜態(tài)庫(kù)然后 harness 只需要鏈接這個(gè)庫(kù)和少量驅(qū)動(dòng)代碼迭代速度快很多。第二個(gè)經(jīng)驗(yàn)是每個(gè) fuzz 任務(wù)只聚焦一個(gè) sink 族。如果你在一個(gè) profile 里同時(shí)指定了 memcpy、system、sprintf 等所有 sinkfuzz 引擎的能量會(huì)被分散到多個(gè)方向上每個(gè)方向都跑不到深度。拆成多個(gè) profile一次只跑一個(gè) sink 族效果好得多。第三個(gè)經(jīng)驗(yàn)是準(zhǔn)備一份字典。把目標(biāo)程序里常見(jiàn)的字段名、magic number、結(jié)構(gòu)標(biāo)記收集到一個(gè)字典文件里fuzz 引擎會(huì)優(yōu)先用這些詞去變異。我實(shí)測(cè)過(guò)一份好字典對(duì)覆蓋率的提升非常顯著尤其適合文本協(xié)議類目標(biāo)。第四個(gè)經(jīng)驗(yàn)是設(shè)置超時(shí)上限。我一般給單輪 fuzz 配置 5 到 10 分鐘的超時(shí)不要讓引擎無(wú)限跑。把任務(wù)做成定時(shí)增量掃描比如每天晚上對(duì)新提交的代碼跑一輪比一次跑三天更能持續(xù)發(fā)現(xiàn)回歸問(wèn)題。6.2 工具結(jié)果和人工審計(jì)怎么配合框架能幫你找到“可崩的路徑”但最終判斷“這是不是漏洞、影響面多大、怎么修”的活還是得人來(lái)干。我的工作流是讓 pasta 先跑出候選崩潰列表然后人工只關(guān)注前 10 名逐一確認(rèn)三個(gè)條件。第一外部輸入是否真正可控第二sink 調(diào)用點(diǎn)之前的校驗(yàn)是否足夠弱比如沒(méi)有長(zhǎng)度檢查或者只做了范圍判斷沒(méi)做內(nèi)容校驗(yàn)第三崩潰是否穩(wěn)定復(fù)現(xiàn)且調(diào)用棧清晰。滿足這三個(gè)條件的候選基本可以進(jìn)入漏洞報(bào)告流程。剩下的崩潰樣本即使不能確定可利用性我也會(huì)保留下來(lái)作為“質(zhì)量缺陷”備注因?yàn)檫@類不可控崩潰在特定版本或者特定配置下可能被誤用作不穩(wěn)定攻擊面。實(shí)際經(jīng)驗(yàn)告訴我這對(duì)安全評(píng)估報(bào)告的完整度很有幫助。修復(fù)之后把崩潰樣本加進(jìn)下一次 fuzz 的種子集用來(lái)做回歸驗(yàn)證。這是很重要的一步否則修復(fù)可能只堵住一個(gè)入口換個(gè)格式繞過(guò)同樣的函數(shù)漏洞又復(fù)活了。6.3 后續(xù)可以怎么擴(kuò)展規(guī)則與接入新引擎pasta 不是一組寫(xiě)死的規(guī)則它最值得投入時(shí)間的地方是規(guī)則庫(kù)的積累。每個(gè)目標(biāo)項(xiàng)目都有自己特有的危險(xiǎn)函數(shù)、封裝和調(diào)用鏈把它們做成自定義規(guī)則一次投入持續(xù)收益。長(zhǎng)期跑下來(lái)這份規(guī)則庫(kù)會(huì)變成你對(duì)這個(gè)項(xiàng)目理解的可執(zhí)行表達(dá)比一長(zhǎng)篇安全審計(jì)報(bào)告更容易傳承。引擎接入方面除了默認(rèn)的 fuzz 引擎我建議你把一些支持混合執(zhí)行的引擎放進(jìn)來(lái)做交叉驗(yàn)證。有些路徑用默認(rèn)引擎解不開(kāi)換一個(gè)工具可能很快就突破了。符號(hào)執(zhí)行工具也是一樣多試幾種求解器不要在一種上死磕。如果你打算把這套流程放在日常安全評(píng)估里還可以考慮接入持續(xù)集成。每有新的提交自動(dòng)對(duì)變更代碼跑一輪 pasta把新出現(xiàn)的崩潰樣本直接作為任務(wù)反饋給開(kāi)發(fā)。這比人工定期全量掃描更能控制新漏洞的引入速度也是我目前最推薦的一種落地形態(tài)。最后說(shuō)點(diǎn)我自己的使用感受。pasta 并不神奇它沒(méi)有把安全評(píng)估完全自動(dòng)化它真正改變的是我分配精力的方式。以前拿到一個(gè)新目標(biāo)我會(huì)先讀代碼一遍遍檢查每個(gè)輸入點(diǎn)可能流到哪兒現(xiàn)在我把入口和 sink 清單扔給框架讓它先跑出真正的崩潰再用分析器確認(rèn)根因。有時(shí)候跑出來(lái)的結(jié)果確實(shí)會(huì)掃出一兩個(gè)我自己也不認(rèn)識(shí)的路徑這些路徑通常就是人工審計(jì)里最容易漏掉的部分。如果你也經(jīng)常面對(duì)“目標(biāo)太多、時(shí)間太少”我建議你從自帶的樣例開(kāi)始先跑通一個(gè)最小閉環(huán)再把它用到真實(shí)目標(biāo)上。