自操作系统诞生以来,编写内存安全的代码一直是一个比较困难的问题 (另一个问题则是保证线程安全)。2004 年以来,微软安全响应中心(MSRC)已对所有报告过的微软安全漏洞进行了分类。根据他们提供的数据,所有微软年度补丁中约有 70% 是针对内存安全漏洞的修复程序。
由于 C/C++ 不是一门内存安全的语言,所以此类问题会经常遇到。而在项目开发中,相关 bug 的定位、解决速度可能影响着项目的整个进度,因此开发者们亟需一个内存检测器来诊断、发现这类错误。实际在 ASan 出现之前,市面上就已经存在了许多内存检测器,如
**PS: 如果只是为了 memcpy/memset/strcpy
等位于 string.h
头文件内相关函数的调用检测,可以使用 _FORTIFY_SOURCE**
# 1 的话是仅在编译时检查
# 2 的话是还会在运行时检查,其实是将 memcpy 替换成 memcpy_chk 版本,然后检查目标内存的长度是否满足要求
gcc -D_FORTIFY_SOURCE=1 -Og -g
当然,这样开启之后会与 ASan 打架,如果已经使用 ASan 则可以不用考虑这个了。
作为 ASan 的使用者,熟悉它的原理才能更好地理解它、利用它提供的机制。
ASan 是一种结合编译器插桩和运行时的一种快速内存检测工具,主要用于检测代码中的部分 内存安全 问题:
缓冲区溢出, ASan 提供 stack-buffer-underflow, stack-buffer-overflow, heap-buffer-underflow, heap-buffer-overflow, global-buffer-overflow 情况下的检测
空指针引用, ASan 支持
悬垂指针,ASan 支持
使用未初始化的内存,ASan 不支持,可以由 MemorySanitizer 提供
非法释放内存 (重复释放内存或者释放一个未经分配的指针),ASan 支持
特别地 ASan 还支持上面列表中未列出的一些特性:
stack-use-after-scope
栈变量在作用域失效后被使用stack-use-after-return
栈变量在函数体返回后被使用global-init-order
全局变量的初始化顺序检测前面提到 ASan 主要由 2 个模块组成:
instrument
静态插桩模块,对栈上对象、全局对象、动态分配的对象分配 redzone,以及针对这些内存做访问检测
runtime 运行时库,替换 malloc/free/memcpy/memset等实现、提供报错函数
针对每一次内存读写 (指针比较、相减也是支持的,但是由于在 STL 中指针前闭后开,end 指针经常会越界,从而会产生许多误报,故默认状态下关闭),编译器都会插入判断逻辑,判断地址是否被投毒(poisoned)。
PS: 通过以下方式来开启对指针比较、相减的 ASan 插桩检测及运行时的选项开启。
# 编译时
# 开启指针的比较、指针的相减
clang++ a.cpp -fsanitize=address -mllvm -asan-detect-invalid-pointer-pair=true
# 如果在 clang 11 下 那么可以更有针对性地开启
-fsanitize=address,pointer-compare,pointer-subtract
# 运行时选项
# 默认为 0,表示不检测
# 如果为 1,那么仅当两个指针都不是 nullptr 的时候才检测
# 其他情况下会判定这 2 个指针是否处于同一个对象内,即通过两指针是否处于同一栈空间、同一堆空间还是全局变量来判定
# 多个运行时选项通过冒号来分隔
ASAN_OPTIONS=detect_invalid_pointer_pairs=1 ./a.out
在插桩前,代码是这样的:
*addr = ...; // or ... = *addr;
在插桩后,代码就变成了这样:
if (IsPoisoned(addr)) {
ReportError(addr, kAccessSize, kIsWrite);
}
*addr = ...; // or ... = *addr;
这里的关键就是 IsPoisoned() 函数的实现,ASan 使用 shadow memory 的技术来存储一个地址是否是 poisoned 的状态。当然 ASan 也不是 shadow memory 用法的开创者,在此之前许多工具都已经在使用了,思想类似。例如:
ASan 为了尽可能快速地判断,采取了跟 Umbra 一样的做法,这样转换函数也会变得比较简单:
shadow = (addr >> scale) + offset;
其中 offset 根据不同的平台、操作系统的不同而不同。在 x86-64 Linux 平台下,这个 offset 默认值为 0x7fff8000,点我查看 offset 计算。默认情况下 asan-mapping-scale 为 3,也就代表着 1 字节的 shadow memory 可以表示 8 字节的普通内存状态。而 shadow memory 的值也可以分成 3 种状态来讨论:
shadow memory
的值为 0shadow memory
的值为负数,如 0xfa 表示堆左边的 redzone、0xf1 表示栈左边的 redzone. ASan 也根据这个值在报错的时候输出对应的错误类型,如区分 heap-buffer-underflow/stack-buffer-underflow
PS: 这里有一个折中点,如果为了形式上的统一第 1 种情况可以被归类在第 3 点中,即 8 字节可读写时它的 shadow memory 值为 8。但是一般来说内存越界的情况始终是少数,所以为了能够快速判断 8 个字节完全可读写的情形将这一种情况独立出来,当然这样的折中最终也会导致非对齐访问无法被检测出来,这个问题后面会详细说明。
如果想修改 scale/offset, 可以增加如下编译参数:
# 修改 scale
clang++ a.cpp -mllvm -asan-mapping-scale=4
# 修改 offset 为自定义值
# 如果 offset 满足 2^n, 那么原来的 (addr >> scale) + offset 可以被优化成 (addr >> scale) | offset
# 在 x86 平台下 OR 操作比 ADD 操作还是快一点的
clang++ a.cpp -mllvm -asan-mapping-offset=0x7fffffff
scale 的值最小为 3,修改它会带来以下几点影响:
考虑默认情况下 scale 为 3 的场景,IsPoisoned() 会遇到 2 种情况:
在第 1 种情况下,访问的内存可能处于最末端抑或是内存有效长度小于当前的访问字节数的大小。
已知当前访问的地址为 addr、访问字节数为 size, 且 memToShadow(addr) 不为 0. 显然,当前的访问操作涉及的地址范围为 [addr, addr + size),而它实际安全的访问范围为 [p, p + memToShadow(addr)), 其中 p 为 addr & ~0x7 表示当前以 8 字节为单元的起始地址,memToShadow(addr)表示 addr 地址对应 shadow memory 的内存值。显然 p <= addr, addr - p == (addr & 0x7).
即只要满足 addr + size - 1 >= p + memToShadow(addr)
就能够说明当前的访问已经越界了。将这个表达式化简可以得到 (addr & 0x7) + size - 1 >= memToShadow(addr)
. 此外也可以证明,在访问到 redzone 时 memToShadow(addr)
为负数,表达式恒成立。
在第 2 种情况下,由于访问的字节数已经大于等于 8 了,所以可以直接检测对应的 memToShadow(addr) 的值,如果不为 0 那么一定是有问题的。
综上,可以用如下的伪代码来描述 IsPoisoned() 的逻辑:
const uint8_t s = memToShadow(addr);
if (size < 8) {
if (s != 0) {
if ((addr & 0x7) + size - 1 >= s) {
ReportError(...);
}
}
} else {
if (s != 0) {
ReportError(...);
}
}
ASan 针对栈上对象主要提供 3 种检测手段,分别是
* stack-buffer-overflow/stack-buffer-underflow, 针对栈上变量 out-of-bound 的检测
* stack-use-after-scope, 栈上变量在作用域失效后仍被使用
* stack-use-after-return, 栈上变量在函数返回后仍被使用(默认未开启)
// clang++ a.cpp -fsanitize=address
int main() {
char a[13];
a[13] = 7; // or a[-1] = 7;
}
如图所示,在 char a[13] 被初始化后它的内存状态如上。栈上变量左侧的 redzone 至少有 32 bytes 大小,因此 ASan 选择在左侧 redzone 上记录一些信息供调试使用:
0x41B58AB3
是一个表示当前栈帧的魔数___asan_gen_
是一个描述当前栈上变量信息的字符串。第一个 1 表示当前栈上总共有 1 个变量,32 表示变量对应当前栈的 offset,13 表示变量的大小,1 表示变量名字的长度,最后再跟着变量的名字。字符串格式形如这样的正则 VarsNum [Offset Size NameLength Name]*
ptr to current function
如字面意思,存储着当前函数的地址用户访问栈上对象时 ASan 会对访问的内存(在本例子里是 a+13) 进行 IsPoisoned()
判定。如果诊断出来这块内存是 Poisoned 那么 ASan 会输出具体原因、局部 shadow memory
的信息以及当时的调用栈并停止当前程序。在本例子中,由于 memToShadow(a+13)
的值不为 0,因此需要再详细判定。因为 a 必然是按照 8 字节对齐的 (LLVM 生成代码时会保证栈上对象至少对齐至 2^scale),所以 ((a+13)&0x7) + 1 - 1 >= 5 表达式成立,判定出当前已越界。
如下是上述代码执行的结果。
=================================================================
==9160==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffefbb8c94d at pc 0x0000004ca0fc bp 0x7ffefbb8c910 sp 0x7ffefbb8c908
WRITE of size 1 at 0x7ffefbb8c94d thread T0
#0 0x4ca0fb (/tmp/a.out+0x4ca0fb)
#1 0x7f987feda554 (/lib64/libc.so.6+0x22554)
#2 0x41c31b (/tmp/a.out+0x41c31b)
Address 0x7ffefbb8c94d is located in stack of thread T0 at offset 45 in frame
#0 0x4c9fff (/tmp/a.out+0x4c9fff)
This frame has 1 object(s):
[32, 45) 'a' <== Memory access at offset 45 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (/tmp/a.out+0x4ca0fb)
Shadow bytes around the buggy address:
0x10005f7698d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f7698e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f7698f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f769900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f769910: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10005f769920: 00 00 00 00 f1 f1 f1 f1 00[05]f3 f3 00 00 00 00
0x10005f769930: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f769940: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f769950: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f769960: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10005f769970: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
==9160==ABORTING
观察 Shadow bytes around the buggy address
项,它描述了当前内存对应的 shadow memory 的内存布局,显示 f1 f1 f1 f1 00 [05] f3 f3 正好可以说明它原始目的是想访问栈上变量的尾部内存,但是却发生了越界。为什么可以得出来是栈上变量越界而不是堆上变量呢?因为前后 redzone 对应的 shadow memory 内存值为 0xf1 与 0xf3,这正好表示是栈上变量左边/右边的 redzone。
如果这个函数里有多个局部变量,那么实际的内存布局会变成这样:
int main() {
char a[13];
char b[13];
a[-1] = 0;
b[13] = 0;
}
变量之间的 redzone 对应的 shadow memory 为 0xf2.
int main() {
char* p = nullptr;
{
char a[13];
p = a;
}
p[13] = 7;
}
在一个变量作用域开始的时候 (在 @llvm.lifetime.start 之后) ASan 会自动 unpoison; 在一个变量作用域结束的时候 (在 @llvm.lifetime.end 之前) ASan 会自动 poison,将此变量标记为过期。
如图所示,原来合法访问的 13 bytes 的 shadow memory 对应为 0x00 0x05, 现在在作用域结束后被 poison 成了 0xf8 0xf8。如果再次对这块内存进行读写,那么显然会被判定为 stack-use-after-scope
报错退出。
=================================================================
==29438==ERROR: AddressSanitizer: stack-use-after-scope on address 0x7ffd698223ed at pc 0x0000004ca110 bp 0x7ffd698223b0 sp 0x7ffd698223a8
WRITE of size 1 at 0x7ffd698223ed thread T0
#0 0x4ca10f (/tmp/a.out+0x4ca10f)
#1 0x7f1a6c599554 (/lib64/libc.so.6+0x22554)
#2 0x41c31b (/tmp/a.out+0x41c31b)
Address 0x7ffd698223ed is located in stack of thread T0 at offset 45 in frame
#0 0x4c9fff (/tmp/a.out+0x4c9fff)
This frame has 1 object(s):
[32, 45) 'a' <== Memory access at offset 45 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-use-after-scope (/tmp/a.out+0x4ca10f)
Shadow bytes around the buggy address:
0x10002d2fc420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10002d2fc470: 00 00 00 00 00 00 00 00 f1 f1 f1 f1 f8[f8]f3 f3
0x10002d2fc480: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10002d2fc4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
注意: 此功能默认未开启,需要在运行时单独开启。
ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out
通常情况下,一个 lifetime 短的变量被一个 lifetime 更长的变量引用可以用 clang-tidy/clang-analyzer (这两者的区别 clang-tidy vs clang-analyzer) 等静态检查工具检测以提前避免绝大部分的此类错误。这里谈一下检测 stack-use-after-return 的算法、所面临的问题。
在连续调用函数时,栈帧会被复用。如果为判断 stack-use-after-return 仅在栈上进行 poison 操作,那么后一个函数会因为先前函数的 poison 而发生莫名的错误,自然这种 poison 可以认为无效。ASan 的做法则是将栈上的变量分配到堆上,这样即使有重复调用同一个函数,函数的栈帧也会因为被分配到了堆上保证了不会复用。
char redzone1[32]; // instrumented
char a[13];
char redzone2[19]; // instrumented
如上述代码所示,原来这 64 字节会统一分配在栈上,而在开启 stack-use-after-return 检测后会将它分配在堆上(分配操作由 FakeStackAllocator 提供),继而在函数返回时这块内存仍可以被保存下来避免被复用。FakeStackAllocator 是一个由 11 种不同大小的 chunk 所组成的 slab 分配器,默认每一种 chunk 类型的 slab 总大小为 1M,可以通过设置环境变量 ASAN_OPTIONS=min_uar_stack_size_log=16:max_uar_stack_size_log=20
来自定义。并且 FakeStackAllocator 是 threadlocal 的,也就是默认情况下每一个线程都会占用 11M 的额外内存。实际上会发现 FakeStack::Create 总会使用 max_uar_stack_size_log
来做为栈大小。
PS: 如果确定不需要检测 stack-use-after-return, 那么可以通过
clang++ -fsanitize=address -mllvm -asan-use-after-return=false a.cpp
来彻底关闭,这样可以减少一点 binary size.
以一个例子来说明:
char* gp = nullptr;
void foo() {
char a[13];
gp = a;
}
int main() {
foo();
*gp = '1'
}
在 foo 函数中,创建了一个临时变量 a, 全局变量 gp 引用了局部变量 a,之后通过全局变量 gp 间接访问了那个曾经位于 foo 函数栈帧内的临时变量。显然这种情况下 ASan 会检测出来,那么它是怎么做的呢?
前面已经提到了 FakeStackAllocator
,这是一个有着 64 字节、128字节… 64k 字节 11 种 chunk 类型的分配器,默认每一种 chunk slab 的大小为 1M。结合上面例子来说,foo 函数内的只有一个大小为 13 的变量,变量左侧的 redzone 最小为 32 字节,变量右侧补齐至 32 字节后总共需要 64 字节。于是这个栈帧可以通过 __asan_stack_malloc_0 (最小分配即为 64 字节,所以以 0 开头) 来分配,其定义可以见 DEFINE_STACK_MALLOC_FREE_WITH_CLASS_ID
其中每一类 chunk 的分配采用 round robin 算法。如果当前 chunk 的 flag 为 false,那么表示此 chunk “可以”被分配、使用,否则会继续寻找下一个 chunk 直到达到搜索上限次数(上限次数 = 1M/64 = 16K 次)。如果最后还是没有找到“可以”被分配的 chunk 则会返回 nullptr,在这种情况下栈帧不会被分配在堆上,即 stack-use-after-return
对当前栈帧不会开启。如果找到了合适的 chunk,那就会标记它的 flag 为 true 以保证在信号发生时仍能够正常工作。为了能够快速标记一个 chunk 是否“可以”被使用,ASan 会提前在 chunk 尾部 (位于 redzone 中) 存储指向其 flag 的指针 (注意“可以”是打引号的)。
在栈帧退出时 (函数 return 前),ASan 会将此 chunk 整体 poison 成 0xf5,如果对这块内存进行读写则会报 stack-use-after-return 错。此外还会将本 chunk 对应的 flag 标记为 false 表示“可以”被使用。虽然这会对 stack-use-after-return 的准确性造成影响,但是因为使用的是 round robin 算法,想要使用当前这块 chunk 则需要经过 16k 次的分配后才会重新考虑它。
以一个例子来体现一下这个问题:
char* gp = nullptr;
// 每一个栈帧都会有 64 字节被分配,其中的 a 会被分配在堆上,可以通过 gp 观察
// 已知 64 字节的 chunk 最多只有 16384 个,所以在开始分配第 16385 个的时候会复用第 1 个 chunk
// 如果我们打印 32768 次,那么在去除之后应当只有 16384 个结果
void foo() {
char a[13];
gp = a;
}
int main() {
for (int i = 0; i < 32768; ++i) {
foo();
__builtin_printf("%p\n", gp);
}
}
// clang++ return.cc -fsanitize=address
// ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out | sort | uniq -c | wc -l
// 16384
// ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out | sort | uniq -c | awk '{ print $1 }' | uniq
// 2
上面程序实际打印了 32768 次,但是观察上面顺序打印出来的结果,在去重之后只有 16384 次说明每一个地址都出现了 2 次。
由于 stack-use-after-return
有以下几点缺点,所以猜测它没有被默认开启:
stack-use-after-return
不生效前一节主要关注于栈上静态对象的检测,实际上 ASan 也支持栈上动态变量的检测。栈上动态变量主要由 vla 扩展及直接的 man 3 alloca 调用产生,与栈上静态对象的插桩相比除 poison 所使用的魔数、前后都至少要有 32 字节的 redzone 外并无其他差别。
// ./a.out $(yes 1 | head -n 12 | paste -s -d' ')
int main(int argc, char* argv[]) {
char a[argc];
a[20] = 0;
}
上述程序在运行时会在 main 函数内创建一个 13 长度的数组,但是因为越界访问被 ASan 检测出来而停止。它的内存布局如下图所示,其中图中的 %n 为 13.
char arr[13];
int main() {
// 这种情况 ASan 检测不出来,且听慢慢分析
// arr[-1] = 0;
arr[30] = 0;
}
它的实际内存布局如上图所示,ASan 会为全局变量分配一个右侧的 redzone,并且变量 sizeof 越大,对应的 redzone 也越大。但是,不是所有的全局变量都会被 ASan 插桩,如果一个全局变量的 alignment 大于 32 或者这个全局变量是 thread_local 的,则不会对此对象插桩。
这里还有一点需要注意,全局变量左侧的 redzone 不存在,只有右侧的 redzone。只不过当两个同时被 ASan 插桩过的全局变量相邻,那么左边变量右侧的 redzone 可以看成右边变量左侧的 redzone,但是这种布局很容易被破坏。 当用户使用了 -fdata-sections 时 (将全局变量放到不同的 section 里,通常结合 -gc-sections 使用),链接器有权将这些 sections 重排,近而这些变量的内存布局会有别于源代码中的顺序。
回到上述例子中,arr[-1] = 0 因为 arr 左侧没有 redzone 所以 arr[-1] 的 underflow 无法被检测出来;而 arr[30] 地地址则位于它右侧的 redzone 中,很顺利地检测出来了。
=================================================================
==40996==ERROR: AddressSanitizer: global-buffer-overflow on address 0x000000e23f1e at pc 0x0000004ca057 bp 0x7ffcce04b8d0 sp 0x7ffcce04b8c8
WRITE of size 1 at 0x000000e23f1e thread T0
#0 0x4ca056 (/tmp/a.out+0x4ca056)
#1 0x7fa780419554 (/lib64/libc.so.6+0x22554)
#2 0x41c31b (/tmp/a.out+0x41c31b)
0x000000e23f1e is located 17 bytes to the right of global variable 'a' defined in 'global.cc:1:6' (0xe23f00) of size 13
SUMMARY: AddressSanitizer: global-buffer-overflow (/tmp/a.out+0x4ca056)
Shadow bytes around the buggy address:
0x0000801bc790: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc7a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc7b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc7c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc7d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0000801bc7e0: 00 05 f9[f9]f9 f9 f9 f9 00 00 00 00 00 00 00 00
0x0000801bc7f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc800: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc810: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc820: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0000801bc830: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
由于全局变量是当前模块可对外暴露的符号,对全局变量的插桩会影响整个模块,需要在模块加载时对变量进行 poison、在退出时 unpoison。不然用户加载一个 .so 时可能会被分配在那些未被 unpoison 的地址附近,那么访问 .so 里的对象可能会出现莫名的 ASan 报错。
如图所示,ASan 对于所有会被插桩的全局对象会生成一份 metadata 用来描述这个全局对象,主要有以下属性:
为了禁止用户代码访问 metadata 数据,ASan 还会对这些内存 poison,这样也避免了用户指针跑飞而导致 ASan 元数据损坏的情况。注意到当前内容还未涉及 metadata 里的 odr_indicator
、has_dynamic_init
2 个属性,容小生慢慢道来。
在仅使用可执行文件的场景下,违背 ODR 规则会导致编译器报错,因此这种情况比较容易被发现。但是如果有同时使用到 DSO,且在可执行文件、DSO 中存在一个同名的变量, 这种违背 ODR 规则的现象就无法在编译阶段得知。
考虑如下例子:
// gcc a.c -shared -fPIC -o liba.so
char foo[4] = "abc";
void callme() {
__builtin_printf("%c\n", foo[0]); // 'a' is EXPECTED?
}
// gcc main.c liba.so -Wl,-rpath=$(pwd)
int foo = 0x42424242;
extern void callme();
int main() {
callme();
}
// ./a.out
// B
但是它的输出结果却是 B. 这是因为 ld.so 在进行符号解析时是按照这样的顺序进行的:
executable -> preload0 -> preload1 -> needed0 -> needed1 -> ...
其中 preload0、preload1 可以由 LD_PRELOAD 引入,needed0、needed1 是可执行文件原来的依赖。在上述例子中实际上只涉及到 executable 和 needed0. 在解析 foo 符号时发现它在可执行文件中,符号搜索就会立即停止。
上述例子中出现了 2 个模块,分别是 a.out 与 liba.so,它们都会先后进行各自模块的初始化,ASan 也会在这过程中依次将模块内的全局变量注册、检测是否违背 ODR 规则、对 redzone 进行 poison,最后插入至一个单向的全局链表中供模块卸载时使用(如果此变量还需要动态初始化则还会将其插入至 dynamic_init_globals 链表以记录,后面会详细说明)。ODR 检测主要有 2 种方式:
这里要提醒一下,第 1 种方式有时候是无法检测出来的,如下图例子所示:
在 a.out 里先注册变量的 size 比在 liba.so 内变量的 size_with_redzone 还大,之后再是 liba.so 内变量注册、检查 redzone 对应的 shadow memory 值时会发现都是 0 从而认为此时变量尚未 poison 而造成误判。在误判的情况下,会把这块内存给重新 poison,将 foo 原来 [0, 128) 可连续访问区间给截断了。因此误判情况下可能会造成 ASan 误报。
回到最初始的例子,正好 foo 在 2 个模块内所定义变量的大小一样,所以比较巧合地不会引起上面的问题。
这里还有一个比较有趣的问题,也是由 symbol interposition 引起 D92078.
// clang -shared a.c -o liba.so -fPIC -fsanitize=address
char foo[4] = "abc";
void dummy() {
__builtin_printf("%c\n", foo[0]); // 'a' is EXPECTED?
}
int visit(void* p) {
return *(int*)p;
}
// clang -c main.c -o main.o
// clang main.o liba.so -fsanitize=address -Wl,-rpath=$(pwd)
int foo = 0x42424242;
int bar = 0x43434343;
extern void dummy();
extern int visit(void*);
int main() {
dummy();
__builtin_printf("%x\n", visit(&foo));
__builtin_printf("%x\n", visit(&bar));
}
// ./a.out
// ASan 报错 global-buffer-overflow
它的根因是一个会被插桩的变量在符号解析时被解析到了一个不会被插桩的变量处,即在 liba.so 里的 foo 实际被解析到了 a.out 中,a.out 里的 foo 不会向 ASan 注册这个全局变量 (因为 main.o 编译时未带 -fsanitize=address
选项),所以当 liba.so 里的 foo 向 ASan 注册时判断不出此变量是否违背 ODR 规则,近而会认为 foo 右侧还有 60 字节的 redzone,故将其 poison。而 a.out 里的 bar 变量与 foo 相邻,bar 对象的地址正好落在了 foo 变量被 poison 的区域,于是透过 visit(&bar) 访问会因为踩中 foo 的 “redzone” 而报错。可以在编译 liba.so 时使用 -mllvm -asan-use-private-alias=1 编译参数,这样就不会间接影响在 a.out 里的 foo 了,自然也就无法检测出违背 ODR 规则的情况了。
ASan 还支持针对 non-trivial 对象的 Static Initialization Order Fiasco 检测,如 std::string, std::vector. 还是以一个例子来说明:
// foo.h
class Foo {
public:
Foo(int x) : a_(x) {}
Foo(Foo&&) = default;
Foo(const Foo&) = default;
Foo& operator=(Foo&&) = default;
Foo& operator=(const Foo&) = default;
int a() const noexcept {
return a_;
}
private:
int a_;
};
// mod.cc
#include "foo.h"
Foo foo_in_other_mod(98);
// main.cc
// clang++ main.cc mod.cc -fsanitize=address -std=c++11
#include "foo.h"
extern Foo foo_in_other_mod;
Foo local(foo_in_other_mod.a());
int main() {
return local.a();
}
// ASAN_OPTIONS=check_initialization_order=1 ./a.out
// ASan 报错
在不同 TU 里全局变量初始化顺序是未定义的,从最终结果来看 main.cc 内的 local 变量引用了另一模块里的变量 foo_in_other_mod,但是 foo_in_other_mod 的初始化晚于 local,在初始化 local 变量时对 foo_in_other_mod 变量的读写是 UB.
前面提到 ASan 会在模块初始化时将全局变量注册,其中那些需要动态初始化的变量则会再单独记录在 dynamic_init_globals 链表中。在这个例子当中,foo_in_other_mod 没有定义在 main.cc 中,因此在 main.cc 模块全局变量初始化时会将这种定义不在本模块的全局变量 poison 成 0xf6 表示处于检测 Global init order 状态中,访问此种状态的全局变量 ASan 自然能够检测出来。如果当前 TU 里的所有全局变量初始化都没有问题,那么除这些已经初始化完毕的全局变量外都会重新 poison,接下来再按照这算法进行下一个 TU 的全局变量初始化。
如上图所示,如果 local 没有依赖 foo_in_other_mod,那么则会发生:
所以总结一下,全局对象的 ASan 在以下情况下会不生效、可能会导致程序误报:
ASan 针对堆上对象主要提供 3 种检测手段,分别是:
heap-buffer-overflow
, 针对堆上对象 out-of-bound 的检测heap-use-after-free
, 针对堆上对象被释放后仍被使用double-free
, 针对堆上对象的重复释放除此之外,ASan 还会在 alloc/dealloc 方法不匹配时报错。
int main() {
char* p = new char[13];
p[-1] = 'a';
}
运行上述程序会遇到 heap-buffer-overflow 报错而退出,这其中除 ASan 针对内存访问进行 poison 判断外,还涉及到 malloc/free 等运行时函数的替换。通常情况下 malloc/free 等符号由 glibc (libc.so) 提供,因为它定义在 libc.so 中所以可以被 interposition 替换。ASan 通过如下方式将默认的 malloc alias 至自己的实现以达到接管 malloc 的目的。
// 主要讨论 elf 平台
extern "C" void* malloc(size_t sz) __attribute__((weak, alias("__interceptor_malloc"), visibility("default")));
extern "C" __attribute__((visibility("default"))) void* __interceptor_malloc(size_t sz) {
if (UNLIKELY(UseLocalPool())
// HACK: dlsym calls malloc before REAL(malloc) is retrieved from dlsym.
return AllocateFromLocalPool(sz);
return asan_malloc(sz);
}
除 malloc/free 外 ASan 还会增强 memset/memcpy 等函数。如在调用 memcpy(dst, src, n) 时会先检查 dst 和 src 是否有 n 的有效长度然后再调用真实的 memcpy,这里真实的 memcpy 通过 dlsym 方式可以获得,其他运行时函数同理。
回到示例程序,用户调用 new char[13] 时实际会从 ASan 的内存分配器中分配,并会分配额外内存用于 redzone。如下图所示
当用户请求 13 字节的内存时实际会分配 32 字节的内存,其中 3 字节用于将 13 对齐至 16 字节,剩下 16 字节作为 redzone 分布在左侧。上图中,它的右侧可能还会存在一个 redzone,与全局变量的情况类似,实际上这个 redzone 是其他 chunk 左侧的 redzone。而且 ASan 为了尽可能减少内存占用,将相关 metadata 也存储在了左侧 redzone 中:
如果 redzone 大于 16 字节 (redzone 必须为 16 的倍数,虚线部分),那么还会在 redzone 的最前端存储一个魔数 0xCC6E96B9 和一个指向 chunk begin 的指针供调试时使用。
内存分配完成之后就是正常的 poison、访问被检测、越界报错,不再赘述。
int main() {
char* p = new char[13];
delete[] p;
p[0] = 0;
}
ASan 的动态内存分配器由两部分组成:PrimaryAllocator 和 SecondaryAllocator。首先会尝试在 PrimaryAllocator 内分配,如果对象大小超过其上限 (Linux 下为 128K ) 则在 SecondaryAllocator 里分配。从实现角度来看,PrimaryAllocator 是一个小块内存的 slab 分配器,而 SecondaryAllocator 则是一个 mmap 的封装,以下讨论它们在 Linux x86-64 下的实现。
PrimaryAllocator 的实现与 stack-use-after-return 一节里描述的分配器非常相似,每一个不同大小的 slab 都有固定个数的 chunk。不同的是这 53 (实际在平均分配 4T 时向上取整至 64 方便计算, 第 54 ~ 64 个 slab 空置不使用) 个 slab 的总大小是 4T (由 kAllocatorSize 确定),且每一个 slab 有一个 free_array 来维护当前剩余可用的 chunk。但是 ASan 没有直接使用 PrimaryAllocator, 而是在其基础之上再封装了一个 AllocatorCache 接口方便以 cache 形式管理被用户释放的小内存。
检测 heap-use-after-free 需要在用户释放内存时将对应的内存 poison 并保存起来。
原来的 0x00 0x05 被 poison 成了 0xfd 0xfd, 这样用户再访问 p 数组就会被检测出 heap-use-after-free。为了与正常内存区分,ASan 会将用户已释放的内存放置在隔离区(quarantine cache list)。隔离区默认大小为 256M,当隔离区内存储的总大小超过上限时会将旧的多余部分踢出以保证不会无休止分配内存导致 OOM。也是因为这一点,有时候 heap-use-after-free
无法被检测出来。
int main() {
char* a = new char[1 << 20]; // 1M
delete[] a;
char* b = new char[1 << 28]; // 256M
delete[] b; // drains the quarantine queue
char* c = new char[1 << 20]; // 1M
a[0] = 0; // may land in 'c'
delete[] c;
}
这里解释一下,默认 PrimaryAllocator 无法分配大于 128K 的内存,所以这些内存都是经过 mmap 分配的。在第一次 new[]、delete[] 之后这块内存被放置到隔离区。而在第二次 new[], delete[] 256M 的 b 时,由于默认的隔离区上限为 256M,随着 b 的加入触发了隔离区的清理,所以先前在这隔离的内存 a 会被归还给系统。而后分配同样大小的 c 大概率会直接分配在原来 a 变量的地址上,之后访问 a[0] 就如同 c[0] 无法检测出来对 a 的 heap-use-after-free。不过在扩大隔离区之后,由于 a, b 变量都一直待在隔离区,后续 a[0] 的内存访问就可以很容易判断出来。
ASAN_OPTIONS=quarantine_size_mb=512 ./a.out
# 或者修改
ASAN_OPTIONS=thread_local_quarantine_size_kb=512000
int main() {
char* p = new char[13];
delete[] p;
delete[] p;
}
在第一次 delete[] p 时会判断 p 的 chunk_state (metadata, 见堆上内存 OOB 检测一节) 是否为 CHUNK_ALLOCATED
, 如果不是则报错,否则置为 CHUNK_QUARANTINE
。但是在第二次 delete[] p 时会发现 p 的 chunk_state 已经为 CHUNK_QUARANTINE,表明此内存块已经位于隔离块中,因此可以报 double-free 错误。
类似的
int main() {
char* p = new char[13];
free(p);
}
在 free§ 的时候会检测当前的 alloc_type 与 p 分配时的 alloc_type 属性是否一致,如果不一致则会报 alloc-dealloc-mismatch。
array cookie 保护
在 cxxabi array-cookies 要求中,对于 non-trivial 类型的 operator new[] 实际需要在内存前端存储实际分配的元素个数 (使用 std::size_t 类型来记录)。
#include
#include
#include
int main() {
std::string* p = new std::string[13];
for (std::size_t i = 0; i < 13; ++i) {
p[i] = "hello";
}
std::memset((char*)p - 8, 0, 8); // overwrite array cookies
delete[] p;
}
如上图所示,如果不小心内存越界将 array-cookies 给写没了,那么在 delete[] non-trivial 类型时它们的 dtor 就没法正确被调用,因而无法正确析构。
在开启 ASan 之后对 array-cookies 的写就可以被检测到了。
// clang++ -fsanitize=address -stdlib=libc++ -lc++ -lc++abi vec.cc
#include
#include
#include
int main() {
// 用 int64_t 是因为正好 8 字节,现象好观察一点
std::vector<int64_t> vec;
vec.push_back(0);
vec.push_back(1);
vec.push_back(2);
assert(vec.size() == 3);
assert(vec.capacity() >= 4);
int64_t* p = &vec[0];
return p[3];
}
已知 libc++ 内的 std::vector 容器实现是按 2 的指数倍扩增,因此在连续 push_back 3 个元素之后它当前的实际 capacity 会是 4, size 是3。从逻辑上来看 vec[3] 是不可访问的,但是在内存分配器角度来看因为它已经被分配给了用户自然是可以访问的。libc++ 借助 ASan 所提供的 API 将容器逻辑的有效性体现出现而非直接地暴露内存。
clang 支持在类内各个成员之间的 padding 处插桩。当然不是所有的 C++ 类都会插入额外的 padding 来作为 redzone,如果它满足以下几点
__attribute__((packed))
union
std::is_trivially_copyable
或 std::is_trivially_destructible
或 std:::standard_layout
那么它就不会产生额外的类间 padding.
// clang++ -fsanitize=address -fsanitize-address-field-padding=1 intra.cc
class Foo {
public:
Foo() : pre1(1), pre2(2), post1(3), post2(4) {}
virtual ~Foo() {}
void set(int i, int val) {
a[i] = val;
}
private:
int pre1, pre2;
int a[11];
int post1, post2;
};
int main() {
Foo* p = new Foo;
p->set(12, 42);
delete p;
}
对于以上 Foo 类型,它的内存布局是这样的:
在示例中正好访问了 a[12] 即踩中了 a 变量右边的 redzone。此选项基本无法在复杂项目下使用,只能做为演示用。如果存在 ABI 依赖,打开此选项后会类的 sizeof 可能会变化引起 ABI 不一致; 网络协议相关的库强要求类的大小不能改变,在这种强要求下甚至可能会编译失败 (如可能有这样的判断 static_assert(sizeof(Foo) == 40))。因此如果想使用此特性,需要结合 ASan 黑名单一同使用。
__attribute__((no_sanitize("address"))) int foo_no_instrumented(int* p) {
return *p;
}
int foo(int* p) {
return *p;
}
如图所示,带有 no_sanitize(“address”) attribute 的函数就不会被 ASan 检测了。
// clang++ stack.cc -fsanitize=address
#include
#include
#include
#include
#include
#include
template <typename T, std::size_t N>
class Stack {
public:
Stack() noexcept : sz_(0) {
// poison
ASAN_POISON_MEMORY_REGION(data(), sizeof(T) * N);
}
T* data() noexcept {
return static_cast<T*>(static_cast<void*>(&arr_));
}
void push(T x) noexcept(std::is_nothrow_move_constructible_v<T>) {
// precondition: sufficient space for a new one
assert(sz_ < N);
// unpoison for the new one
ASAN_UNPOISON_MEMORY_REGION(data() + sz_, sizeof(T));
new (data() + sz_) T(std::move(x));
++sz_;
}
void pop() noexcept(std::is_nothrow_destructible_v<T>) {
// precondition: pop on a non-empty stack
assert(sz_ > 0);
T* p = data() + --sz_;
p->~T();
// poison back
ASAN_POISON_MEMORY_REGION(p, sizeof(T));
}
private:
std::aligned_storage_t<sizeof(T) * N, alignof(T)> arr_;
std::size_t sz_;
};
int main() {
Stack<std::int64_t, 10> stk;
stk.push(0);
stk.push(1);
assert(stk.data()[0] == 0);
assert(stk.data()[1] == 1);
stk.pop();
assert(stk.data()[1] == 1); // ASan should panic
return 0;
}
上述代码描述了一个简单、固定长度的 Stack,由于使用的是静态空间所以默认状态下这些空间都是可以访问的。这里将 Stack 的逻辑与 ASan 结合,严格保证仅 [0, sz) 区间的可以被访问,[sz, N) 区间访问禁止。
在进行稳定性测试时通常需要发现更多的业务错误,但是经常会遇到 gRPC 的问题被 ASan 检测出来而提前退出。例如 gRPC 的 tcp_flush 函数,即使将函数标记为 no_address_sanitize 仍然会检测出 heap-use-after-free。这是因为 ASan 不仅会对 memset/memcpy 等函数检测,还会覆盖一些 posix 函数,被覆盖的 posix 函数在执行完原始函数之后也会进行 ASan 检测。通过 tcp_flush 函数的调用栈可以明显观测到是由 sendmsg 引起的,这里不关心 gRPC 的错误,因此可以通过如下运行时选项
ASAN_OPTIONS=intercept_send=0 ./a.out
来屏蔽 ASan 对于
的 检测。
此问题与上一小节的问题类似,ASan 令 malloc
解析至 __interceptor_malloc
(ASan 自己的 malloc alias)。__interceptor_malloc
是一个定义在 libclang_rt.asan-x86_64.a 内的强符号,链接时的 -fsanitize=address 选项会将此静态库链接至最终的可执行文件中 。根据 ELF 符号解析规则
executable -> preload0 -> preload1 -> needed0 -> needed1
因为 __interceptor_malloc 符号直接存在于可执行文件中,无法通过 LD_PRELOAD
等方式进行 interposition 替换。那么想在部分静态库中关闭 malloc 的 ASan 检测还有什么其他办法呢?考虑将此部分库内的 call malloc 重写成 call malloc_no_sanitize (malloc_no_sanitize 为用户自己提供的没有 ASan 检测功能的函数,free 也需要替换),那么就会存在一部分静态库使用 malloc 另一部分使用 malloc_no_sanitize 混用的情况,一旦出现跨 lib 的 malloc/free (如 getline 函数) 就会因为内存分配器实现不同而导致程序 coredump。另一种是在插桩时将 malloc 全部替换,显然这会造成大面积误伤,因此目前屏蔽 malloc/free 等函数无解。
ASan 实际使用的报错函数有 3 个 版本:
__asan_report_
正常都是这种__asan_report_exp_
目前尚未实现相关 experiments__asan_report_*_no_abort
报错后可以不退出可以通过传递 -fsantize-recover=address
给 clang driver
(相当于 -mllvm -asan-recover=true
) 令 ASan 生成的报错函数版本为 __asan_repot_*_no_abort
,但是想令它报错后不退出还依赖用户提供的运行时变量 halt_on_error (默认为 1)。 也就是当用户使用 -fsantize-recover=address 编译并且在运行时设置 ASAN_OPTIONS=halt_on_error=0 才能令 ASan 忽略报错继续运行。
int main() {
char* buf[8];
int* p = (int*)(buf + 6);
*p = 1; // [6, 9]
}
在确定 p 地址是否可读写时会首先判断其对应的 shadow memory 值是否为 0,在上述例子中这显然正好为 0。正是因为判断 8 字节读写仅需快速与 0 比较即可而导致实际访问的内存越界无法被检测出来。通过修改 asan-mapping-scale 可以让 1 字节 shadow memory 表示更多的内存,在 scale = 4 时它的内存布局如下:
那么在这个时候就可以继续判断访问 p 是否有越界行为。遗憾的是当前 ASan 对于 scale != 3 的情况下报错信息基本处于不可用的状态… (本人正在尝试修复中,希望
@MaskRay
宋教授不要把这个活给抢了 QAQ)
=================================================================
==2337==ERROR: AddressSanitizer: unknown-crash on address 0x7ffc7f8cc5a6 at pc 0x0000004ca104 bp 0x7ffc7f8cc570 sp 0x7ffc7f8cc568
WRITE of size 4 at 0x7ffc7f8cc5a6 thread T0
#0 0x4ca103 (/tmp/a.out+0x4ca103)
#1 0x7fca6296f554 (/lib64/libc.so.6+0x22554)
#2 0x41c31b (/tmp/a.out+0x41c31b)
Address 0x7ffc7f8cc5a6 is located in stack of thread T0
SUMMARY: AddressSanitizer: unknown-crash (/tmp/a.out+0x4ca103)
Shadow bytes around the buggy address:
0x10000ff11860: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff11870: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff11880: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff11890: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff118a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10000ff118b0: 00 00 00 00[00]00 00 00 00 00 00 00 00 00 00 00
0x10000ff118c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff118d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff118e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff118f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10000ff11900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
不过好在还可以利用输出的 backtrace 信息,通过设置 ASAN_SYMBOLIZER_PATH=/path/to/llvm-symbolizer ./a.out 运行来获得可读的准确源码位置信息。此外未对齐的访问也可以交给 UBSan 来检测。
知乎版