1. 程式人生 > 實用技巧 >AFL 漏洞挖掘

AFL 漏洞挖掘

AFL 漏洞挖掘技術漫談(一):用 AFL 開始你的第一次 Fuzzing

轉載自天融信阿爾法實驗室

一、前言

模糊測試(Fuzzing)技術作為漏洞挖掘最有效的手段之一,近年來一直是眾多安全研究人員發現漏洞的首選技術。AFL、LibFuzzer、honggfuzz等操作簡單友好的工具相繼出現,也極大地降低了模糊測試的門檻。筆者近期學習漏洞挖掘過程中,感覺目前網上相關的的資源有些冗雜,讓初學者有些無從著手,便想在此對學習過程中收集的一些優秀的博文、論文和工具進行總結與梳理、分享一些學習過程中的想法和心得,同時對網上一些沒有涉及到的內容做些補充。

由於相關話題涉及的內容太廣,筆者決定將所有內容分成一系列文章,且只圍繞AFL這一具有里程碑意義的工具展開,從最簡單的使用方法和基本概念講起,再由淺入深介紹測試完後的後續工作、如何提升Fuzzing速度、一些使用技巧以及對原始碼的分析等內容。因為筆者接觸該領域也不久,內容中難免出現一些錯誤和紕漏,歡迎大家在評論中指正。

第一篇文章旨在讓讀者對AFL的使用流程有個基本的認識,文中將討論如下一些基本問題:

  1. AFL的基本原理和工作流程;
  2. 如何選擇Fuzzing的目標?
  3. 如何獲得初始語料庫?
  4. 如何使用AFL構建程式?
  5. AFL的各種執行方式;
  6. 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程式如kelincijava-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. 尋找

  1. 使用專案自身提供的測試用例
  2. 目標程式bug提交頁面
  3. 使用格式轉換器,用從現有的檔案格式生成一些不容易找到的檔案格式:
  4. afl原始碼的testcases目錄下提供了一些測試用例
  5. 其他大型的語料庫
  6. afl generated image test sets
  7. fuzzer-test-suite
  8. libav samples
  9. ffmpeg samples
  10. fuzzdata
  11. moonshine

3. 修剪

網上找到的一些大型語料庫中往往包含大量的檔案,這時就需要對其精簡,這個工作有個術語叫做——語料庫蒸餾(Corpus Distillation)。AFL提供了兩個工具來幫助我們完成這部工作——afl-cminafl-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 modecrash 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背後的原理,敬請各位期待。