理解AddressSanitizer:为代码提供更好的内存安全性
AddressSanitizer(ASan)是一个编译器插件,可帮助开发者检测代码中的内存问题,这些问题可能导致远程代码执行攻击(如WannaCry或WebP实现漏洞)。ASan在编译时围绕内存访问插入检查,并在检测到不当内存访问时使程序崩溃。由于其能够检测单元测试遗漏的错误,并且与其他类似工具相比具有更好的性能,因此在模糊测试中被广泛使用。
ASan专为C和C++设计,但也可用于Objective-C、Rust、Go和Swift。本文将重点介绍C++,演示如何使用ASan,解释其错误输出,探讨实现基础,并讨论ASan的局限性和常见错误,帮助您掌握之前未检测到的错误。
最后,我们分享一个在审计过程中遇到的实际错误示例,该错误被ASan遗漏,但可以通过我们的更改检测到。这个案例促使我们研究ASan的错误检测能力,并为LLVM项目贡献了数十个上游提交。这些提交导致了以下更改:
- 在LLVM16中扩展了容器清理ASan API,添加了对未对齐内存缓冲区的支持,并添加了用于双端连续容器的函数。因此,自LLVM17起,std::vector注解默认适用于所有分配器。
- 在LLVM17中添加了std::deque注解。详情请查看libc++ 17发布说明。
- 在LLVM18中添加了std::string长字符串情况的注解(默认适用于所有分配器)。更多详情请查看libc++18发布说明。
我们最近上游了短字符串注解(阅读“短字符串优化”),如果没有新的问题出现,它们很可能会包含在libc++19中。请关注libc++19发布说明。
开始使用ASan
ASan可以通过使用-fsanitize=address
编译器和链接器标志在LLVM的Clang和GNU GCC编译器中启用。Microsoft Visual C++(MSVC)编译器通过/fsanitize=address
选项支持它。在底层,程序的内存访问将通过ASan检查进行检测,并且程序将与ASan运行时库链接。因此,当检测到内存错误时,程序将停止并提供可能有助于诊断内存损坏原因的信息。
AddressSanitizer的方法与其他工具(如Valgrind)不同,后者可以在不从源代码重新构建程序的情况下使用,但性能开销更大(20倍 vs 2倍),并且可能检测到的错误更少。
简单示例:检测越界内存访问
让我们通过一个简单的有错误的C++程序来实际看看ASan,该程序从数组越界读取数据。图1显示了此类程序的代码,图2显示了其编译、链接和运行时的输出,包括ASan检测到的错误。请注意,程序使用调试符号且未优化(-g3
和-O0
标志)编译,以使ASan输出更易读。
1
2
3
4
5
6
7
8
9
10
11
|
#include <iostream>
void out_of_bounds(char const *buf) {
for (int i = 0; i <= 4; ++i)
std::cout << "buf[" << i << "] = " << buf[i] << std::endl;
}
int main() {
char *buf = new char[4]{"Hey"};
out_of_bounds(buf);
}
|
图1: 示例程序,在堆上存在越界错误,因为它从buf数组读取第五项,而该数组只有4个元素(example.cpp)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
$ clang++ -fsanitize=address -O0 -g3 ./example.cpp
$ ./a.out
buf[0] = H
buf[1] = e
buf[2] = y
buf[3] =
Program stderr
=================================================================
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000014 at pc
0x5591ad37f523 bp 0x7ffe6acc8e70 sp 0x7ffe6acc8e68
READ of size 1 at 0x502000000014 thread T0
#0 0x5591ad37f522 in out_of_bounds(char const*) /app/example.cpp:5:45
#1 0x5591ad37f59a in main /app/example.cpp:10:4
#2 0x7f8882a29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f) (BuildId:
c289da5071a3399de893d2af81d6a30c62646e1e)
#3 0x7f8882a29e3f in __libc_start_main
(/lib/x86_64-linux-gnu/libc.so.6+0x29e3f) (BuildId:
c289da5071a3399de893d2af81d6a30c62646e1e)
#4 0x5591ad2a4324 in _start (/app/output.s+0x2c324)
|
图2: 使用ASan运行图1中的程序
当ASan检测到错误时,它会打印出发生错误类型的最佳猜测、代码中发生错误的回溯以及其他位置信息(例如,相关内存分配或释放的位置)。
1
2
3
4
5
6
7
|
0x502000000014 is located 0 bytes after 4-byte region [0x502000000010,0x502000000014)
allocated by thread T0 here:
#0 0x555e42ab02bd in operator new[](unsigned long)
/root/llvm-project/compiler-rt/lib/asan/asan_new_delete.cpp:98:3
#1 0x555e42ab2571 in main /app/example.cpp:9:16
#2 0x7fd18d029d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f) (BuildId:
c289da5071a3399de893d2af81d6a30c62646e1e)
|
图3: ASan错误消息的一部分,显示相关内存分配的代码位置
在此示例中,ASan检测到example.cpp文件第五行中的堆缓冲区溢出(越界读取)。问题在于,当循环计数器变量(i)的值为4时,我们通过buf[i]
代码越界读取了buf变量的内存。
还值得注意的是,ASan可以检测许多不同类型的错误,如堆栈缓冲区溢出、堆使用后释放、双重释放、分配-释放不匹配、容器溢出等。图4和图5展示了另一个示例,其中ASan检测到堆使用后释放错误,并显示相关堆内存分配和释放的确切位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
#include <cstddef>
int* allocate_buffer(std::size_t n) {
return new int[n];
}
void increment_value(int* ptr) {
*ptr += 1;
}
int main() {
int* buffer = allocate_buffer(8);
delete [] buffer;
increment_value(&buffer[0]);
}
|
图4: 使用已释放缓冲区的示例程序(使用-fsanitize=address -O0 -g3
构建)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
READ of size 4 at 0x603000000040 thread T0
#0 0x401201 in increment_value(int*) /app/example.cpp:8
#1 0x401248 in main /app/example.cpp:14
#2 0x7fa090a29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f) (BuildId:
c289da5071a3399de893d2af81d6a30c62646e1e)
#3 0x7fa090a29e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
(BuildId: c289da5071a3399de893d2af81d6a30c62646e1e)
#4 0x4010c4 in _start (/app/output.s+0x4010c4) (BuildId:
66da66c1949dd7dbdd05b93a0dddc29539db8671)
0x603000000040 is located 0 bytes inside of 32-byte region
[0x603000000040,0x603000000060)
freed by thread T0 here:
#0 0x7fa0911e36e8 in operator delete[](void*)
(/opt/compiler-explorer/gcc-13.2.0/lib64/libasan.so.8+0xdd6e8) (BuildId:
53df075b42b04e0fd573977feeb6ac6e330cfaaa)
#1 0x40123c in main /app/example.cpp:13
#2 0x7fa090a29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f) (BuildId:
c289da5071a3399de893d2af81d6a30c62646e1e)
previously allocated by thread T0 here:
#0 0x7fa0911e2cd8 in operator new[](unsigned long)
(/opt/compiler-explorer/gcc-13.2.0/lib64/libasan.so.8+0xdccd8) (BuildId:
53df075b42b04e0fd573977feeb6ac6e330cfaaa)
#1 0x4011bc in allocate_buffer(unsigned long) /app/example.cpp:4
#2 0x401225 in main /app/example.cpp:12
#3 0x7fa090a29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f) (BuildId:
c289da5071a3399de893d2af81d6a30c62646e1e)
|
图5: 运行图4程序时ASan报告的摘录
有关更多ASan示例,请参阅LLVM测试代码或Microsoft的文档。
ASan的构建块
ASan建立在两个关键概念之上:影子内存和红区。影子内存是一个专用的内存区域,存储有关应用程序内存的元数据。红区是放置在内存中对象之间(例如,堆栈上的变量或堆分配)的特殊内存区域,以便ASan可以检测尝试访问预期边界之外的内存。
影子内存
影子内存在程序的高地址分配,ASan在进程的整个生命周期中修改其数据。影子内存中的每个字节描述了进程可能访问的相应内存块的可访问性状态。这些内存块通常称为“颗粒”,通常大小为8字节,并与其大小对齐(颗粒大小在GCC/LLVM代码中设置)。图6显示了颗粒与进程内存之间的映射关系。
图6: 进程内存和相应影子内存字节的逻辑划分
影子内存值详细说明了给定颗粒是否可以完全或部分寻址(进程可访问),或者内存是否不应被进程触及。在后一种情况下,我们称此内存为“中毒”,相应的影子内存字节值详细说明了ASan认为如此的原因。影子内存值图例与ASan报告一起打印。图7显示了此图例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
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
|
图7: 影子内存图例(值以十六进制格式显示)
通过在进程执行期间更新影子内存的状态,ASan可以通过检查颗粒的值(及其可访问性状态)来验证内存访问的有效性。如果内存颗粒完全可访问,则相应的影子字节设置为零。相反,如果整个颗粒中毒,则该值为负数。如果颗粒是部分可寻址的——即,只有前N个字节可以访问,其余部分不应访问——那么可寻址字节的数量N存储在影子内存中。例如,堆上已释放的内存用值fd描述,在再次分配之前不应被进程使用。这允许检测使用后释放错误,这些错误通常导致严重的安全漏洞。
部分可寻址颗粒非常常见。一个例子可能是堆上大小不是8字节对齐的缓冲区;另一个可能是堆栈上大小小于8字节的变量。
红区
红区是插入到进程内存中(并因此反映在影子内存中)的内存区域,它们充当缓冲区区域,用中毒内存分隔内存中的不同对象。因此,使用ASan编译程序会改变其内存布局。
让我们看看图8所示程序的影子内存,其中我们在堆栈上引入了三个变量:“buf”,一个包含六个项目的数组,每个项目2字节,以及“a”和“b”变量,分别为2字节和1字节。
1
2
3
4
5
6
7
|
int main() {
volatile short buf[6], a=0;
volatile char b=1;
// trigger out-of-bounds so ASan shows
// shadow bytes around the buggy access
buf[10] = a+b;
}
|
图8: 示例程序,具有ASan检测到的越界内存访问错误(使用-fsanitize=address -O0 -g3
构建)
使用ASan运行程序,如图9所示,显示有问题的内存访问击中了“堆栈右红区”,如影子内存字节“[f3]”所标记。请注意,ASan在地址前用箭头和值周围的括号标记了这个字节。
1
2
3
4
5
6
7
8
|
$ gcc -fsanitize=address -O0 -g3 ./example.cpp && ./a.out
…
SUMMARY: AddressSanitizer: stack-buffer-overflow (/root/a.out+0x12b2) in main
Shadow bytes around the buggy address:
0x1000593a1dc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x1000593a1dd0: 00 00 00 00 00 00 00 00 f1 f1 f1 f1 01 f2 02 f2
=>0x1000593a1de0: 00 04[f3]f3 00 00 00 00 00 00 00 00 00 00 00 00
0x1000593a1df0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
图9: 描述图6中堆栈变量周围内存区域的影子字节。注意,字节01对应于变量“b”,02对应于变量“a”,“00 04”对应于buf数组。
此影子内存以及相应的进程内存如图10所示。ASan将检测对红色字节的访问并将其报告为错误。
图10: 使用ASan的内存布局。每个单元格代表一个字节。
如果没有ASan,“a”、“b”和“buf”变量可能会彼此相邻,没有任何填充。填充的添加是因为变量必须是部分可寻址的,并且红区也被添加到它们之间以及它们之前和之后。
红区不会添加到数组中的元素之间或结构中的成员变量之间。这是因为这会破坏许多依赖于结构布局、其大小或数组在内存中连续的事实的应用程序。
遗憾的是,ASan也不会使结构填充字节中毒,因为它们可能被有效程序访问(例如,使用memcpy函数复制整个结构时)。
ASan检测如何工作?
ASan检测完全依赖于编译器;然而,编译器之间的实现非常相似。其影子内存具有相同的布局,并在LLVM和GCC中使用相同的值,因为后者基于前者。检测后的代码还调用到compiler-rt中定义的特殊函数,compiler-rt是LLVM的一个低级运行时库。值得注意的是,还有ASan库的共享或静态版本,但这可能因编译器或环境而异。
ASan检测向程序代码添加检查,以验证程序内存访问的合法性。这些检查通过将访问的地址和大小与影子内存进行比较来执行。影子内存映射和值编码(颗粒大小为8字节)允许ASan有效检测内存访问错误并提供对遇到问题的宝贵洞察。
让我们看一个在x86-64上编译和测试的简单C++示例,其中touch函数访问参数中给定地址的8字节(touch函数接受指向指针的指针并解引用它):
1
2
3
|
void* touch(void **ptr) {
return *ptr;
}
|
图11: 访问大小为8字节的内存区域的函数
没有ASan,该函数有一个非常简单的汇编代码:
1
2
3
4
|
touch(void**): # @touch(void**)
mov rax, qword ptr [rdi] # 从参数中给定的地址读取值
# 并保存在rax中
ret # 返回(返回值在rax中)
|
图12: 图11中的函数在没有ASan的情况下编译
图13显示,当使用ASan编译图11中的代码时,添加了一个检查,确认访问是否正确(即,是否访问了整个颗粒)。我们可以看到,我们即将访问的地址首先除以8(shr rax, 3
指令)以计算其在影子内存中的偏移量。然后,程序检查影子内存字节是否为零;如果不是,则调用__asan_report_load8
函数,使ASan报告内存访问违规。字节与零比较,因为零表示8字节可访问,而程序执行的内存解引用返回另一个指针,该指针当然大小为8字节。
1
2
3
|
touch(void**): # @touch(void**)
push rax
mov rax, rdi
|