深入分析GNU Readline中基于堆的缓冲区溢出漏洞

 

在上一篇文章中,我们讨论了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邮件列表中公布了一个修正了该漏洞的新版本。

(完)