在上一篇文章中,我们讨论了fuzzer是如何确定崩溃的唯一性的。在本文中,我们将为读者介绍如何通过手动方式对崩溃进行分类,并确定导致漏洞的根本原因。为此,我们将以GNU readline 8.1 rc2中发现的一个基于堆的缓冲区溢出漏洞为例进行演示,另外,该漏洞已经在最新的版本中得到了相应的修复。同时,我们将使用GDB和rr进行时间旅行式调试(time – travel debugging),以确定该漏洞的根本原因。
对于GNU readline的源代码,读者可以从这里下载。
下载之后,请利用如下所示的命令进行编译:
$ ./configure --enable-shared=no
make 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>
#endif
int
main (int c, char **v)
{
char * buf = readline("");
if (buf != 0) {
puts(buf);
free(buf);
}
}
接下来,我们还需要编译该示例,具体命令如下所示:
$ cd examples
$ make all
$ echo test | ./rlbasic
test
test
这个设置(加上相应的插桩技术)不仅用于模糊测试,同时,还将用于对各种崩溃进行分类。
经过一段时间的模糊测试之后,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.fuzz
rr: Saving execution to trace directory `/[…]/rlbasic-6
现在,我们就可以重放执行过程了。在这里,我们将使用GEF作为gdbinit脚本。它在两方面对GDB进行了加强:提供了更多的命令,提高了易用性。当然,即使在不借助gdbinit脚本的情况下,我们也可以顺利使用rr。
rr replay /home/till/.local/share/rr/rlbasic-6
gef➤ 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 realloc
Breakpoint 1 at 0x7f0e82ef9580
gef➤ reverse-continue
很快,我们就到了断点处。现在,让我们回顾一下realloc函数的原型及其参数:
void *realloc(void *ptr, size_t size);
接下来,我们可以通过查看调用约定来了解参数是如何传递的。实际上,Ptr将被保存到RDI寄存器中,同时,参数size则是通过RSI寄存器进行传递的。
gef➤info registers
[…]
rdi0x5584b91541f00x5584b91541f0
现在,我们知道了传递的是哪个分块。这样,我们就可以用GEF来研究它了。这正是我喜欢使用gdbinit脚本的众多原因之一。
gef➤heap chunk 0x5584b91541f0
Chunk(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: On
IS_MMAPPED flag: On
NON_MAIN_ARENA flag: On
但是,我们也可以通过手动方式来调查这个分块。此外,读者也可以在此处找到堆块布局的图示。
此外,prev_size将位于实际的数据之前,这也是我们的指针所指向的位置。
gef➤x/2x 0x00005584b91541f0 - 8
0x5584b91541e8:0x373737370x37373737
这些内容看起来与通过GEF收集到的prev_size信息非常相似。由于写入的值是0x3737373737373737或“77777777”,这表明前一个分块中发生了基于堆的缓冲区溢出。现在,我们想找出这些数据是写到哪里的。为此,我们可以在prev_size处放置一个观察点,然后继续利用“时间旅行”查找数据的写入位置。
gef➤watch *0x5584b91541e8
Hardware watchpoint 2: *0x5584b91541e8
gef➤info breakpoints
NumTypeDisp Enb AddressWhat
1breakpointkeep y
0x00007f0e82ef9580 <realloc>
breakpoint already hit 2 times
2hw watchpointkeep y*0x5584b91541e8
gef➤disable 1
gef➤ 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-8
0x5584b91541e8: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-8
0x5584b91541e8: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 = 0xb4
gef➤print rl_point
$4 = 0x350a
gef➤print rl_line_buffer_len
$5 = 0x3500
gef➤print rl_line_buffer
$6 = 0x5584b9150ce0 "\377\200\"\377p|pp\"pppppppp"
gef➤heap chunk 0x5584b9150ce0
Chunk(addr=0x5584b9150ce0, size=0x3510, flags=PREV_INUSE)
Chunk size: 13584 (0x3510)
Usable size: 13576 (0x3508)
Previous chunk size: 0 (0x0)
PREV_INUSE flag: On
IS_MMAPPED flag: Off
NON_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 breakpoints
NumTypeDisp Enb AddressWhat
1breakpointkeep n
0x00007f0e82ef9580 <realloc>
breakpoint already hit 2 times
2hw watchpointkeep n*0x5584b91541e8
breakpoint already hit 2 times
5hw watchpointkeep yrl_end
6hw watchpointkeep yrl_point
现在,我们反向执行。这需要一些时间,因为观察点会被频繁触发,而且条件在相当长的时间内并不会发生改变。
gef➤ reverse-continue
Continuing.
We found the condition were everything was okay:
──────────────────────────────────────────────── source:isearch.c+616 ────
611
612break;
613
614case -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 = 0x11
gef➤print rl_end
$8 = 0x11
gef➤print cxt->save_point
$9 = 0x3468
此时,一个“旧”的上下文被恢复了。但是,这并不会恢复完整的上下文。上面的源代码清单中缺少的就是return语句。这个程序还原了rl_point,但没有还原rl_end。这样的话,我们就能观察到缓冲区溢出的状态了。
下面我们来看看该漏洞的修复代码:
diff --git a/isearch.c b/isearch.c
index 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邮件列表中公布了一个修正了该漏洞的新版本。