Fuzzer 讓應用程式當機後,你幾乎可以確定找到了一個 bug。下一步是判斷它是不是漏洞、是哪種漏洞——這取決於程式如何當、為何當。這個過程稱為漏洞分級(vulnerability triaging):透過一連串步驟追出當機的根本成因。有時成因一目瞭然,有時毀損發生後要過好幾秒、甚至好幾小時才引發當機,讓追查變得棘手。
用除錯器分析#
不同平台能提供的分析程度不同。Windows、macOS、Linux 上可以把除錯器掛到行程上;嵌入式系統可能就只有系統日誌裡的當機報告。原書作者的慣用組合是 Windows 用 CDB、Linux 用 GDB、macOS 用 LLDB,全部從命令列操作。
開始除錯#
除錯的第一步是把除錯器掛到目標應用程式上:可以直接在除錯器下啟動它,也可以依行程 ID 掛到既有行程。掛上之後行程會被暫停,必須再下指令讓它開始或繼續執行。
常用除錯器指令對照(啟動 / 掛載 / 執行)
啟動與掛載:
| 除錯器 | 新行程 | 掛載既有行程 |
|---|---|---|
| CDB | cdb application.exe [arguments] | cdb -p PID |
| GDB | gdb --args application [arguments] | gdb -p PID |
| LLDB | lldb -- application [arguments] | lldb -p PID |
開始 / 繼續執行:
| 除錯器 | 開始執行 | 繼續執行 |
|---|---|---|
| CDB | g | g |
| GDB | run, r | continue, c |
| LLDB | process launch, run, r | thread continue, c |
當機的可能是被除錯行程 fork 出來的子行程而非本身,這在 Unix 類平台很常見(許多網路伺服器會 fork 出行程副本來服務新連線)。這時需要讓除錯器跟隨子行程。
跟隨子行程的指令與注意事項
| 除錯器 | 啟用跟隨子行程 | 停用 |
|---|---|---|
| CDB | .childdbg 1 | .childdbg 0 |
| GDB | set follow-fork-mode child | set follow-fork-mode parent |
| LLDB | process attach --name NAME --waitfor | 離開除錯器 |
注意事項:
- CDB 可從單一除錯器同時除錯所有行程。
- GDB 設為跟隨子行程後,就會停止對父行程的除錯;Linux 上可用
set detach-on-fork off變通——它暫停父行程的除錯、專心跟子行程,等子行程結束再重新掛回父行程。但若子行程跑很久,父行程可能永遠無法接受新連線。 - LLDB 沒有跟隨子行程的選項;得另開一個 LLDB 實例,用上表的
--name NAME --waitfor語法依行程名自動掛載。
分析當機#
一邊 fuzzing 一邊讓程式跑,等它當掉。要留意的是暗示記憶體毀損的當機:讀寫無效位址,或試圖在無效位址執行程式碼。
第一步先從輸出判斷當機類型。CDB 通常印出類似 Access violation 的字樣;GDB/LLDB 則顯示訊號類型,最常見的是代表區段錯誤的 SIGSEGV,表示程式碼存取了無效記憶體位置。
接著要找出是哪一條指令造成當機。若當機位址本身無效,除錯器印出的指令會是一串問號;若是讀寫無效記憶體,則會印出完整指令,此時可反組譯當機點附近的指令。之後再檢視暫存器、記憶體與呼叫堆疊來釐清狀態。
分析當機的核心除錯器指令
反組譯:
| 除錯器 | 從當機處 | 從指定位址 |
|---|---|---|
| CDB | u | u ADDR |
| GDB | disassemble | disassemble ADDR |
| LLDB | disassemble --frame | disassemble --start-address ADDR |
暫存器(顯示 / 顯示單一 / 設定):
| 除錯器 | 全部 | 單一 | 設定 |
|---|---|---|---|
| CDB | r | r @rcx | r @rcx = NEWVALUE |
| GDB | info registers | info registers rcx | set $rcx = NEWVALUE |
| LLDB | register read | register read rcx | register write rcx NEWVALUE |
呼叫堆疊追蹤:
| 除錯器 | 顯示堆疊 | 含引數 |
|---|---|---|
| CDB | K | Kb |
| GDB | backtrace | backtrace full |
| LLDB | backtrace |
顯示記憶體值:
| 除錯器 | 位元組 / 字 / 雙字 / 四字 | 顯示 10 個 1-byte 值 |
|---|---|---|
| CDB | db, dw, dd, dq ADDR | db ADDR L10 |
| GDB | x/b, x/h, x/w, x/g ADDR | x/10b ADDR |
| LLDB | memory read --size 1,2,4,8 | memory read --size 1 --count 10 |
行程記憶體映射(判斷位址屬於堆疊 / 堆積 / 映射執行檔):
| 除錯器 | 顯示行程記憶體映射 |
|---|---|
| CDB | !address |
| GDB | info proc mappings |
| LLDB | 無直接對應 |
CDB 在運算式中用
@NAME指定暫存器,GDB/LLDB 則用$NAME。後兩者還有兩個偽暫存器:$pc指向當前執行的指令位址(x64 對應 RIP),$sp指向當前堆疊指標。設定暫存器值可讓程式暫時跳過眼前的當機繼續跑,但若程式已毀損,通常撐不了多久。
兩個當機範例#
以下用 Linux 上的 GDB 為例,展示兩種典型當機的樣貌。
堆疊緩衝區溢位(stack buffer overflow):輸入是一連串重複的 A 字元(hex 0x41)。程式當在試圖執行位址 0x41414141——這個位址剛好是輸入資料的重複拷貝,正是記憶體毀損的徵兆(正常記憶體值應反映執行狀態,如指向堆疊或堆積的指標,不太可能是同值重複)。反組譯確認該處無可執行程式碼,再往回 dump 堆疊,可看到輸入資料確實毀損了堆疊。
Listing 10-3:堆疊緩衝區溢位當機
(gdb) r
Program received signal SIGSEGV, Segmentation fault.
0x41414141 in ?? () ; 試圖執行 0x41414141
(gdb) x/i $pc
=> 0x41414141: Cannot access memory at address 0x41414141
(gdb) x/16xw $sp-16
0xbffff620: 0x41414141 0x41414141 0x41414141 0x41414141
0xbffff630: 0x41414141 0x41414141 0x41414141 0x41414141
0xbffff640: 0x41414141 0x41414141 0x41414141 0x41414141
0xbffff650: 0x41414141 0x41414141 0x41414141 0x41414141這種當機的困難在於:我們是靠呼叫無效位址觸發的,執行 return 的函式已不再被直接參照,堆疊又被毀損,難以擷取呼叫資訊。可往毀損下方的堆疊記憶體找尋漏洞函式留下的返回位址,藉此追出禍首。
堆積緩衝區溢位(heap buffer overflow):這種當機牽涉更廣。當機發生在一條有效指令 mov (%eax),%eax——很可能是 EAX 指向無效記憶體。印出 EAX 是我們的溢位字元重複值,是毀損徵兆。再往下反組譯發現 EAX 被當成函式位址呼叫,且前面有連續兩次解參照,指向這是虛擬函式表(Virtual Function Table, VTable)查表。用 0x41414141 樣式確認值被毀損後,再用 info proc mappings 確認該位址落在 [heap] 區域,即可判定毀損侷限在堆積。
Listing 10-4:堆積緩衝區溢位當機(節錄)
(gdb) r
Program received signal SIGSEGV, Segmentation fault.
0x0804862b in main ()
(gdb) x/i $pc
=> 0x804862b <main+112>: mov (%eax),%eax
(gdb) info registers $eax
eax 0x41414141 1094795585 ; 溢位字元重複 = 毀損徵兆
(gdb) x/5i $pc
=> 0x804862b <main+112>: mov (%eax),%eax
0x804862d <main+114>: sub $0xc,%esp
0x8048630 <main+117>: pushl -0x10(%ebp)
0x8048633 <main+120>: call *%eax ; 把 EAX 當函式位址呼叫(VTable)
0x8048635 <main+122>: add $0x10,%esp
(gdb) info proc mappings
Start Addr End Addr Size objfile
...
0x804a000 0x806b000 0x21000 [heap] ; 毀損值落在堆積區雖然「解參照 VTable 指標時當機」不能直接證明堆積物件被毀損,卻是很好的指標。找出毀損侷限於堆積不見得直指根本成因,但至少能從堆疊上找出呼叫路徑,縮小需要逆向的函式範圍。
提高找出根本成因的機率#
追根本成因很難:堆疊被毀損時你就失去了「當下呼叫的是哪個函式」的資訊;而堆積溢位、釋放後使用(use-after-free)等漏洞,當機甚至可能永遠不在漏洞所在處發生;被毀損的記憶體也可能被設成不會當機的值,導致行為悄悄改變、難以觀察。以下幾種手法能在不花大力氣的前提下提高命中率。
用 Address Sanitizer 重建應用程式#
在 Unix 類系統上,你很可能拿得到原始碼。這不只給你完整除錯資訊,還能重建程式、加入更強的記憶體錯誤偵測。Address Sanitizer(ASan)是 CLANG 的擴充功能,編譯時加上 -fsanitize=address(通常透過 CFLAGS 環境變數指定)即可加入偵測記憶體毀損、越界寫入、use-after-free、double-free 的儀器碼。
ASan 最大的優點是在漏洞條件發生後儘早停下程式,並把細節印到主控台——包含 bug 類型、溢位寫入位址、肇事程式位置與溢位大小。
Listing 10-5 / 10-6:ASan 輸出與符號化
未符號化時,位置只是記憶體位址:
==3998==ERROR: AddressSanitizer: heap-buffer-overflow on address
0xb6102bf4 at pc 0x081087ae bp 0xbf9c64d8 sp 0xbf9c64d0
WRITE of size 1 at 0xb6102bf4 thread T0
#0 0x81087ad (/home/user/triage/heap_overflow+0x81087ad)要拿到原始碼檔名與行號,需啟用符號化並用 -g 編入除錯資訊:
export ASAN_OPTIONS=symbolize=1
export ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer-3.5
./heap_overflow輸出的當機位置就會變成原始碼位置(例如 heap_overflow.c:8:3),大幅簡化定位漏洞程式碼的工作。
Windows 的 Debug 與 Page Heap#
Windows 上你多半拿不到原始碼,只能在既有二進位檔上下工夫。Windows 內建 Page Heap,以系統管理員身分執行下列指令即可對特定程式啟用(gflags 隨 CDB 除錯器一併安裝):
gflags.exe -i appname.exe +hpaPage Heap 的原理是在每次堆積配置之後放一個 OS 定義的保護頁(guard page)。若應用程式讀寫這個保護頁就會立即引發錯誤並通知除錯器——若溢位剛好寫在緩衝區尾端,保護頁會馬上被觸碰,錯誤立刻現形。

圖表 10-1:Page Heap 偵測到一次溢位
Page Heap 極耗記憶體(每次配置都要一個獨立保護頁),設定保護頁又要呼叫系統呼叫、拖慢配置效能。除了除錯之外,不建議常態啟用它。