一个sizeof引发的血案

一个sizeof引发的血案

Jinzhao LiuJinzhao Liu

0x00 写在最前

在这个系列中,我们将会和大家分享一些产品开发过程中所遇到的一些技术难题和具体的解决过程与解决方法。在系列的第一篇中,我们首先将分享一次线上系统崩溃的完整调查过程。希望能给大家提供一点解决类似问题的思路。


0x10 问题描述

某日例行检查时,发现一个部署在生产环境中的服务器程序会随机崩溃并产生coredump。其特征是对于某些特定的输入时,会让程序直接崩溃退出。事关重大,赶紧抓下来看看程序哪里出BUG了。


0x20 收集信息

搜集到足够多的调试信息是问题排查的基础,包括但不限于程序的版本信息、崩溃时的现场信息(Coredump,Syslog等)、程序依赖的动态链接库等。

  • Coredump

Coredump里保存了程序运行时的内存镜像和崩溃现场的寄存器信息,配合gdb等调试工具可以有效的复原现场,通常情况下能够帮助我们快速定位问题出现的位置。

  • 程序binary

由于生产环境中的服务器程序是不携带调试信息的,这样在调试coredump时会很不方便(例如无法看到调用堆栈的函数名和行号等信息)。一个好的办法时使用相同版本的、但是带有调试信息的binary进行调试。因此,在发布程序时,一个好的做法是生成一个带有调试信息的binary,然后使用strip之类的工具在这个binary基础上生成不带调试信息的binary,在生产环境中实际使用。这样在程序出现问题时,便可以很容易根据程序的版本信息找到方便调试的binary。

  • 动态链接库

通常,一个可执行程序会依赖特定的动态链接库。由于调试过程无法在生产环境中的机器上完成,因此我们需要将应用程序所依赖的动态库都拷贝出来。这样做的目的是为了保证调试所使用的动态库同程序运行时所使用的一致,如果版本不一致的话,得到的调试信息会不准确。

在Linux系统中,可以用ldd命令来查看一个应用程序所依赖的动态库信息。

在gdb中,可以通过set sysroot [Directory]指令让gdb加载我们指定的动态库而不是直接使用系统的动态库。

0x30 分析Core

收集到所需要的信息后,便可以祭出gdb以深入coredump查看问题出现的原因。

首先,使用bt指令,我们可以让gdb打印出崩溃时的程序调用堆栈。

通过调用堆栈,可以看到,程序是在执行free这个函数时触发了abort导致程序异常终止。malloc和free这对函数大家一定不陌生,他们是libc为应用程序所提供的一套用于申请/释放内存的函数接口。但是为什么程序在调用free的时候会导致异常退出呢?这是因为libc为我们的程序维护了运行时的内存分配信息,而如果我们的程序在运行过程中无意间破坏了libc用来管理内存分配的元数据的话,就会导致在执行free函数时,libc发现自己的一些数据被破坏了,从而直接抛出异常来终止程序。也就是说,我们的程序出现了所谓的内存破坏(Memory corruption)。

导致free出错的原因有很多,例如后文将会提到的double free也会导致free出错,需要根据程序具体分析

内存破坏

对于Java等有VM的编程语言来说,通常很难遇到内存破坏的问题。JVM会在执行内存操作前通常会检查传入的参数,如果发现传入了非法的参数(例如数组读/写越界),则会触发异常处理流程。由于实际的破坏操作此时尚未被执行,因此通常并不会直接结束程序,而是让程序自己来决定如何处理。但是对于C程序来说,由于并没有类似的内存保护机制,因此非法的内存读写经常可以被成功的执行。

当非法内存操作成功执行之后,程序未必立刻就能产生异常并崩溃。往往需要等到实际读取到了被破坏的那片内存区域时,由于此时这部分内存已经被写入了非法的数据,这时程序才会真正的崩溃。正是由于这种延迟性,内存破坏问题往往难以调试。这是因为程序崩溃时的现场并非内存实际被破坏时的现场,因而难以定位内存破坏实际出现的时间和地点。这真是一个坏消息[sad]。

当然,有坏消息就会有好消息。libc“贴心”的在出现崩溃时向内存里写入了一点错误的信息。尽管只有一点信息,但是作为问题调查的开端,也还是极好的。

这个信息一般会在abort函数的调用堆栈中找到,我们先执行两次up,切换到abort函数的栈帧处。(什么?不记得栈帧是什么了?那你一定要读一读这篇文章:链接)。

我们打印一下当前的栈帧,如下图所示。

可以看到,Arglist在内存地址0x7ffdead0b5f8处。

但是我们知道,abort函数其实是没有参数的,所以这个地址向后的内存应该位于上一层函数的栈帧内。我们打印一下这块内存的内容看一下里面都有啥。

可以看到,栈上有一些看上去像是指针的变量(64位系统的指针长度是8字节),我们尝试着以字符串的方式输出一下这几个(看上去像是指针的)指针所指向的内容。

果然,这些指针指向了一些字符串,而这些字符串描述了free出错时的错误信息:double free or corruption (!prev)。实际上,如果读者有兴趣去看一下glibc(GNU libc)的free函数的实现的话,就可以发现glibc会在执行free操作时执行一些完整性检查,如果检查失败的话,就会输出一些错误信息并调用abort函数来终止程序。到这里,也进一步验证了我们之前的判断:某些用于完整性检查的数据被破坏了。

我们来看glibc提供的出错信息:double free or corruption (!prev)。这提示了两种出错的可能性,第一个显然就是double free了,就是说对同一个内存地址执行了多次free操作。根据之前得到的调试信息(bt),我们在代码对应的位置并没有发现有double free的可能性,所以问题极大的可能仍然是内存破坏引起的。

如果真的是内存破坏的话,上面那句错误信息的后半句可能会给我们很大的帮助。我们知道corruption说的自然是内存破坏,那 (!prev)又代表了什么呢?为了解答这个问题,我们需要深入glibc来了解一下malloc/free究竟是如何管理堆内存的。

glibc会在free出错时将错误信息输出到syslog。但是由于某些特殊的原因,我们无法在生产环境的机器上获取到这部分信息

0x40 堆内存布局

glibc采用的是一个被称作ptmalloc的内存分配器。由于我们并不关心ptmalloc有关多线程性能优化的设计,因此我们并不需要去了解Arena以及不同的Bin的实现细节,我们只需要理解内存分配的基本单元:内存区块(Memory Chunk)即可。在ptmalloc的实现中,内存区块通过一种被称作Boundary Tag的方式串联在一起,相邻的区块之间形成一个类似于链表的数据结构。Boundary Tag会在一个内存区块的开头和结尾都存放着该片内存区域的大小(注:结尾处也有可能并没有存放当前区块的size信息,这取决于当前区块的状态,见后文)。这样做的好处是既可以向后遍历各个内存区块,又可以通过存储在区块之前的前一个区块的size信息向前遍历各个区块。一个内存区块的示例图如下。

如果当前的内存区块处于已分配状态的话,尾部next chunk处的size用来存放用户数据。如果处于未分配状态的话,尾部的size存放的是当前(对于后一个内存区块来说是前一块)内存区块的大小。这样在合并空闲区块时,就可以通过prev_size这个字段找到前一个空闲区块的起始位置,从而实现合并操作。一段连续的内存区块实例如下图所示(注:实际的实现可能会有不同):

由于内存分配通常按一定方式(例如按16字节)进行字节对齐,因此size字段的低几位(跟平台和实现相关)永远是0。因此ptmalloc利用这个几个位来存放一些额外的信息。其中P位(通常是最低位)用来标识前一个内存区块是否处于已分配状态(prev_inuse)。利用这个信息,在释放内存时,如果发现前一个区块处于空闲状态,就可以将两个区块合并成为一个更大的区块,从而减少内存碎片的产生。同时,ptmalloc也可以在释放内存时检查这个位置的值是否是正确的:首先,当前正在释放的内存区块显然是处于inuse状态的;如果后一个区块的P位是0的话,则表示当前区块处于空闲状态。即对当前区块的状态的认识上,前后两个区块产生了分歧。如果不是两次对同一块内存调用了free的话,这只能说明后一个区块的状态位有可能已经被写入了非法值,亦即,后一个区块的内容已经被破坏了。而这也就是错误信息中!prev的含义。glibc中这个完整性检查部分的代码大概是下面这个样子:

if (!prev_inuse(nextchunk)) {
    errstr = "double free or corruption (!prev)";
    goto errout;
} 

0x50 查看被破坏的内存

既然知道问题有可能是由于后一个区块被非法数据破坏所引起的,那我们干脆就来看一下后一个区块的数据被破坏成了什么样子。根据上一节对malloc的介绍,要找到后一个区块的位置,只需要找到当前区块的起始位置和大小,而大小可以通过size字段找到:只需要看一下free的参数向前一个字长处的内存即可。根据调用堆栈,我们可以知道free的参数是0x1043500,那我们就去看一下这个地址之前的malloc元数据。

如图中,0x1043500是传递给free的参数。由于我们的程序在malloc基础上实现了自己的内存管理,在内存区块前增加了额外0x20个字节(在64位PC上)的内存占用。因此malloc的size字段就位于地址0x1043500-0x20-0x08 = 0x10434d8处,并且从图中可以看到,size字段的值是0x1031。去掉最低位的P位信息后(在该示例中,P位的值显然是1。另外还有M位和A位,在该示例中都是0),我们算得该内存区块的实际大小是0x1030字节。就此,我们就可以根据当前内存区块的起始地址和大小来定位后一个内存区块的位置了,也就是0x10434d8+0x1030处。根据前面的说明,这个地址处存放的应当是后一个内存区块的size字段。现在我们来看看它的值究竟是多少。

我们看到,本来应当是存放size字段的位置变成了疑似是字符串的值(地址0x1044508处)。而且毫无疑问,P位的值果然是0(也就是说它认为前一个区块并未处于inuse状态)。这就是free时报错的直接原因。我们把字符串打印出来看一下这段数据究竟是什么。

看上去像是一串字符串’0’和逗号之类的东西,而且笔者敢对灯发誓这绝对不是合法的size值(否则内存得有多大。。)。有了具体的数据,就可以根据其内容到程序源代码中去查找究竟哪一部分代码有可能向内存中写入这样的数据。通过检查程序源代码,最终证明这是一个json字符串的一部分。又根据字符串的内容,我们最终定位到了对应的源代码的位置。

0x60 问题真相

我们先来看一段示例代码,其中包含了导致该问题的BUG(去掉了无关的返回值检查等)。

// 一段有问题的代码
const char *fmt = "this is fmt: %s, blabla...";
...
char *data, *other_data;
size_t data_len, other_data_len;
...
size_t expected_len = (sizeof(fmt)-1-2) + data_len + other_data_len;
char *buf = alloc_memory(expected_len);
buf += snprintf(buf, expected_len, fmt, data);
memcpy(buf, other_data, other_data_len);
你能一眼看出问题所在么?问题就在于,在计算expected_len时,代码使用sizeof来计算字符串fmt的长度。但是要看清楚,fmt的类型是一个 "char *" 而不是 "char []" !!!如果是char *的话,sizeof计算出来的只是一个指针的长度(64位系统上是8字节),这就导致计算得到的expected_len小于实际所需的内存。由于程序自身的内存管理会在内存分配时进行对齐(对于小内存区块,对齐到2的整数次幂),导致大多数情况下对齐之后多出来的部分大于少计算的那部分,从而并不会在每次执行时都导致崩溃,因而出现了一定的随机性,提高了问题排查的难度。

0x70 总结

本篇文章介绍了一次内存破坏的排查过程,在理解了glibc是如何管理内存的基础上,根据收集到的一点线索,可以定位到出现问题的代码,从而除掉BUG。所以,查BUG的过程中最重要的是啥呢?那当然是写代码要认真,不要写出BUG啊!不多说了,笔者要去发BUG红包了。。。我们下回再见。

0x80 参考文献

1. The GNU C Library

2. Malloc-Implementations

3. Understanding glibc malloc

32 条评论