首頁>技術>

在上一篇文章中,我們討論了fuzzer是如何確定崩潰的唯一性的。在本文中,我們將為讀者介紹如何透過手動方式對崩潰進行分類,並確定導致漏洞的根本原因。為此,我們將以GNU readline 8.1 rc2中發現的一個基於堆的緩衝區溢位漏洞為例進行演示,另外,該漏洞已經在最新的版本中得到了相應的修復。同時,我們將使用GDB和rr進行時間旅行式除錯(time – travel debugging),以確定該漏洞的根本原因。

對於GNU readline的原始碼,讀者可以從這裡下載。

下載之後,請利用如下所示的命令進行編譯:

$ ./configure --enable-shared=nomake all

我對其中一個示例進行了相應的修改,以使其更加簡單明瞭。

$ cat examples/rlbasic.c#include <stdlib.h>#include <unistd.h>#include <stdio.h>#include <string.h>#if defined (READLINE_LIBRARY)#include "readline.h"#include "history.h"#else#include <readline/readline.h>#include <readline/history.h>#endifintmain (int c, char **v){char * buf = readline("");if (buf != 0) {puts(buf);free(buf);}}

接下來,我們還需要編譯該示例,具體命令如下所示:

$ cd examples$ make all$ echo test | ./rlbasictesttest

這個設定(加上相應的插樁技術)不僅用於模糊測試,同時,還將用於對各種崩潰進行分類。

經過一段時間的模糊測試之後,honggfuzz報告了第一個崩潰事件。正如上一篇文章中提到的,這時將有許多資訊被嵌入到發生崩潰的程式碼所在的檔名中。下面是honggfuzz建立的一些檔案。

'SIGABRT.PC.7ffff7c03615.STACK.19d36d1d13.CODE.-6.ADDR.0.INSTR.mov____0x108(%rsp),%rax.fuzz''SIGABRT.PC.7ffff7c03615.STACK.eb563da6d.CODE.-6.ADDR.0.INSTR.mov____0x108(%rsp),%rax.fuzz''SIGABRT.PC.7ffff7c03615.STACK.ec136d3ea.CODE.-6.ADDR.0.INSTR.mov____0x108(%rsp),%rax.fuzz''SIGABRT.PC.7ffff7c03615.STACK.f43c4c1ee.CODE.-6.ADDR.0.INSTR.mov____0x108(%rsp),%rax.fuzz'

我們可以在檔名的第一部分中看到,這裡傳送的訊號是“abort(異常終止)”。此外,我們還可以看到,所有崩潰的程式計數器的值都是相同的(即0x7ffff7c03615)。這兩點都表明,這些都是與堆有關的問題(SIGABRT),並且可能是同一型別的問題,例如基於堆的緩衝區溢位漏洞或Double Free漏洞。

為了收集崩潰發生時的一手資訊,我們可以藉助於Valgrind看看到底發生了什麼事情。

$ valgrind --tool=memcheck ./rlbasic > /dev/null < SIGABRT.PC.7ffff7c03615.STACK.f43c4c1ee.CODE.-6.ADDR.0.INSTR.mov____0x108\(%rsp\),%rax.fuzz==510271== Memcheck, a memory error detector==510271== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.==510271== Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info==510271== Command: ./rlbasic==510271====510271== Invalid write of size 1==510271==at 0x483DF68: strncpy (vg_replace_strmem.c:550)==510271==by 0x13221D: rl_insert_text (text.c:99)==510271==by 0x1325F9: _rl_insert_char.part.0 (text.c:902)==510271==by 0x133B8D: _rl_insert_char (text.c:720)==510271==by 0x133B8D: rl_insert (text.c:965)==510271==by 0x112FFA: _rl_dispatch_subseq (readline.c:887)==510271==by 0x1135AF: _rl_dispatch (readline.c:833)==510271==by 0x1135AF: readline_internal_char (readline.c:645)==510271==by 0x113F2C: readline_internal_charloop (readline.c:694)==510271==by 0x113F2C: readline_internal (readline.c:706)==510271==by 0x113F2C: readline (readline.c:385)==510271==by 0x11262F: main (rlbasic.c:17)==510271==Address 0x4bb2c20 is 0 bytes after a block of size 13,568 alloc'd[...]==510271== Invalid read of size 8==510271==at 0x12EBC8: _rl_free_undo_list (undo.c:108)==510271==by 0x12EBC8: rl_free_undo_list (undo.c:124)==510271==by 0x112B4D: readline_internal_teardown (readline.c:498)==510271==by 0x113F43: readline_internal (readline.c:707)==510271==by 0x113F43: readline (readline.c:385)==510271==by 0x11262F: main (rlbasic.c:17)==510271==Address 0x3737373737373737 is not stack'd, malloc'd or (recently) free'd==510271====510271====510271== Process terminating with default action of signal 11 (SIGSEGV): dumping core==510271==General Protection Fault==510271==at 0x12EBC8: _rl_free_undo_list (undo.c:108)==510271==by 0x12EBC8: rl_free_undo_list (undo.c:124)==510271==by 0x112B4D: readline_internal_teardown (readline.c:498)==510271==by 0x113F43: readline_internal (readline.c:707)==510271==by 0x113F43: readline (readline.c:385)==510271==by 0x11262F: main (rlbasic.c:17)==510271====510271== HEAP SUMMARY:==510271==in use at exit: 382,054 bytes in 898 blocks==510271==total heap usage: 4,421 allocs, 3,523 frees, 1,342,095 bytes allocated[…]

上面的內容向我們展示了兩個重要的線索。第一部分內容表明,在rl_insert_text.c:99處可能存在一個堆溢位問題;第二部分內容表明,我們控制了可能被釋放的資料(0x373737373737或 “7777777”)。需要注意的是,Valgrind的輸出可能跟其他工具(如“Dr Memory”)以及glibc錯誤資訊的結果會有所不同,這取決於它們對與堆相關的問題的具體檢查方式。

rr(表示記錄和重放)是GDB的一個增強工具。它實際上會執行兩件事:記錄二進位制程式碼的執行過程,並在稍後重放執行過程。需要注意的是,重放將始終是確定性的。此外,重放還可以進行反轉。同時,它還支援在執行流程中後退一步,而其他工具通常只能向前走一步;這有時被稱為時間旅行式除錯。下面,讓我們首先來記錄程式碼的執行情況。

$ rr record -n ./rlbasic < SIGABRT.PC.7ffff7c03615.STACK.f43c4c1ee.CODE.-6.ADDR.0.INSTR.mov____0x108\(%rsp\),%rax.fuzzrr: Saving execution to trace directory `/[…]/rlbasic-6

現在,我們就可以重放執行過程了。在這裡,我們將使用GEF作為gdbinit指令碼。它在兩方面對GDB進行了加強:提供了更多的命令,提高了易用性。當然,即使在不借助gdbinit指令碼的情況下,我們也可以順利使用rr。

rr replay /home/till/.local/share/rr/rlbasic-6gef➤ continue[…]───────────────────────────────────────────────────────────── threads ────[#0] Id 1, stopped 0x7f0e82eaa615 in raise (), reason: SIGABRT─────────────────────────────────────────────────────────────── trace ────[#0] 0x7f0e82eaa615 → raise()[#1] 0x7f0e82e93862 → abort()[#2] 0x7f0e82eec5e8 → __libc_message()[#3] 0x7f0e82ef427a → malloc_printerr()[#4] 0x7f0e82ef47ec → mremap_chunk()[#5] 0x7f0e82ef9670 → realloc()[#6] 0x5584b8275f2e → xrealloc(pointer=<optimized out>, bytes=0x8000)[#7] 0x5584b826017c → realloc_line(minsize=<optimized out>)[#8] 0x5584b82651ab → invis_addc(face=0x30, c=0x5e, outp=<synthetic pointer>)[#9] 0x5584b82651ab → rl_redisplay()

我們可以透過GDB考察SIGABRT。回溯表明,程式碼呼叫了realloc函式。現在,我們可以在realloc處放置一個斷點,然後使用reverse-continue命令以相反的順序繼續執行(也就是所謂的時間旅行)。

gef➤break reallocBreakpoint 1 at 0x7f0e82ef9580gef➤ reverse-continue

很快,我們就到了斷點處。現在,讓我們回顧一下realloc函式的原型及其引數:

void *realloc(void *ptr, size_t size);

接下來,我們可以透過檢視呼叫約定來了解引數是如何傳遞的。實際上,Ptr將被儲存到RDI暫存器中,同時,引數size則是透過RSI暫存器進行傳遞的。

gef➤info registers[…]rdi0x5584b91541f00x5584b91541f0

現在,我們知道了傳遞的是哪個分塊。這樣,我們就可以用GEF來研究它了。這正是我喜歡使用gdbinit指令碼的眾多原因之一。

gef➤heap chunk 0x5584b91541f0Chunk(addr=0x5584b91541f0, size=0x3737373737373730, flags=PREV_INUSE|IS_MMAPPED|NON_MAIN_ARENA)Chunk size: 3978709506094217008 (0x3737373737373730)Usable size: 3978709506094216992 (0x3737373737373720)Previous chunk size: 3978709506094217015 (0x3737373737373737)PREV_INUSE flag: OnIS_MMAPPED flag: OnNON_MAIN_ARENA flag: On

但是,我們也可以透過手動方式來調查這個分塊。此外,讀者也可以在此處找到堆塊佈局的圖示。

此外,prev_size將位於實際的資料之前,這也是我們的指標所指向的位置。

gef➤x/2x 0x00005584b91541f0 - 80x5584b91541e8:0x373737370x37373737

這些內容看起來與透過GEF收集到的prev_size資訊非常相似。由於寫入的值是0x3737373737373737或“77777777”,這表明前一個分塊中發生了基於堆的緩衝區溢位。現在,我們想找出這些資料是寫到哪裡的。為此,我們可以在prev_size處放置一個觀察點,然後繼續利用“時間旅行”查詢資料的寫入位置。

gef➤watch *0x5584b91541e8Hardware watchpoint 2: *0x5584b91541e8gef➤info breakpointsNumTypeDisp Enb AddressWhat1breakpointkeep y0x00007f0e82ef9580 <realloc>breakpoint already hit 2 times2hw watchpointkeep y*0x5584b91541e8gef➤disable 1gef➤ reverse-continue[…][#0] Id 1, stopped 0x7f0e82fd1933 in __strncpy_avx2 (), reason: BREAKPOINT─────────────────────────────────────────────────────────────── trace ────[#0] 0x7f0e82fd1933 → __strncpy_avx2()[#1] 0x5584b826d21e → rl_insert_text(string=0x7ffc188192a0 "7")[#2] 0x5584b826d5fa → _rl_insert_char(count=0x1, c=0x37)[#3] 0x5584b826eb8e → _rl_insert_char(c=<optimized out>, count=0x1)[#4] 0x5584b826eb8e → rl_insert(count=<optimized out>, c=<optimized out>)[#5] 0x5584b824dffb → _rl_dispatch_subseq(key=<optimized out>, map=0x5584b8287420 <emacs_standard_keymap>, got_subseq=<optimized out>)[#6] 0x5584b824e5b0 → _rl_dispatch(map=<optimized out>, key=<optimized out>)[#7] 0x5584b824e5b0 → readline_internal_char()[#8] 0x5584b824ef2d → readline_internal_charloop()[#9] 0x5584b824ef2d → readline_internal()──────────────────────────────────────────────────────────────────────────gef➤x/x 0x5584b91541f0-80x5584b91541e8:0x00373737

為了保險起見,讓我們再重複一次這個過程。

gef➤ reverse-continue[…]───────────────────────────────────────────────────────────── threads ────[#0] Id 1, stopped 0x7f0e82fd1933 in __strncpy_avx2 (), reason: BREAKPOINT─────────────────────────────────────────────────────────────── trace ────[#0] 0x7f0e82fd1933 → __strncpy_avx2()[#1] 0x5584b826d21e → rl_insert_text(string=0x7ffc188192a0 "7")[#2] 0x5584b826d5fa → _rl_insert_char(count=0x1, c=0x37)[#3] 0x5584b826eb8e → _rl_insert_char(c=<optimized out>, count=0x1)[#4] 0x5584b826eb8e → rl_insert(count=<optimized out>, c=<optimized out>)[#5] 0x5584b824dffb → _rl_dispatch_subseq(key=<optimized out>, map=0x5584b8287420 <emacs_standard_keymap>, got_subseq=<optimized out>)[#6] 0x5584b824e5b0 → _rl_dispatch(map=<optimized out>, key=<optimized out>)[#7] 0x5584b824e5b0 → readline_internal_char()[#8] 0x5584b824ef2d → readline_internal_charloop()[#9] 0x5584b824ef2d → readline_internal()──────────────────────────────────────────────────────────────────────────gef➤x/x 0x5584b91541f0-80x5584b91541e8:0x00003737

這看起來確實像是一步一步地寫入資料,但就本例來說,更像是未寫入資料。因為如果重複呼叫同一個函式,將有越來越多的資料按位元組寫入。

現在,我們有了足夠的資訊來考察原始碼,下面看看到底發生了什麼事情。實際上,函式rl_insert_text可以在text.c:85中找到,下面的展示的是我們感興趣的部分:

if (rl_end + l >= rl_line_buffer_len)rl_extend_line_buffer (rl_end + l);for (i = rl_end; i >= rl_point; i--)rl_line_buffer[i + l] = rl_line_buffer[i];strncpy (rl_line_buffer + rl_point, string, l);[…]rl_point += l;rl_end += l;

由此可見,溢位發生在第99行,其中strncpy溢位到了下一個分塊中。這種情況是如何發生的呢?

gef➤print rl_end$3 = 0xb4gef➤print rl_point$4 = 0x350agef➤print rl_line_buffer_len$5 = 0x3500gef➤print rl_line_buffer$6 = 0x5584b9150ce0 "\377\200\"\377p|pp\"pppppppp"gef➤heap chunk 0x5584b9150ce0Chunk(addr=0x5584b9150ce0, size=0x3510, flags=PREV_INUSE)Chunk size: 13584 (0x3510)Usable size: 13576 (0x3508)Previous chunk size: 0 (0x0)PREV_INUSE flag: OnIS_MMAPPED flag: OffNON_MAIN_ARENA flag: Off

我們從頭分析一下這段程式碼。如果rl_end(一個全域性變數)+l大於rl_line_buffer_len的話,那麼緩衝區就會被擴充套件,其中rl_extend_line_buffer是realloc函式的一個封裝函式。同時,它還負責調整rl_line_buffer_len的大小。在text.c:99中(我們的程式碼段中的第6行),發生了溢位現象。其中,rl_line_buffer是一個指向堆分配緩衝區的指標,注意我們加上了rl_point的值。然而,這裡從來沒有檢查過這個算術運算後,它是否仍然指向我們分配的緩衝區。實際上,關於緩衝區及其大小的檢查只有一次,並且在檢查過程中,大小是與rl_end和l進行比較的。透過原始碼我們可以瞭解到,rl_end應該指向緩衝區的末端,而rl_point應該指向緩衝區中的某個位置。

See Readline.h:545/* The location of point, and end. */extern int rl_point;extern int rl_end;

因此,透過檢查rl_point + l和rl_line_buffer_len的比較結果應該能夠防止這種記憶體損壞,但就這裡來說沒有任何意義。因此,我們需要找到狀態被破壞的地方,以至於rl_point > rl_end。

我們可以再次使用具有反向除錯功能的GDB會話。現在,我們將禁用現有的觀察點。同時,我們將使用Python指令碼,因為GDB Python API允許對斷點類進行子類化並編寫自定義停止函式。根據自定義stop函式的返回值,程式將中斷,我們可以對其進行分析,或者斷點將以靜默方式步進。我們將用GDB指令碼比較rl_point和rl_end的值,如果rl_point值較大,程式就會中斷。

然後,我們將把這個自定義的斷點用作rl_point和rl_end的觀察點。按照Gist的描述應用該指令碼後,我們得到了四個斷點:

gef➤info breakpointsNumTypeDisp Enb AddressWhat1breakpointkeep n0x00007f0e82ef9580 <realloc>breakpoint already hit 2 times2hw watchpointkeep n*0x5584b91541e8breakpoint already hit 2 times5hw watchpointkeep yrl_end6hw watchpointkeep yrl_point

現在,我們反向執行。這需要一些時間,因為觀察點會被頻繁觸發,而且條件在相當長的時間內並不會發生改變。

gef➤ reverse-continueContinuing.We found the condition were everything was okay:──────────────────────────────────────────────── source:isearch.c+616 ────611612break;613614case -4:/* C-G, abort */615rl_replace_line (cxt->lines[cxt->save_line], 0);→616rl_point = cxt->save_point;617rl_mark = cxt->save_mark;618rl_deactivate_mark ();619rl_restore_prompt();620rl_clear_message ();621───────────────────────────────────────────────────────────── threads ────[#0] Id 1, stopped 0x5584b825f171 in _rl_isearch_dispatch (), reason: BREAKPOINT─────────────────────────────────────────────────────────────── trace ────[#0] 0x5584b825f171 → _rl_isearch_dispatch(cxt=0x5584b915b750, c=<optimized out>)[#1] 0x5584b825ffef → _rl_isearch_dispatch(c=<optimized out>, cxt=0x5584b915b750)[#2] 0x5584b825ffef → rl_search_history(direction=<optimized out>, invoking_key=<optimized out>)[#3] 0x5584b824dffb → _rl_dispatch_subseq(key=<optimized out>, map=0x5584b8287420 <emacs_standard_keymap>, got_subseq=<optimized out>)[#4] 0x5584b824e5b0 → _rl_dispatch(map=<optimized out>, key=<optimized out>)[#5] 0x5584b824e5b0 → readline_internal_char()[#6] 0x5584b824ef2d → readline_internal_charloop()[#7] 0x5584b824ef2d → readline_internal()[#8] 0x5584b824ef2d → readline(prompt=0x5584b827842b "")[#9] 0x5584b824d630 → main(c=<optimized out>, v=<optimized out>)──────────────────────────────────────────────────────────────────────────gef➤print rl_point$7 = 0x11gef➤print rl_end$8 = 0x11gef➤print cxt->save_point$9 = 0x3468

此時,一個“舊”的上下文被恢復了。但是,這並不會恢復完整的上下文。上面的原始碼清單中缺少的就是return語句。這個程式還原了rl_point,但沒有還原rl_end。這樣的話,我們就能觀察到緩衝區溢位的狀態了。

下面我們來看看該漏洞的修復程式碼:

diff --git a/isearch.c b/isearch.cindex ef65e5f..080ba3c 100644--- a/isearch.c+++ b/isearch.c@@ -619,6 +619,7 @@ opcode_dispatch:rl_restore_prompt();rl_clear_message ();+_rl_fix_point (1);/* in case save_line and save_point are out of sync */return -1;case -5:/* C-W */

如果rl_line和rl_point不同步,則透過_rl_fix_point進行同步處理。透過這次修正,所有由honggfuzz報告的Abort都被修正了。所以,雖然honggfuzz進行了一些初步的篩選和分類,但造成的崩潰的一個根本原因,用時間旅行式除錯是無法發現的。本文中,我們是透過rr來分析這個漏洞的根本原因的,從而節省了很多時間和腦細胞,因為我們可以輕鬆地將執行流程逆轉到之前的時間點。同時,這也讓本文的撰寫和步驟記錄變得異常簡單。如果您意識到自己之前搞砸了什麼,並報告了錯誤的資訊,那麼,您可以直接穿越到出問題的時間點,直接修復相關資料即可,哈哈哈!

在此,我們感謝GNU readline的維護者Chet Ramey修復了這個漏洞,並指出了我的最初漏洞分析中的一處謬誤。最初,我在反向除錯時,檢查了rl_point大於rl_end的情況,但後來,我忽略了這些值是透過_rl_fix_point同步的。在寫這篇文章的時候,我注意到,如果在問題出現之前進行相應的檢查,事情會變得更加簡單。

現在,我們知道了該漏洞的相關細節,如果我們能在其他地方檢測到這個問題就更好了。為此,我們可以藉助於相關的靜態工具,比如CodeQL或Joern。我之所以選擇使用動態的方法,是因為透過之前和這次模糊測試活動,我已經得到了一個corpus。在檢測過程中,我使用Frida的Stalker在每個返回語句處獲得控制權,並檢查rl_point是否大於rl_end。最後,我沒有在其他地方發現這個漏洞。如果您對這個Frida指令碼感興趣的話,可以從這裡找到它。

時間線

2020-11-10 向GNU readline維護者提交漏洞報告。

2020-11-10維護者確認了這個漏洞。

2020-11-18 在GNU readline郵件列表中公佈了一個修正了該漏洞的新版本。

12
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 大廠的Android工程師到底厲害在哪裡?