LLVM 工具系列 - Address Sanitizer 基本原理介绍及案例分析 (1)
Address Sanitizer 介绍
LLVM 提供了一系列的工具帮助 C/C++/Objc/Objc++ 开发者检查代码中可能的潜在问题,这些工具包括 Address Sanitizer,Memory Sanitizer,Thread Sanitizer,XRay 等等,功能各异。
本篇主要介绍可能是最常用的一个工具 Address Sanitizer,它的主要作用是帮助开发者在运行时检测出内存地址访问的问题,比如访问了释放的内存,内存访问越界等。
全部种类如下,也都是非常常见的几类内存访问问题。
- Use after free
- Heap buffer overflow
- Stack buffer overflow
- Global buffer overflow
- Use after return
- Use after scope
- Initialization order bugs
- Memory leaks
这里为了便于理解,先介绍一下大概的工作原理。然后从上面几种场景中挑出几个有代表性的介绍一下。
Address Sanitizer 的基本工作原理
我们对一个内存地址的 访问 无外乎两种操作:读 和 写,也就是
1 | *address = ...; // 写操作 |
Address Sanitizer 的工作依赖编译器运行时库,当开启 Address Sanitizer 之后, 运行时库将会替换掉 malloc
和 free
函数,在 malloc
分配的内存区域前后设置 “投毒”(poisoned) 区域,使用 free
释放之后的内存也会被隔离并投毒,poisoned 区域也被称为 redzone
。
这样对内存的访问,编译器会在编译期自动在所有内存访问之前做一下 check 是否被 “投毒”。所以以上的代码,就会被编译器改成这样:
1 | if (IsPoisoned(address)) { |
这样的话,当我们不小心访问越界,访问到 poisoned 的内存(redzone),就会命中陷阱,在运行时 crash 掉,并给出有帮助的内存位置的信息,以及出问题的代码位置,方便开发者排查和解决。
Note: 从基本工作原理来看,我们可以获知,打开 Address Sanitizer 会增加内存占用,且因为所有的内存访问之前都会有 check 是否访问了 “投毒” 区域的内存,会有额外的运行开销,对运行性能造成一定的影响,因此通常只在 Debug 模式或测试场景下打开
更详细的原理参考第二篇 Address Sanitizer 实现原理
如何开启 Address Sanitizer
默认 clang 是不打开 Address Sanitizer 的,需要增加 -fsanitize=address -g
参数,-g
用来在出现问题的报告中,增加有助于 debug 的信息,比如出问题的代码位置和行数等,非常建议带上。
如何使用我们在下个例子里进行展示。
分析一个 Use after free 的 case
来看一个简单的例子,test_use_after_free.c 文件有以下内容:
1 |
|
这段代码很简单,在堆上创建了一块 int 大小的内存,随后释放,然后 *p 来读取位于 p 内存地址的值,显然是有问题的。实际场景往往会更杂,free 的位置和访问的位置可能离得很远,不容易发现,而且编译期并不会提示错误。
编译:
1 | clang -fsanitize=address -g test_use_after_free.c -o use_after_free |
运行之后 crash,并提供给我们一些错误信息:
这些错误信息很重要,可以协助我们排查出现问题的位置。我们从上往下看,第一行告诉我们了内存地址访问错误类型为 heap-use-after-free,并给出了地址和寄存器的值:
1 | ==65906==ERROR: AddressSanitizer: heap-use-after-free on address 0x000105000730 at pc 0x000102c57f48 bp 0x00016d1ab190 sp 0x00016d1ab188 |
接下来就是告诉我们是在 test_use_after_free.c 文件的 第 7 行 Read 时出的问题,也就是 return *p
时出现的问题。
接着就是该内存区域是在哪里释放的,就是第 6 行,以及之前在哪里分配的,也就是第 5 行。 可以说非常清晰。
接下来就是 Shadow 的 bytes,具体这里先按下不表,放到下篇具体实现原理里来具体解释。从图上我标记的箭头可以看出访问的是一块已经释放的堆内存。
Heap buffer overflow 堆内存溢出的 case
1 | // heap-buffer-overflow.cpp |
编译,这里用的是 C++,因此加上 -lc++ 来使用 libc++ 库
1 | clang -fsanitize=address -g -lc++ test_heap_buffer_overflow.cpp -o heap_buffer_overflow |
运行 & 错误信息:
分析:
第一行告诉我们错误类型为 heap-buffer-overflow,访问出错的内存地址为 0x00010613a7d4, 我们先记下来。
然后告诉我们是第 5 行的 读操作 导致的,也就是 int res = array[100];
这里。
接下来的信息是告诉我们出现错误读操作的内存地址 0x00010613a7d4 是位于 400 bytes 内存的右边 4 个 byte 的位置,根据代码,我们知道这 400bytes,其实就是代码中创建的 100 个 int 值所在的内存地址。
1 | 0x00010403a7d4 is located 4 bytes to the right of 400-byte region [0x00010403a640,0x00010403a7d0) |
但实际中往往更复杂,访问的内存可能是距离很远的一块内存上,虽然也可以从这段错误信息里的 allocated by
的堆栈中找到实际分配这块的内存地址的位置,但是可能跟这个访问地址并没有什么关联,要注意辨别。
我们来这样模拟一下,在 array 后面再创建一个 array2,分配 100 个 int 的空间,然后访问 array 的时候,让其越界到 array2 的后面。为了方便查看,我们这里打印出来 array 和 array2 的内存地址范围。
1 |
|
我们来看下错误信息:
第二段错误信息里,相当于告诉我们访问的这块内存位于 array2 的紧挨着的右边的位置,但是这个内存位置其实和访问出错并无关系,此时,这个位置信息价值就不大了,应该参考第一段错误信息(红框位置),根据出现访问问题的源代码位置来分析即可,第二段相当于一个辅助的信息。
Note:
到这里大家可能会思考一个问题,如果上面访问 array 的代码,正好越界到 array2 的地址合法范围内,比如,int res = array[(array2-array + 1)]
, 会不会被检测到并 crash 呢?
很遗憾,这种 case 虽然越界了,但根据前面的运行原理来看,访问的内存区域并未被 “投毒”(poisoned),因此不会被检测到越界,也不会 crash。
最后我们再看一个检查内存泄漏的 case。
分析一个 Memory leak 的 case
我们在 test_memory_leak.cpp 模拟一个 leak:
1 |
|
Note:
Memory leak 检测目前不支持 ARM,因此 M1 芯片的 MBP 也是不支持的,运行时会出现以下的错误提示。
1
2
3 ASAN_OPTIONS=detect_leaks=1 ./test_memory_leak.out
==39355==AddressSanitizer: detect_leaks is not supported on this platform.
[1] 39355 abort ASAN_OPTIONS=detect_leaks=1 ./test_memory_leak.out
这里我在 X86_64 的 Linux 机器上进行测试。
编译:
1 | clang -fsanitize=address -g -lstdc++ test_memory_leak.cpp -o test_memory_leak |
运行:
1 | # LeakSanitizer 在 X86 的 linux 上开启 Address Sanitizer 时默认打开的,因此直接运行即可 |
运行结果:
第一行告诉我们检测到了内存泄露,然后告诉我们泄漏了一个对象,共 4 个字节。泄漏的的位置是在 test_memory_leak.cpp 文件的第 15 行。
Summary
内存问题是 C/C++ 项目中比较头疼的问题,为了解决这类的问题,本篇文章主要介绍了 LLVM 的 Address Sanitizer 工具,以及基本的工作的原理;接着分析了 C/C++ 中几种常见的内存地址访问错误的 case,以及如何从错误信息中提取关键的信息进行排查问题。
其余的几种内存问题,大家可以自行模拟来尝试,非常建议在开发阶段 Debug 或者测试场景中打开 Address Sanitizer 提前暴露很多内存问题。