使用 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
2
3
4
5
6
7
8
#include <stdlib.h>
#include <stdio.h>

int main() {
int *p = malloc(sizeof(int));
p[1] = 42;
return 0;
}

虽然存在内存越界写入,但是使用常规方法编译后,是能够“正常”运行的:

1
2
3
$ gcc -o test test.c
$ ./test
42

加上编译参数 -fsanitize=address 启用 ASan 后,运行便会触发 ASan 报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ gcc -o test test.c -fsanitize=address -g
$ ./test
=================================================================
==65982==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000014 at pc 0x5600b2a24202 bp 0x7ffda82b5c70 sp 0x7ffda82b5c60
WRITE of size 4 at 0x602000000014 thread T0
#0 0x5600b2a24201 in main (/home/luyuhuang/test+0x1201)
#1 0x7f7eee24c082 in __libc_start_main ../csu/libc-start.c:308
#2 0x5600b2a240ed in _start (/home/luyuhuang/test+0x10ed)

0x602000000014 is located 0 bytes to the right of 4-byte region [0x602000000010,0x602000000014)
allocated by thread T0 here:
#0 0x7f7eee527808 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cc:144
#1 0x5600b2a241be in main (/home/luyuhuang/test+0x11be)
#2 0x7f7eee24c082 in __libc_start_main ../csu/libc-start.c:308
...

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
$ ASAN_OPTIONS=handle_segv=0:disable_coredump=0 ./test

常用的运行时参数有:

  • 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: SIGSEGV
    • handle_sigbus: SIGBUS
    • handle_abort: SIGABRT
    • handle_sigill: SIGILL
    • handle_sigfpe: SIGFPE

因为 ASan 默认会注册 SIGSEGV 的信号处理器,所以当程序发生段错误时,会触发 ASan 的报错而不是直接 coredump。要想让程序像往常一样产生 coredump,可以指定参数 handle_segv=0 不注册信号处理器,和 disable_coredump=0 启用 coredump。

有些函数可能会做一些比较 hack 操作,又想绕过 Asan 的越界检测。这可以通过声明属性 __attribute__((no_sanitize_address)) 实现。例如

1
2
3
4
__attribute__((no_sanitize_address))
size_t chunk_size(void *p) {
return *((size_t*)p - 4);
}

这样即使 chunk_size 访问越界,ASan 也不会报错。

更多参数可参考官方文档

原理简介

ASan 需要检测的是应用程序是否访问已向操作系统申请、但未分配给应用程序的内存,也就是下图中红色的部分。至于图中白色的部分,也就是未向操作系统申请的内存,是不需要检测的(一旦访问就会触发段错误)。

ASan 会 hook 标准内存分配函数(malloc、free 等),所有未被分配和已释放的区域都会标记为红区。所有内存的访问都会被检查,如果访问了红区的内存,asan 会立刻报错。例如,原本简单的内存访问

1
*address = ...;  // or: ... = *address;

在启用 ASan 后,会生成类似如下的代码:

1
2
3
4
if (IsPoisoned(address)) {
ReportError(address, kAccessSize, kIsWrite);
}
*address = ...; // or: ... = *address;

为了标记内存是否为红区,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
2
3
4
5
6
7
8
char *pShadow = ((intptr_t)address >> 3) + 0x7fff8000;  // 计算得到 shadow 内存地址
if (*pShadow) { // 如果 shadow 内存不为 0,做进一步检查
int last = ((intptr_t)address & 7) + kAccessSize - 1; // address % 8 + kAccessSize - 1 计算这次访问的最后一个字节
if (last >= *pShadow) { // 如果 last >= shadow 则报错
ReportError(address, kAccessSize, kIsWrite);
}
}
*address = ...; // or: ... = *address;

假设某 8 字节内存后 3 字节在红区,程序要从第 4 字节开始访问两字节,如下图所示。那么有 address % 8 = 4,last = 4 + 2 - 1 = 5 >= shadow,因此这次访问是越界访问,ASan 就会报错。

了解了 ASan 原理之后就能更好地理解 ASan 的报错信息。ASan 报错时会打印出报错位置的 shadow 内存情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
SUMMARY: AddressSanitizer: heap-buffer-overflow (/home/luyuhuang/test+0x1201) in main
Shadow bytes around the buggy address:
0x0c047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c047fff8000: fa fa[04]fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa

程序试图在 0x602000000014 写入 4 字节数据。 0x602000000014 对应的 shadow 内存地址为 (0x602000000014 >> 3) + 0x7fff8000 = 0x0c047fff8002,也就是上面 [04] 的位置。0x602000000014 % 8 = 4,从这 8 字节的第 4 字节开始访问四字节。而这 8 字节后四字节都在红区,因此访问越界。


参考资料: https://github.com/google/sanitizers/wiki/AddressSanitizer


使用 Address Sanitizer 排查内存越界
https://luyuhuang.tech/2025/02/16/asan.html
Author
Luyu Huang
Posted on
February 16, 2025
Licensed under