AFL 漏洞挖掘技術漫談(一):用 AFL 開始你的第一次 Fuzzing
一、前言
模糊測試(Fuzzing)技術作為漏洞挖掘最有效的手段之一,近年來一直是眾多安全研究人員發現漏洞的首選技術。AFL、LibFuzzer、honggfuzz等操作簡單友好的工具相繼出現,也極大地降低了模糊測試的門檻。筆者近期學習漏洞挖掘過程中,感覺目前網上相關的的資源有些冗雜,讓初學者有些無從着手,便想在此對學習過程中收集的一些優秀的博文、論文和工具進行總結與梳理、分享一些學習過程中的想法和心得,同時對網上一些沒有涉及到的內容做些補充。
由於相關話題涉及的內容太廣,筆者決定將所有內容分成一系列文章,且只圍繞AFL這一具有里程碑意義的工具展開,從最簡單的使用方法和基本概念講起,再由淺入深介紹測試完后的后續工作、如何提升Fuzzing速度、一些使用技巧以及對源碼的分析等內容。因為筆者接觸該領域也不久,內容中難免出現一些錯誤和紕漏,歡迎大家在評論中指正。
第一篇文章旨在讓讀者對AFL的使用流程有個基本的認識,文中將討論如下一些基本問題:
- AFL的基本原理和工作流程;
- 如何選擇Fuzzing的目標?
- 如何獲得初始語料庫?
- 如何使用AFL構建程序?
- AFL的各種執行方式;
- AFL狀態窗口中各部分代表了什么意義?
二、AFL簡介
AFL(American Fuzzy Lop)是由安全研究員Micha? Zalewski(@lcamtuf)開發的一款基於覆蓋引導(Coverage-guided)的模糊測試工具,它通過記錄輸入樣本的代碼覆蓋率,從而調整輸入樣本以提高覆蓋率,增加發現漏洞的概率。
①從源碼編譯程序時進行插樁,以記錄代碼覆蓋率(Code Coverage);
②選擇一些輸入文件,作為初始測試集加入輸入隊列(queue);
③將隊列中的文件按一定的策略進行“突變”;
④如果經過變異文件更新了覆蓋范圍,則將其保留添加到隊列中;
⑤上述過程會一直循環進行,期間觸發了crash的文件會被記錄下來。
三、選擇和評估測試的目標
開始Fuzzing前,首先要選擇一個目標。 AFL的目標通常是接受外部輸入的程序或庫,輸入一般來自文件(后面的文章也會介紹如何Fuzzing一個網絡程序)。
1. 用什么語言編寫
AFL主要用於C/C++程序的測試,所以這是我們尋找軟件的最優先規則。(也有一些基於AFL的JAVA Fuzz程序如kelinci、java-afl等,但並不知道效果如何)
2. 是否開源
AFL既可以對源碼進行編譯時插樁,也可以使用AFL的QEMU mode
對二進制文件進行插樁,但是前者的效率相對來說要高很多,在Github上很容易就能找到很多合適的項目。
3. 程序版本
目標應該是該軟件的最新版本,不然辛辛苦苦找到一個漏洞,卻發現早就被上報修復了就尷尬了。
4. 是否有示例程序、測試用例
如果目標有現成的基本代碼示例,特別是一些開源的庫,可以方便我們調用該庫不用自己再寫一個程序;如果目標存在測試用例,那后面構建語料庫時也省事兒一點。
5.項目規模
某些程序規模很大,會被分為好幾個模塊,為了提高Fuzz效率,在Fuzzing前,需要定義Fuzzing部分。這里推薦一下源碼閱讀工具Understand,它treemap
功能,可以直觀地看到項目結構和規模。比如下面ImageMagick的源碼中,灰框代表一個文件夾,藍色方塊代表了一個文件,其大小和顏色分別反映了行數和文件復雜度。
6. 程序曾出現過漏洞
如果某個程序曾曝出過多次漏洞,那么該程序有仍有很大可能存在未被發現的安全漏洞。如ImageMagick每個月都會發現難以利用的新漏洞,並且每年都會發生一些具有高影響的嚴重漏洞,圖中可以看到僅2017年就有357個CVE!(圖源medium.com)
四、構建語料庫
AFL需要一些初始輸入數據(也叫種子文件)作為Fuzzing的起點,這些輸入甚至可以是毫無意義的數據,AFL可以通過啟發式算法自動確定文件格式結構。lcamtuf就在博客中給出了一個有趣的例子——對djpeg進行Fuzzing時,僅用一個字符串”hello”作為輸入,最后憑空生成大量jpge圖像!
盡管AFL如此強大,但如果要獲得更快的Fuzzing速度,那么就有必要生成一個高質量的語料庫,這一節就解決如何選擇輸入文件、從哪里尋找這些文件、如何精簡找到的文件三個問題。
1. 選擇
(1) 有效的輸入
盡管有時候無效輸入會產生bug和崩潰,但有效輸入可以更快的找到更多執行路徑。
(2) 盡量小的體積
較小的文件會不僅可以減少測試和處理的時間,也能節約更多的內存,AFL給出的建議是最好小於1 KB,但其實可以根據自己測試的程序權衡,這在AFL文檔的perf_tips.txt
中有具體說明。
2. 尋找
- 使用項目自身提供的測試用例
- 目標程序bug提交頁面
- 使用格式轉換器,用從現有的文件格式生成一些不容易找到的文件格式:
- afl源碼的testcases目錄下提供了一些測試用例
- 其他大型的語料庫
- afl generated image test sets
- fuzzer-test-suite
- libav samples
- ffmpeg samples
- fuzzdata
- moonshine
3. 修剪
網上找到的一些大型語料庫中往往包含大量的文件,這時就需要對其精簡,這個工作有個術語叫做——語料庫蒸餾(Corpus Distillation)。AFL提供了兩個工具來幫助我們完成這部工作——afl-cmin
和afl-tmin
。
(1) 移除執行相同代碼的輸入文件——AFL-CMIN
afl-cmin
的核心思想是:嘗試找到與語料庫全集具有相同覆蓋范圍的最小子集。舉個例子:假設有多個文件,都覆蓋了相同的代碼,那么就丟掉多余的文件。其使用方法如下:
$ afl-cmin -i input_dir -o output_dir -- /path/to/tested/program [params]
更多的時候,我們需要從文件中獲取輸入,這時可以使用“@@”代替被測試程序命令行中輸入文件名的位置。Fuzzer會將其替換為實際執行的文件:
$$ afl-cmin -i input_dir -o output_dir -- /path/to/tested/program [params] @@
下面的例子中,我們將一個有1253個png文件的語料庫,精簡到只包含60個文件。
(2) 減小單個輸入文件的大小——AFL-TMIN
整體的大小得到了改善,接下來還要對每個文件進行更細化的處理。afl-tmin縮減文件體積的原理這里就不深究了,有機會會在后面文章中解釋,這里只給出使用方法(其實也很簡單,有興趣的朋友可以自己搜一搜)。
afl-tmin
有兩種工作模式,instrumented mode
和crash mode
。默認的工作方式是instrumented mode
,如下所示:
$ afl-tmin -i input_file -o output_file -- /path/to/tested/program [params] @@
如果指定了參數-x
,即crash mode
,會把導致程序非正常退出的文件直接剔除。
$ afl-tmin -x -i input_file -o output_file -- /path/to/tested/program [params] @@
afl-tmin
接受單個文件輸入,所以可以用一條簡單的shell腳本批量處理。如果語料庫中文件數量特別多,且體積特別大的情況下,這個過程可能花費幾天甚至更長的時間!
for i in *; do afl-tmin -i $i -o tmin-$i -- ~/path/to/tested/program [params] @@; done;
下圖是經過兩種模式的修剪后,語料庫大小的變化:
這時還可以再次使用afl-cmin
,發現又可以過濾掉一些文件了。
五、構建被測試程序
前面說到,AFL從源碼編譯程序時進行插樁,以記錄代碼覆蓋率。這個工作需要使用其提供的兩種編譯器的wrapper編譯目標程序,和普通的編譯過程沒有太大區別,本節就只簡單演示一下。
1. afl-gcc模式
afl-gcc
/afl-g++
作為gcc
/g++
的wrapper,它們的用法完全一樣,前者會將接收到的參數傳遞給后者,我們編譯程序時只需要將編譯器設置為afl-gcc
/afl-g++
就行,如下面演示的那樣。如果程序不是用autoconf構建,直接修改Makefile
文件中的編譯器為afl-gcc/g++
也行。
$ ./configure CC="afl-gcc" CXX="afl-g++"
在Fuzzing共享庫時,可能需要編寫一個簡單demo,將輸入傳遞給要Fuzzing的庫(其實大多數項目中都自帶了類似的demo)。這種情況下,可以通過設置LD_LIBRARY_PATH
讓程序加載經過AFL插樁的.so文件,不過最簡單的方法是靜態構建,通過以下方式實現:
$ ./configure --disable-shared CC="afl-gcc" CXX="afl-g++"
下面libtiff這個例子中,加上--disable-shared
選項后,libtiff.so
被編譯進了目標程序中。
2. LLVM模式
LLVM Mode模式編譯程序可以獲得更快的Fuzzing速度,用法如下所示:
$ cd llvm_mode
$ apt-get install clang
$ export LLVM_CONFIG=`which llvm-config` && make && cd ..
$ ./configure --disable-shared CC="afl-clang-fast" CXX="afl-clang-fast++"
筆者在使用高版本的clang編譯時會報錯,換成clang-3.9后通過編譯,如果你的系統默認安裝的clang版本過高,可以安裝多個版本然后使用update-alternatives
切換。
六、開始Fuzzing
afl-fuzz
程序是AFL進行Fuzzing的主程序,用法並不難,但是其背后巧妙的工作原理很值得研究,考慮到第一篇文章只是讓讀者有個初步的認識,這節只簡單的演示如何將Fuzzer跑起來,其他具體細節這里就暫時略過。
1. 白盒測試
(1) 測試插樁程序
編譯好程序后,可以選擇使用afl-showmap
跟蹤單個輸入的執行路徑,並打印程序執行的輸出、捕獲的元組(tuples),tuple用於獲取分支信息,從而衡量衡量程序覆蓋情況,下一篇文章中會詳細的解釋,這里可以先不用管。
$ afl-showmap -m none -o /dev/null -- ./build/bin/imagew 23.bmp out.png
[*] Executing './build/bin/imagew'...
-- Program output begins --
23.bmp -> out.png
Processing: 13x32
-- Program output ends --
[+] Captured 1012 tuples in '/dev/null'.
使用不同的輸入,正常情況下afl-showmap
會捕獲到不同的tuples,這就說明我們的的插樁是有效的,還有前面提到的afl-cmin
就是通過這個工具來去掉重復的輸入文件。
$ $ afl-showmap -m none -o /dev/null -- ./build/bin/imagew 111.pgm out.png
[*] Executing './build/bin/imagew'...
-- Program output begins --
111.pgm -> out.png
Processing: 7x7
-- Program output ends --
[+] Captured 970 tuples in '/dev/null'.
(2) 執行FUZZER
在執行afl-fuzz
前,如果系統配置為將核心轉儲文件(core)通知發送到外部程序。 將導致將崩潰信息發送到Fuzzer之間的延遲增大,進而可能將崩潰被誤報為超時,所以我們得臨時修改core_pattern
文件,如下所示:
echo core >/proc/sys/kernel/core_pattern
之后就可以執行afl-fuzz
了,通常的格式是:
$ afl-fuzz -i testcase_dir -o findings_dir /path/to/program [params]
或者使用“@@”替換輸入文件,Fuzzer會將其替換為實際執行的文件:
$ afl-fuzz -i testcase_dir -o findings_dir /path/to/program @@
如果沒有什么錯誤,Fuzzer就正式開始工作了。首先,對輸入隊列中的文件進行預處理;然后給出對使用的語料庫可警告信息,比如這里提示有個較大的文件(14.1KB),且輸入文件過多;最后,開始Fuzz主循環,顯示狀態窗口。
(3) 使用SCREEN
一次Fuzzing過程通常會持續很長時間,如果這期間運行afl-fuzz實例的終端終端被意外關閉了,那么Fuzzing也會被中斷。而通過在screen session
中啟動每個實例,可以方便的連接和斷開。關於screen的用法這里就不再多講,大家可以自行查詢。
$ screen afl-fuzz -i testcase_dir -o findings_dir /path/to/program @@
也可以為每個session命名,方便重新連接。
$ screen -S fuzzer1
$ afl-fuzz -i testcase_dir -o findings_dir /path/to/program [params] @@
[detached from 6999.fuzzer1]
$ screen -r fuzzer1
...
2. 黑盒測試
所謂黑盒測試,通俗地講就是對沒有源代碼的程序進行測試,這時就要用到AFL的QEMU模式了。啟用方式和LLVM模式類似,也要先編譯。但注意,因為AFL使用的QEMU版本太舊,util/memfd.c
中定義的函數memfd_create()
會和glibc中的同名函數沖突,在這里可以找到針對QEMU的patch,之后運行腳本build_qemu_support.sh
就可以自動下載編譯。
$ apt-get install libini-config-dev libtool-bin automake bison libglib2.0-dev -y
$ cd qemu_mode
$ build_qemu_support.sh
$ cd .. && make install
現在起,只需添加-Q
選項即可使用QEMU模式進行Fuzzing。
$ afl-fuzz -Q -i testcase_dir -o findings_dir /path/to/program [params] @@
3. 並行測試
(1) 單系統並行測試
如果你有一台多核心的機器,可以將一個afl-fuzz
實例綁定到一個對應的核心上,也就是說,機器上有幾個核心就可以運行多少afl-fuzz
實例,這樣可以極大的提升執行速度,雖然大家都應該知道自己的機器的核心數,不過還是提一下怎么查看吧:
$ cat /proc/cpuinfo\| grep "cpu cores"\| uniq
afl-fuzz
並行Fuzzing,一般的做法是通過-M
參數指定一個主Fuzzer(Master Fuzzer
)、通過-S
參數指定多個從Fuzzer(Slave Fuzzer
)。
$ screen afl-fuzz -i testcases/ -o sync_dir/ -M fuzzer1 -- ./program
$ screen afl-fuzz -i testcases/ -o sync_dir/ -S fuzzer2 -- ./program
$ screen afl-fuzz -i testcases/ -o sync_dir/ -S fuzzer3 -- ./program
...
這兩種類型的Fuzzer執行不同的Fuzzing策略,前者進行確定性測試(deterministic ),即對輸入文件進行一些特殊而非隨機的的變異;后者進行完全隨機的變異。
可以看到這里的-o
指定的是一個同步目錄,並行測試中所有的Fuzzer將相互協作,在找到新的代碼路徑時,相互傳遞新的測試用例,如下圖中以Fuzzer0的角度來看,它查看其它fuzzer的語料庫,並通過比較id來同步感興趣的測試用例。
afl-whatsup
工具可以查看每個fuzzer的運行狀態和總體運行概況,加上-s
選項只顯示概況,其中的數據都是所有fuzzer的總和。
還afl-gotcpu
工具可以查看每個核心使用狀態。
(2) 多系統並行測試
多系統並行的基本工作原理類似於單系統並行中描述的機制,你需要一個簡單的腳本來完成兩件事。在本地系統上,壓縮每個fuzzer實例目錄中queue
下的文件,通過SSH分發到其他機器上解壓。
來看一個例子,假設現在有兩台機器,基本信息如下:
fuzzer1 | fuzzerr2 |
---|---|
172.21.5.101 | 172.21.5.102 |
運行2個實例 | 運行4個實例 |
為了能夠自動同步數據,需要使用authorized_keys
的方式進行身份驗證。現要將fuzzer2中每個實例的輸入隊列同步到fuzzer1中,可以下面的方式:
#!/bin/sh
# 所有要同步的主機
FUZZ_HOSTS='172.21.5.101 172.21.5.102'
# SSH user
FUZZ_USER=root
# 同步目錄
SYNC_DIR='/root/syncdir'
# 同步間隔時間
SYNC_INTERVAL=$((30 * 60))
if [ "$AFL_ALLOW_TMP" = "" ]; then
if [ "$PWD" = "/tmp" -o "$PWD" = "/var/tmp" ]; then
echo "[-] Error: do not use shared /tmp or /var/tmp directories with this script." 1>&2
exit 1
fi
fi
rm -rf .sync_tmp 2>/dev/null
mkdir .sync_tmp || exit 1
while :; do
# 打包所有機器上的數據
for host in $FUZZ_HOSTS; do
echo "[*] Retrieving data from ${host}..."
ssh -o 'passwordauthentication no' ${FUZZ_USER}@${host} \
"cd '$SYNC_DIR' && tar -czf - SESSION*" >".sync_tmp/${host}.tgz"
done
# 分發數據
for dst_host in $FUZZ_HOSTS; do
echo "[*] Distributing data to ${dst_host}..."
for src_host in $FUZZ_HOSTS; do
test "$src_host" = "$dst_host" && continue
echo " Sending fuzzer data from ${src_host}..."
ssh -o 'passwordauthentication no' ${FUZZ_USER}@$dst_host \
"cd '$SYNC_DIR' && tar -xkzf - &>/dev/null" <".sync_tmp/${src_host}.tgz"
done
done
echo "[+] Done. Sleeping for $SYNC_INTERVAL seconds (Ctrl-C to quit)."
sleep $SYNC_INTERVAL
done
成功執行上述shell腳本后,不僅SESSION000
SESSION002
中的內容更新了,還將SESSION003
SESSION004
也同步了過來。
七、認識AFL狀態窗口
通過狀態窗口,我們可以監控Fuzzer運行時的各種信息,在status_screen中有詳細的說明,這里只是做一個簡單的介紹,對已經了解這部分的讀者可以直接跳過,如果需要更具體的內容,可以去看看原文。另外說一下,該輸出信息也不是必須的,后面的文章中會提到如何將Fuzzer的輸出重定向到/dev/null
,然后通過其他方法取得Fuzzer運行狀態。
① Process timing:Fuzzer運行時長、以及距離最近發現的路徑、崩潰和掛起經過了多長時間。
② Overall results:Fuzzer當前狀態的概述。
③ Cycle progress:我們輸入隊列的距離。
④ Map coverage:目標二進制文件中的插樁代碼所觀察到覆蓋范圍的細節。
⑤ Stage progress:Fuzzer現在正在執行的文件變異策略、執行次數和執行速度。
⑥ Findings in depth:有關我們找到的執行路徑,異常和掛起數量的信息。
⑦ Fuzzing strategy yields:關於突變策略產生的最新行為和結果的詳細信息。
⑧ Path geometry:有關Fuzzer找到的執行路徑的信息。
⑨ CPU load:CPU利用率
八、總結
到此為止,本文已經介紹完了如何開始一次Fuzzing,但這僅僅是一個開始。AFL 的Fuzzing過程是一個死循環,我們需要人為地停止,那么什么時候停止?上面圖中跑出的18個特別的崩潰,又如何驗證?還有文中提到的各種概念——代碼覆蓋率、元組、覆蓋引導等等又是怎么回事兒?所謂學非探其花,要自拔其根,學會工具的基本用法后,要想繼續進階的話,掌握這些基本概念相當重要,有助於后續更深層次內容的理解。所以后面的幾篇文章,首先會繼續本文中未完成的工作,然后詳細講解重要概念和AFL背后的原理,敬請各位期待。