AddressSanitizer 是一个性能非常好的 C/C++ 内存错误探测工具。它由编译器的插桩模块(目前,LLVM 通过)和替换了 malloc
函数的运行时库组成。这个工具可以探测如下这些类型的错误:
- 对堆,栈和全局内存的访问越界(堆缓冲区溢出,栈缓冲区溢出,和全局缓冲区溢出)
- UAP(Use-after-free,悬挂指针的解引用,或者说野指针)
- Use-after-return(无效的栈上内存,运行时标记
ASAN_OPTIONS=detect_stack_use_after_return=1
) - Use-After-Scope(作用域外访问,clang 标记 -fsanitize-address-use-after-scope )
- 内存的重复释放
- 初始化顺序的 bug
- 内存泄漏
这个工具非常快。通常情况下,内存问题探测这类调试工具的引入,会导致原有应用程序运行性能的大幅下降,比如大名鼎鼎的 valgrind 据说会导致应用程序性能下降到正常情况的十几分之一,但引入 AddressSanitizer 只会减慢运行速度的一半。
AddressSanitizer 的使用
自 LLVM 的版本 3.1 和 GCC 的版本 4.8 开始,AddressSanitizer 就是它们的一部分。如果需要的话,也可以从源码编译 AddressSanitizerHowToBuild。
查看自己的 LLVM 版本和 GCC 版本来确认是否内置了对 AddressSanitizer 的支持:
我本地的工具虽然版本比较老,但对 AddressSanitizer 还是支持的。
看一下前面提到的 AddressSanitizer 的运行时库:
为了使用 AddressSanitizer,需要在使用 GCC 或 Clang 编译链接程序时加上 -fsanitize=address
开关。为了获得合理的性能,可以加上 -O1
或更高。为了在错误信息中获得更友好的栈追踪信息可以加上 -fno-omit-frame-pointer
。为了获得完美的栈追踪信息,还可以禁用内联(使用 -O1
)和尾调用消除(-fno-optimize-sibling-calls
)
下面是一段存在内存访问错误的代码:
使用 GCC 编译并运行:
AddressSanitizer 在探测到内存错误之后,向 stderr 打印了错误信息并以非 0 值返回码退出。AddressSanitizer 在发现第一个错误时退出程序。这主要是基于如下的设计:
- 这种方法允许 AddressSanitizer 产生更快和更小的生成码(总共 ~5%)。
- 解决 bug 变得无法避免。AddressSanitizer 不产生误报。一旦内存崩溃发生,则程序进入不一致的状态,这可能导致令人费解的结果和潜在的误导性的后续报告。
如果进程运行在沙盒中且运行在 OS X 10.10 或更早的版本上,则需要设置DYLD_INSERT_LIBRARIES
环境变量并把它指向由编译器打包用于构建可执行文件的 ASan 库。(可以搜索名字中包含 asan
的动态链接库来找到这个库。)如果没有设置环境变量,则进程将试图重新执行。同时记住,当把可执行文件移动到另一台机器时,ASan 库也需要复制过去。
编译时如果遗漏了 -g
参数,导致可执行文件中缺乏调试信息,则探测到内存错误时,AddressSanitizer 吐出来的错误信息中,无法显示具体的出错的代码行,就像下面这样:
上面的 -g
选项也可以用 -ggdb
选项(尽可能的生成 gdb 的可以使用的调试信息)替换。
使用 LLVM/Clang 编译与上面使用 GCC 编译基本相同:
可以通过 readelf 看一下 GCC 和 LLVM 生成的可执行文件有什么差别:
主要看两个可执行文件中都有的符号 __asan_report_load4
和 __asan_init
,可以看到 GCC 生成的可执行文件动态链接 ASan 库,LLVM 静态链接。
符号化输出
AddressSanitizer 收集如下事件的调用栈:
malloc
和malloc
- 线程创建
- 失败
malloc
和 malloc
发生的相对频繁,且它对于快速解开调用栈非常重要。AddressSanitizer 使用一个依赖帧指针的简单的 unwinder。
如果不关心 malloc
/free
调用栈,简单地完全禁用(使用 malloc_context_size=0
运行时标记)unwinder。
每个栈帧需要被符号化(当然,如果二进制文件编译时带有调试信息)。给定一台 PC,我们需要输出:
AddressSanitizer 使用 Clang 包中的 llvm-symbolizer 符号化栈追踪信息(注意理想的 llvm-symbolizer 版本必须与 ASan 运行时库匹配)。为了使 AddressSanitizer 符号化它的输出,需要设置 ASAN_SYMBOLIZER_PATH
环境变量指向 llvm-symbolizer
二进制文件,或确保 llvm-symbolizer
在 $PATH
中:
llvm-symbolizer
符号化工具属于 llvm 包,Ubuntu 下具体的安装方法可以参考 LLVM Debian/Ubuntu nightly packages。
如果上面的方法不起作用,可以使用一个单独的脚本来离线地符号化结果(在线的符号化可以通过设置 ASAN_OPTIONS=symbolize=0
,或者设置一个空的 ASAN_SYMBOLIZER_PATH
环境变量($ export ASAN_SYMBOLIZER_PATH=
)来强制禁用):
这个脚本接收一个可选的参数 -- a file prefix
。子串 .*prefix
将被从文件名中移除。
上面的 c++filt
用于解函数名的符号重组,这也可以通过给 asan_symbolize.py
脚本添加 -d
参数来完成。
在 OS X 上可能需要对二进制文件运行 dsymutil
以在 AddressSanitizer 报告中获得 file:line
信息。
还可以引入自己的栈追踪格式,使用 stack_trace_format
运行时标记完成。例如:
AddressSanitizer 算法
简单的版本
运行时库替换 malloc
和 free
函数。把 malloc 分配的内存区域(红色区域)附近放入一些特定的字节(使中毒)。把 free
的内存放入隔离区,并且也放入一些特定的字节(使中毒)。程序中的每次内存访问由编译器以下面的方式做一个转换。
之前:
之后:
棘手的部分是如何把 IsPoisoned
实现的高效,且 ReportError
紧凑。同时,对某些访问的插桩可能被证明是冗余的。
内存映射
虚拟地址空间被分割为 2 个互斥的类别:
- 主应用内存(
Mem
):这种内存由常规的应用代码使用。 - 阴影内存(
Shadow
):这种内存包含阴影值(或元数据)。阴影和主应用程序内存之间存在对应关系。在主内存中 使中毒 一个字节意味着在对应的阴影区写入一些特殊的值。
这两种类别的内存应该以阴影内存(MemToShadow
)可以被快速计算出来的方式进行组织。
编译器执行的插桩如下:
映射
AddressSanitizer 把 8 个字节的应用内存映射为 1 个字节的阴影内存。
对于任何 8 字节对齐的应用内存只有 9 个不同的值:
- qword 中的所有 8 字节是未中毒的(比如,可寻址)。阴影值为 0。
- qword 中的所有 8 字节是中毒的(比如,不可寻址)。阴影值为负数。
- 起始的
k
字节是未中毒的,其余的8-k
字节是中毒的。阴影值为k
。这主要由malloc
的行为保证,即malloc
总是返回 8 字节对齐的内存块。一个 qword 对齐的不同字节具有不同状态的仅有的情况是 malloc 的区域的尾部。比如,如果我们调用malloc(13)
,我们将拥有一个完整的未中毒的 qword 和一个开头 5 字节未中毒的 qword。
插桩看起来像下面这样:
|
|
MemToShadow(ShadowAddr)
落入不可寻址的 ShadowGap
区域。因此,如果程序试图直接访问阴影区域中的内存位置,它将崩溃。
64-bit
|
|
[0x10007fff8000, 0x7fffffffffff] | HighMem |
---|---|
[0x02008fff7000, 0x10007fff7fff] | HighShadow |
[0x00008fff7000, 0x02008fff6fff] | ShadowGap |
[0x00007fff8000, 0x00008fff6fff] | LowShadow |
[0x000000000000, 0x00007fff7fff] | LowMem |
32 bit
|
|
[0x40000000, 0xffffffff] | HighMem |
---|---|
[0x28000000, 0x3fffffff] | HighShadow |
[0x24000000, 0x27ffffff] | ShadowGap |
[0x20000000, 0x23ffffff] | LowShadow |
[0x00000000, 0x1fffffff] | LowMem |
超紧凑的阴影区
使用更紧凑的阴影区内存也是可能的,比如:
还在实验中。
报告错误
ReportError
可以被实现为一个调用(当前默认就是这样),但也有一些其它的,稍微更加高效和/或更加紧凑的方案。此刻默认的行为是:
- 把失败地址拷贝到
%rax
(%eax
) - 执行
ud2
(产生 SIGILL) - 在
ud2
之后的一个字节指令中编码访问类型和大小。整体上这 3 个指令需要 5-6 个字节的机器码。
仅使用单个指令(比如 ud2
)也是可能的,但这需要在运行时库中有一个完整的反汇编器(或一些其它的 hacks)。
栈
为了捕获栈溢出,AddressSanitizer 插桩的代码像这样:
原始的代码:
插桩后的代码:
插桩的代码示例(x86_64)
|
|
|
|
未对齐的访问
当前紧凑的映射将不捕获未对齐的部分越界访问:
https://github.com/google/sanitizers/issues/100 中描述了一个可行的方案,但它付出了性能的代价。
运行时库
Malloc
运行时库替换 malloc
/free
,并提供错误报告函数,如 __asan_report_load8
。
malloc
分配由红区围绕的请求数量的内存。阴影值对应的红区被下毒,主内存区域的阴影值被清除。
free
用阴影值对整个区域下毒,并把内存块放入一个隔离区(这样在一定时间内这个内存块将不会再次被 malloc 返回)。
二进制兼容性
二进制兼容性问题指的是,分别用 GCC 和 clang 编译的不同工程,它们之间存在依赖,或要一起使用时出现的问题。如本人遇到的场景,有一个用 clang 编译的动态链接库 A,有一个 GCC 编译的动态链接库 B,还有一个用 GCC 编译的 C/C++ 应用程序 C。它们之间的依赖关系为,B 依赖于 A,C 依赖于 A 和 B。其中只有编译 A 时,开启了 ASAN 以检查内存问题。
上述场景中,在用 CMake 通过 G++ 编译链接 C 应用程序时报出了如下的错误:
看上去是由于没有链接 ASAN 库的原因。通过为 G++ 加上对 ASAN 库的链接依赖(-lasan
),再看一下。
这次的错误少了许多,但链接还是失败了:
为 B 和 C 工程的编译加上编译标记 -fsanitize=address
,再次用 g++ 编译:
错误还是一样的。
为 g++ 添加 -v
参数,查看编译过程的详细信息,可以看到最后链接时执行的命令如下:
通过 readelf -s -W
查看 g++ 链接的 asan 动态链接库中的符号:
确实没有看到上面报错信息中提到的 __asan_unregister_elf_globals
和 __asan_register_elf_globals
这两个符号。
不用 g++ 编译 B 和 C 工程,改用 clang,去掉对 ASAN 库的依赖:
遇到了和用 g++ 编译时遇到的同样的找不到 ASAN 的符号的问题。加上对 ASAN 库的依赖:
遇到了和用 g++ 编译时遇到的相同的问题。为 B 和 C 工程的编译加上编译标记 -fsanitize=address
,再次用 clang 编译,终于编译通过。然而在运行时却遇到了问题:
用 clang 编译时,去掉对 ASAN 库的依赖。再次编译运行,终于 OK。给 clang++ 加上 -v
参数,可以看到最终的链接过程如下:
clang 链接了 /usr/lib/llvm-7/lib/clang/7.1.0/lib/linux/libclang_rt.asan-x86_64.a
。
总结一下,有一个用 clang 编译,且开启了 ASAN debug 的库,在用 g++ 编译依赖该库的库和应用时遇到链接问题,尝试的几种方法的实际结果:
- g++ 直接编译 ===> 失败,找不到所有的 ASAN 符号
- g++ + 链接 asan 库 ===> 失败,找不到符号
__asan_unregister_elf_globals
和__asan_register_elf_globals
。
- g++ + 链接 asan 库 ===> 失败,找不到符号
- g++ +
-fsanitize=address
标记 ===> 失败,和链接 asan 库遇到相同的链接错误。
- g++ +
- clang++ 直接编译 ===> 失败,与 1 中遇到的相同的错误。
- clang++ + 链接 asan 库 ===> 失败,与 2 中遇到的相同的错误。
- clang++ + 链接 asan 库 +
-fsanitize=address
标记 ===> 编译成功,但运行失败。
- clang++ + 链接 asan 库 +
- clang++ +
-fsanitize=address
标记 ===> 编译成功,运行成功,PASS。
- clang++ +
因而,需要用 clang++ + -fsanitize=address
标记编译。