AddressSanitizer — 程序员检测内存访问错误的利器
内存访问错误是最常见的软件错误,常常造成程序崩溃。程序员们一直在找寻优秀的内存访问错误检测工具,以便及时定位和排除错误以提高软件的可靠性。2012年由谷歌工程师开发的一款AddressSanitizer工具,以其覆盖面广、高效率和低开销的特性,已成为C/C++程序员们的首选。这里对其原理和使用方法做一个简要的介绍。
工具概述
C/C++语言允许程序员对存储器进行低端控制,这种直接内存管理使编写高效应用软件成为可能。然而,由此也让内存访问错误,包括缓冲区溢出、访问释放后的内存和内存泄漏等,成为程序设计和实现中必须面对的严重问题。虽然有一些工具软件提供了检测这类错误的能力,但是它们的运行效率和功能覆盖常常不太理想。
2012年,谷歌工程师Konstantin Serebryany和团队成员一起发布了名为AddressSanitizer1的开源C/C++程序内存访问错误检测器。AddressSanitizer(简称ASan)应用新的内存分配、映射和代码插桩技术,能高效地检测几乎所有的内存访问错误。使用SPEC 2006基准分析软件包测量,ASan运行过程中的减速比均值不超过2、内存消耗约为2.4倍。相比之下,另一个知名的检测工具Valgrind的减速比均值约为20,几乎无法投入实用。
下表总结了ASan能检测的C/C++程序内存访问错误类型:
错误类型 | 英文 | 简称 | 说明 |
---|---|---|---|
堆内存释放后使用 | heap use after free | UAF | 内存释放后继续访问(悬空指针) |
堆内存缓冲区溢出 | heap buffer overflow | Heap OOB | 动态分配内存越界读写 |
堆内存泄漏 | heap memory leak | HML | 内存使用完毕未被释放 |
全局缓冲区溢出 | global buffer overflow | Global OOB | 全局对象越界读写 |
堆栈作用域后使用 | stack use after scope | UAS | 局部对象在作用域外访问 |
堆栈返回后使用 | stack use after return | UAR | 局部对象在返回后访问 |
堆栈缓冲区溢出 | stack buffer overflow | Stack OOB | 局部对象越界读写 |
其实ASan本身并不包括检测堆内存泄漏功能,只是当集成ASan到编译器时,基于其对内存分配函数的修改,编译工具原来的泄漏检测功能与ASan互相融合到一起了。所以,编译时加入ASan选项也默认打开了泄漏检测功能。
这涵盖了除“读未初始化内存”(uninitialized memory reads,简称UMR)之外所有的常见内存访问错误。ASan检测这些错误的误报率(false positive)为0,这是相当出众的成绩。此外,ASan还能检测一些C++特有的内存访问错误:
- 初始化次序错误(Initialization Order Fiasco):当两个静态对象定义在不同的源文件,且一个对象的构造函数调用另一个对象的方法时,如果前者的编译单元先初始化,就会产生程序崩溃。
- 容器访问溢出(Container Overflow):给定libc++/libstdc++容器container,访问[container.end(), container.begin() + container.capacity())],即超出[container.begin(), container.end()]区域但仍在动态分配的内存区内。
- 删除不匹配(Delete Mismatch):使用
new foo[n]
创建的数组对象,不应该调用delete foo
删除,必须调用delete [] foo
。
ASan的高可靠性和高性能,使它一经问世就得到编译器和集成开发环境开发者的首肯。现今ASan已经集成到全部四大编译工具集中:
编译器/IDE | 起始支持版本 | 操作系统 | 适用平台 |
---|---|---|---|
Clang/LLVM2 | 3.1 | Unix-like | 跨平台 |
GCC | 4.8 | Unix-like | 跨平台 |
Xcode | 7.0 | Mac OS X | 苹果公司产品 |
MSVC | 16.9 | Windows | IA-32、x86-64和ARM |
ASan的研发者最早使用Chromium开源浏览器做常规测试,在10个月的时间里发现了300多个内存访问错误。在集成到主流编译工具之后,它报告了众多流行的开源软件中隐藏已久的错误,如Mozilla Firefox、Perl、Vim、PHP和MySQL等。有趣的是,ASan还找出了LLVM和GCC编译器本身代码中一些内存访问错误。现在,许多软件公司已经将运行ASan加入到必备的质量控制流程中。
工作原理
2012年Serebryany发表的USENIX会议论文3,全面阐述了ASan的设计原理、算法思想和编程实现。在整体结构上,ASan由两部分构成:
- 编译器插桩(compiler instrumentation)模块 — 修改代码用以核对每次内存访问时的影子内存(shadow memory)状态,并在全局和堆栈对象边缘创建毒化的红区(poisoned redzones)以检测向上或向下溢出的情况。
- 运行时库(run-time library)替换模块 — 替换内存分配/释放(
malloc/free
)及其相关函数,用以在动态分配的堆内存区域边缘创建毒化的红区、延迟释放后内存区域的重用并生成出错报告。
这里影子内存、编译器插桩和内存分配函数替换都是之前已经存在的技术,那么ASan是如何创新地应用它们以实现高效的错误检测的呢?让我们来看看细节。
影子内存
许多检测工具使用分离的影子内存记录程序内存的元数据,然后应用插桩在内存访问时检查影子内存,以确认读写是否安全。不同的是,ASan使用了更有效的直接映射影子内存。
ASan的设计者们注意到,典型情况下malloc
函数返回的内存地址至少是8字节对齐的。比如申请20个字节的内存,会划分24字节内存,实际返回指针的最后3比特全为0。此外,任何一个对齐的8字节序列只会有9种不同状态:前 �(0≤�≤8) 字节可访问,后 8−� 不可。由此他们想到了一个更紧凑的影子内存映射和使用方案:
- 预留八分之一的虚拟地址空间给影子内存
- 使用除以8再加上偏移量的公式直接映射应用程序内存到影子内存
- 32位应用程序:
Shadow = (Mem >> 3) + 0x20000000;
- 64位应用程序:
Shadow = (Mem >> 3) + 0x7fff8000;
- 32位应用程序:
- 影子内存的每个字节记录对应8字节内存块的9种状态之一
- 全部8字节可访问,值为0
- 全部8字节不可访问(已毒化),值为负
- 仅首 �(1≤�≤7) 字节可访问,值为 �
下图显示了ASan的地址空间布局和映射关系。留意中间的Bad区,这是影子内存自身映射后的地址段。因为影子内存对应用程序是不可见的,ASan使用页保护机制将之设定为不可访问。
编译器插桩
确定了影子内存的设计,检测动态内存访问错误的编译器插桩实现就很容易了。对于8字节的内存访问,在原读写代码前插入指令检查影子内存字节,如不为0则报错。对于不足8字节的内存访问,插桩较复杂一点,这时要比较影子内存的字节数值与读写地址的最后三个比特。这种情况也被称为“慢通道”(slow path),示例代码如下:
1
|
// Check the cases where we access first k bytes of the qword
|
对于全局和堆栈(局部)对象,ASan设计了不同的插桩检测它们的越界访问错误。全局对象周边的红区由编译器在编译时加入,其地址在应用程序启动时被传递到运行时库,运行库函数再毒化红区并记下地址以便生成错误报告。堆栈对象是在函数调用时创建的,相应地其红区的建立和毒化也在运行时完成。另外,因为堆栈对象在函数返回时被删除,插桩代码还必须将其映射到的影子内存清零。
在实际实现中,ASan编译器插桩的过程被置于编译器优化流水线的末端,这样插桩就只适用于变量和循环优化之后剩余的内存访问指令。在最新的GCC发行版中,ASan编译器插桩代码位于gcc子目录下的两个文件中gcc/asan.[ch]
。
运行库替换
运行库需要加入管理影子内存的代码。在应用程序启动时,要初始化影子内存自身映射到的地址段,以禁止程序的其他部分访问影子内存。运行库替换了旧的内存分配和释放函数,还加入了一些错误报告函数如__asan_report_load8
等。
替换后新的内存分配函数malloc
将在请求的内存块前后分配额外的存储区作为红区,并设置红区为不可寻址。这就是所谓的毒化过程。实际中,因为内存分配器要维护对应不同对象大小的可用内存列表,如果某个对象列表为空时,操作系统会一次性分配一大组内存块及其红区。由此前后内存块的红区会相连,如下图所示,� 个内存块只需要分配 �+1 个红区:
新的free
函数在内存释放后需要毒化整个存储区并将其置于一个隔离(quarantine)队列。这样可以避免存储区被立即分配。否则,如果存储区马上就被重用,就无法检测出对上次释放后内存的错误访问。隔离队列的大小决定了存储区处于隔离状态的时间,越大则检测UAF错误的能力越强!
默认情况下,为了在错误报告中提供更详尽的信息,malloc
和free
函数都会记录其调用栈。malloc
的调用栈保存在所分配内存左边的红区中,故而大的红区可以保留更多的调用栈帧结构。free
的调用栈则保存在所分配内存区的起始处。
集成到GCC编译器中,ASan运行库替换的源代码位于libsanitizer子目录下libsanitizer/asan/*
,编译后产生的运行库名为libasan.so
。
应用示例
ASan的使用非常方便,下面以运行于x86_64虚拟机上的Ubuntu Linux 20.4 + GCC 9.3.0系统为例,演示检测各种内存访问错误的能力。
测试用例
如下所示,测试程序编写了7个函数,各自引入不同的错误类型。函数名与错误类型一一对照:
1
|
/*
|
测试程序调用getopt
库函数支持单字母命令行选项,可让用户选择要测试的错误类型。命令行选项使用信息如下:
1
|
$ ./asan-test
|
测试程序的GCC编译命令很简单,只要加上两个编译选项就够了
-fsanitize=address
:激活ASan工具-g
:启动调试功能,保留调试信息
OOB测试
对于Heap OOB错误,运行结果是
1
|
$ ./asan-test -b
|
参考heap-buffer-overflow
函数实现,可以看到它申请了40个字节的内存,以容纳10个32位整型数。然而在函数返回时,代码越界读取所分配内存之后的数据。如上运行记录显示,程序检测到了Heap OOB错误并立即中止,ASan报告了出错的代码文件名和行号asan-test.c:34
,也准确地列出了动态内存的原始分配函数调用栈。报告的总结(SUMMARY)部分,还打印出了相关地址对应的影子内存数据(观察=>
标注的行)。要读的地址是0x604000000038,其映射后的影子内存地址0x0c087fff8007保存的是负值0xfa(已毒化,不可访问)。正因如此,ASan报错并中止程序运行。
Stack OOB测试用例如下所示。ASan报告了局部对象越界读错误。由于局部变量位于堆栈空间中,所以列出了函数stack_buffr_overflow
的起始行号asan-test.c:37
。与Heap OOB报告不同的是,局部变量的前后红区的影子内存毒化值是不一样的,之前Stack left redzone
为0xf1,之后Stack right redzone
为0xf3。使用不同的毒化值(都是0x80之后的负值),有利于快速区分不同的错误类型。
1
|
$ ./asan-test -s
|
以下Global OOB测试结果,也清晰地显示了出错行asan-test.c:16
、全局变量名ga
和其定义代码位置asan-test.c:13:5
,还可以看到全局对象的红区毒化值为0xf9。
1
|
$ ./asan-test -o
|
注意在这个例子中,全局数组int ga[10] = {1};
是被初始化过的,如果是未初始化的会怎么样?将代码稍作改动
1
|
int ga[10];
|
令人意外的是,ASan没有报告出这里明显的Global OOB错误。为什么?
原因与GCC对全局变量的处理方式有关。编译器将函数和已初始化的变量视为强符号(Strong symbols),而未初始化的变量默认是弱符号(Weak symbols)。因为弱符号在不同的源文件中的定义可能相异,所以其需要空间的大小未知。编译器无法为弱符号在BSS段分配空间,就采用COMMON块的机制,让所有弱符号共享一个COMMON存储区,由此ASan无法插入红区。在链接过程中,链接器读取所有输入目标文件以后,就可以确定弱符号的大小,在最终输出文件的BSS段为其分配空间。
幸运的是,GCC的-fno-common
选项可以关闭COMMON块机制,使编译器直接将所有未初始化的全局变量加入目标文件的BSS段,也能让ASan正常工作。这一选项也禁止链接器合并弱符号,所以当链接器发现目标文件有重复定义的全局变量编译单元时直接报错。
实测证实了这一点,对上一段代码修改GCC命令行为
1
|
gcc asan-test.c -o asan-test -fsanitize=address -fno-common -g
|
编译链接后运行ASan,成功地报告了Global OOB错误。
UAF测试
下面是UAF错误检测的运行记录。这里不仅报告了出错的代码信息,还给出了动态内存的原始分配函数和释放函数的调用栈。记录表明内存由asan-test.c:25
分配,在asan-test.c:27
处被释放,却又在asan-test.c:28
被读出。后面打印的影子内存数据表明所填充的数据是负值0xfd,这也是内存释放后毒化的结果。
1
|
$ ./asan-test -f
|
HML测试
内存泄漏的测试结果如下。与其他测试用例不同,输出记录末尾没有打印ABORTING
。这是因为默认情况下,ASan只在程序终止(进程结束)时生成内存泄漏报告。如果想要在运行中检查是否有泄漏,可以调用ASan自己的库函数__lsan_do_recoverable_leak_check
,其定义位于头文件sanitizer/lsan_interface.h
中。
1
|
$ ./asan-test -l
|
UAS测试
参见stack_use_after_scope
函数代码,局部变量c
所在的存储区在其作用域外被写入。测试记录准确地报告了变量定义所在的行号line 54
及错误写的代码位置asan-test.c:57
:
1
|
./asan-test -p
|
UAR测试
UAR测试有其特殊性。因为函数返回后其堆栈内存会被立即重用,所以要检测局部对象在返回后访问错误,必须设置动态内存分配的“伪堆栈”,具体细节可查询ASan的相关Wiki页4。由于这种算法变化对性能冲击不小,所以ASan默认不会检测UAR错误。如果真的需要,可以在运行前设定环境变量ASAN_OPTIONS
为detect_stack_use_after_return=1
。对应测试记录如下:
1
|
$ export ASAN_OPTIONS=detect_stack_use_after_return=1
|
ASan支持许多其他的编译器标示和运行时环境变量选项,以控制和调整检测的功能和范围,感兴趣者请参考ASan标示Wiki页5。
完整的测试程序的压缩包在此下载:asan-test.c.gz
-
Serebryany, K.; Bruening, D.; Potapenko, A.; Vyukov, D. "AddressSanitizer: a fast address sanity checker". In USENIX ATC, 2012↩︎
- 本文链接: https://www.packetmania.net/2021/08/03/ASAN-intro/
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-ND 许可协议。转载请注明出处!