使用 Address Sanitizer 排查内存越界
在 C++ 开发中,内存越界是很头痛的问题。这类问题往往非常隐蔽,难以排查,且难以复现。为了排查这类问题,我们可以使用 Address Sanitizer 这个工具。
Address Sanitizer (aka ASan) 是 Google 开发的一款内存错误排查工具,帮助开发这定位各种内存错误,目前已经集成在主流工具链中。LLVM 3.1 和 GCC 4.8 以上的版本均支持 ASan。本文介绍 ASan 的使用方法和其基本原理。
如何使用
在支持 ASan 的编译器加上编译参数 -fsanitize=address
即可开启 ASan。例如我们有代码 test.c
:
1 |
|
虽然存在内存越界写入,但是使用常规方法编译后,是能够“正常”运行的:
1 |
|
加上编译参数 -fsanitize=address
启用 ASan 后,运行便会触发 ASan 报错:
1 |
|
ASan 告诉我们程序触发了一个堆越界 (heap-buffer-overflow) 的报错,尝试在地址 0x602000000014 写入 4 字节数据。随后打印出出错位置的堆栈。第 10 行 ASan 还告诉我们越界的内存是在哪个位置被分配的,随后打印出分配位置的堆栈。
常用参数
ASan 的参数有编译参数和运行时参数两种。常用的编译参数有:
-fsanitize=address
启用 ASan-fno-omit-frame-pointer
获得更好的堆栈信息- ASan 专属的参数,GCC 使用
--param FLAG=VAL
传入,LLVM 使用-mllvm -FLAG=VAL
传入:asan-stack
是否检测栈内存错误,默认启用。GCC 使用--param asan-stack=0
、LLVM 使用-mllvm -asan-stack=0
关闭栈内存错误检测。asan-global
是否检测全局变量内存错误,默认启用。同理使用--param asan-global=0
或-mllvm -asan-global=0
可关闭。
运行时参数通过环境变量 ASAN_OPTIONS
设置,每个参数的格式为 FLAG=VAL
,参数之间用冒号 :
分隔。例如:
1 |
|
常用的运行时参数有:
log_path
: ASan 报错默认输出在 stderr。使用这个参数可以指定报错输出的路径。abort_on_error
: 报错时默认使用_exit
结束进程。指定abort_on_error=1
则使用abort
结束进程。disable_coredump
: Asan 默认会禁用 coredump。指定disable_coredump=0
启用 coredump。detect_leaks
: 是否启用内存泄漏检测,默认启用。ASan 还包含 LSan (Leak Sanitizer) 内存泄露检测模块。handle_*
: 信号控制选项。ASan 默认会注册一些信号处理函数,参数置 0 表示让 ASan 不注册相应的信号信号处理器,置 1 则注册信号信号处理器,置 2 则注册信号处理器并禁止用户修改。handle_segv
: SIGSEGVhandle_sigbus
: SIGBUShandle_abort
: SIGABRThandle_sigill
: SIGILLhandle_sigfpe
: SIGFPE
因为 ASan 默认会注册 SIGSEGV 的信号处理器,所以当程序发生段错误时,会触发 ASan 的报错而不是直接 coredump。要想让程序像往常一样产生 coredump,可以指定参数 handle_segv=0
不注册信号处理器,和 disable_coredump=0
启用 coredump。
有些函数可能会做一些比较 hack 操作,又想绕过 Asan 的越界检测。这可以通过声明属性 __attribute__((no_sanitize_address))
实现。例如
1 |
|
这样即使 chunk_size
访问越界,ASan 也不会报错。
更多参数可参考官方文档。
原理简介
ASan 需要检测的是应用程序是否访问已向操作系统申请、但未分配给应用程序的内存,也就是下图中红色的部分。至于图中白色的部分,也就是未向操作系统申请的内存,是不需要检测的(一旦访问就会触发段错误)。
ASan 会 hook 标准内存分配函数(malloc、free 等),所有未被分配和已释放的区域都会标记为红区。所有内存的访问都会被检查,如果访问了红区的内存,asan 会立刻报错。例如,原本简单的内存访问
1 |
|
在启用 ASan 后,会生成类似如下的代码:
1 |
|
为了标记内存是否为红区,ASan 将每 8 字节内存映射成 1 字节的 shadow 内存,在 shadow 内存中标记这 8 字节内存的使用情况。64 位系统中,地址 p 对应的 shallow 内存的地址为 (p >> 3) + 0x7fff8000
。
因为 malloc 分配的内存必然是 8 字节对齐的。这样的话只有 9 种情况:
- 这 8 字节内存都不是红区。此时 shadow 内存值为 0
- 这 8 字节内存都是红区。此时 shadow 内存值为负数
- 前 k 字节不在红区,剩下的 8 - k 字节在红区。此时 shadow 内存值为 k
例如 malloc(11)
分配 11 字节内存的情况,如下图所示。第一个 8 字节都不在红区,对应的 shadow 内存值为 0;第二个 8 字节前 3 字节不在红区,后 5 字节在红区,对应的 shadow 内存值为 3。第三个 8 字节都在红区,对应的 shadow 内存值为负数。
这样,整个地址空间被分为 5 个部分。HighMem 对应 HighShadow,LowMem 对应 LowShadow。如果一个地址对应的 shallow 内存在 ShadowGap 区域,则这个地址不可访问。因为 64 位机器的虚拟内存地址空间很大,这样划分后地址仍然很够用。
地址区间 | 区域 |
---|---|
[0x10007fff8000, 0x7fffffffffff] | HighMem |
[0x02008fff7000, 0x10007fff7fff] | HighShadow |
[0x00008fff7000, 0x02008fff6fff] | ShadowGap |
[0x00007fff8000, 0x00008fff6fff] | LowShadow |
[0x000000000000, 0x00007fff7fff] | LowMem |
这样,每当程序要访问内存时,ASan 都会做如下检查:
1 |
|
假设某 8 字节内存后 3 字节在红区,程序要从第 4 字节开始访问两字节,如下图所示。那么有 address % 8 = 4,last = 4 + 2 - 1 = 5 >= shadow,因此这次访问是越界访问,ASan 就会报错。
了解了 ASan 原理之后就能更好地理解 ASan 的报错信息。ASan 报错时会打印出报错位置的 shadow 内存情况:
1 |
|
程序试图在 0x602000000014 写入 4 字节数据。 0x602000000014 对应的 shadow 内存地址为 (0x602000000014 >> 3) + 0x7fff8000 = 0x0c047fff8002,也就是上面 [04]
的位置。0x602000000014 % 8 = 4,从这 8 字节的第 4 字节开始访问四字节。而这 8 字节后四字节都在红区,因此访问越界。
参考资料: https://github.com/google/sanitizers/wiki/AddressSanitizer