LLVM 工具系列 - Address Sanitizer 基本原理介绍及案例分析 (1)

Address Sanitizer 介绍

LLVM 提供了一系列的工具帮助 C/C++/Objc/Objc++ 开发者检查代码中可能的潜在问题,这些工具包括 Address Sanitizer,Memory Sanitizer,Thread Sanitizer,XRay 等等,功能各异。

本篇主要介绍可能是最常用的一个工具 Address Sanitizer,它的主要作用是帮助开发者在运行时检测出内存地址访问的问题,比如访问了释放的内存,内存访问越界等。

全部种类如下,也都是非常常见的几类内存访问问题。

  1. Use after free
  2. Heap buffer overflow
  3. Stack buffer overflow
  4. Global buffer overflow
  5. Use after return
  6. Use after scope
  7. Initialization order bugs
  8. Memory leaks

这里为了便于理解,先介绍一下大概的工作原理。然后从上面几种场景中挑出几个有代表性的介绍一下。

Address Sanitizer 的基本工作原理

我们对一个内存地址的 访问 无外乎两种操作:,也就是

1
2
*address = ...;  // 写操作
... = *address; // 读操作

Address Sanitizer 的工作依赖编译器运行时库,当开启 Address Sanitizer 之后, 运行时库将会替换掉 mallocfree 函数,在 malloc 分配的内存区域前后设置 “投毒”(poisoned) 区域,使用 free 释放之后的内存也会被隔离并投毒,poisoned 区域也被称为 redzone

这样对内存的访问,编译器会在编译期自动在所有内存访问之前做一下 check 是否被 “投毒”。所以以上的代码,就会被编译器改成这样:

1
2
3
4
5
if (IsPoisoned(address)) {
ReportError(address, kAccessSize, kIsWrite);
}
*address = ...; // or: ... = *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
2
3
4
5
6
7
8
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
int *p = malloc(sizeof(int));
free(p);
return *p; // 访问了已经释放的内存地址
}

这段代码很简单,在堆上创建了一块 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
2
3
4
5
6
7
8
// heap-buffer-overflow.cpp
int main(int argc, char **argv) {
int *array = new int[100];
array[0] = 0;
int res = array[100]; // 内存地址访问越界
delete [] array;
return res;
}

编译,这里用的是 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
2
3
4
5
0x00010403a7d4 is located 4 bytes to the right of 400-byte region [0x00010403a640,0x00010403a7d0)
allocated by thread T0 here:
#0 0x1025de018 in wrap__Znam+0x74 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x4e018)
#1 0x1021d3e6c in main test_heap_buffer_overflow.cpp:3
#2 0x193e4be4c (<unknown module>)

但实际中往往更复杂,访问的内存可能是距离很远的一块内存上,虽然也可以从这段错误信息里的 allocated by 的堆栈中找到实际分配这块的内存地址的位置,但是可能跟这个访问地址并没有什么关联,要注意辨别。

我们来这样模拟一下,在 array 后面再创建一个 array2,分配 100 个 int 的空间,然后访问 array 的时候,让其越界到 array2 的后面。为了方便查看,我们这里打印出来 array 和 array2 的内存地址范围。

1
2
3
4
5
6
7
8
9
10
11
#include <cstdio>
int main(int argc, char **argv) {
int *array = new int[100];
printf("array: %p\n", array);
array[0] = 0;
int *array2 = new int[100];
printf("array2: %p\n", array2);
int res = array[(array2-array + 100)]; // 首先肯定是越界了,甚至越界到 array2 的右边区域了
delete [] array;
return res;
}

我们来看下错误信息:

第二段错误信息里,相当于告诉我们访问的这块内存位于 array2 的紧挨着的右边的位置,但是这个内存位置其实和访问出错并无关系,此时,这个位置信息价值就不大了,应该参考第一段错误信息(红框位置),根据出现访问问题的源代码位置来分析即可,第二段相当于一个辅助的信息。

Note:
到这里大家可能会思考一个问题,如果上面访问 array 的代码,正好越界到 array2 的地址合法范围内,比如,int res = array[(array2-array + 1)], 会不会被检测到并 crash 呢?
很遗憾,这种 case 虽然越界了,但根据前面的运行原理来看,访问的内存区域并未被 “投毒”(poisoned),因此不会被检测到越界,也不会 crash。

最后我们再看一个检查内存泄漏的 case。

分析一个 Memory leak 的 case

我们在 test_memory_leak.cpp 模拟一个 leak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdlib.h>

class BadClass {
public:
BadClass(int value): value_(new int(value)) {}
~BadClass() {
// 没有 delete value_ 导致泄漏
}

private:
int *value_;
};

int main() {
BadClass *bad = new BadClass(10);
delete bad;
return 0;
}

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
2
3
4
# LeakSanitizer 在 X86 的 linux 上开启 Address Sanitizer 时默认打开的,因此直接运行即可
./test_memory_leak
# 如果是 Intel 版本的 macos,默认没有打开 LeakSanitizer,需要在运行前面增加一个环境变量来开启
ASAN_OPTIONS=detect_leaks=1 ./test_memory_leak

运行结果:

第一行告诉我们检测到了内存泄露,然后告诉我们泄漏了一个对象,共 4 个字节。泄漏的的位置是在 test_memory_leak.cpp 文件的第 15 行。

Summary

内存问题是 C/C++ 项目中比较头疼的问题,为了解决这类的问题,本篇文章主要介绍了 LLVM 的 Address Sanitizer 工具,以及基本的工作的原理;接着分析了 C/C++ 中几种常见的内存地址访问错误的 case,以及如何从错误信息中提取关键的信息进行排查问题。

其余的几种内存问题,大家可以自行模拟来尝试,非常建议在开发阶段 Debug 或者测试场景中打开 Address Sanitizer 提前暴露很多内存问题。

Ref & 扩展阅读

  1. Google AddressSanitizer Wiki

  2. Hardware-assisted AddressSanitizer