深入理解AddressSanitizer:为代码提供更强大的内存安全保护

本文详细介绍了AddressSanitizer(ASan)的工作原理和使用方法,包括其基于影子内存和红区的检测机制,如何检测内存越界、使用已释放内存等常见漏洞,并通过实际代码示例展示ASan的错误报告格式和检测能力。

理解AddressSanitizer:为代码提供更好的内存安全性

AddressSanitizer(ASan)是一个编译器插件,帮助开发者检测代码中的内存问题,这些问题可能导致远程代码执行攻击(如WannaCry或WebP实现漏洞)。ASan在编译时围绕内存访问插入检查,并在检测到不当内存访问时使程序崩溃。由于其能够检测单元测试遗漏的错误,并且相比其他类似工具具有更好的性能,ASan在模糊测试中被广泛使用。

ASan专为C和C++设计,但也可用于Objective-C、Rust、Go和Swift。本文将重点介绍C++,演示如何使用ASan,解释其错误输出,探讨实现基础,并讨论ASan的限制和常见错误,帮助您掌握之前未检测到的错误。

最后,我们分享一个在审计过程中遇到的实际错误示例,该错误被ASan遗漏,但可以通过我们的更改检测到。这个案例促使我们研究ASan的错误检测能力,并为LLVM项目贡献了数十个上游提交。

开始使用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:在堆栈上存在越界错误的示例程序,因为它从只有4个元素的buf数组中读取第五项(example.cpp)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ 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)
    #3 0x7f8882a29e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
    #4 0x5591ad2a4324 in _start (/app/output.s+0x2c324)

图2:使用ASan运行图1中的程序

当ASan检测到错误时,它会打印出发生错误类型的最佳猜测、代码中发生错误的回溯以及其他位置信息(例如,相关内存分配或释放的位置)。

1
2
3
4
5
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)

图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
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)

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)
    #1 0x40123c in main /app/example.cpp:13
    #2 0x7fa090a29d8f  (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)

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)
    #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)

图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构建)

如图9所示,使用ASan运行程序显示有问题的内存访问命中了"堆栈右红区",如"[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(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
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
 4
 5
 6
 7
 8
 9
10
11
touch(void**):                                    # @touch(void**)
        push    rax
        mov     rax, rdi
        shr     rax, 3                            # 地址除以8,颗粒id保存在rax中
        cmp     byte ptr [rax + 2147450880], 0    # 影子字节与0比较,检查整个颗粒是否可寻址
        jne     .LBB0_2                           # 如果不为0,则跳转到错误标签
        mov     rax, qword ptr [rdi]
        pop     rcx
        ret
.LBB0_2:
        call    __asan_report_load8@PLT           # 调用ASan报告函数

图13:使用Clang 15和ASan编译的图11中的函数

作为比较,我们可以看到gcc编译器生成类似的代码(图14)与LLVM(图13)相比:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
touch(void**):
        mov     rax, rdi
        shr     rax, 3                       # 除以8
        cmp     BYTE PTR [rax+2147450880], 0 # 检查颗粒是否完全可寻址
        jne     .L7                          # 如果不,跳转到错误标签
        mov     rax, QWORD PTR [rdi]
        ret
.L7:
        push    rax
        call    __asan_report_load8

图14:使用gcc 12和ASan编译的图11中的函数

当然,如果程序访问较小的区域,编译器必须生成不同的检查。这在图15和16中显示,其中程序仅访问单个字节。

1
2
3
char touch(char *ptr) {
    return *ptr;
}

图15:访问小于颗粒的内存区域的函数

现在函数访问可能位于颗粒开头、中间或末尾的单个字节,每个颗粒可能完全可寻址、部分可寻址或完全中毒。影子内存字节首先与零检查,如果不匹配,则执行详细检查(从.LBB0_1标签开始)。如果颗粒部分可寻址并且访问了中毒字节(来自中毒后缀),或者如果颗粒完全中毒,此检查将引发错误。(GCC生成类似的代码。)

1
2
3
4
5
6
7
touch(char*):                             # @touch(char*)
        push    rax
        mov     rax, rdi
        shr     rax, 3                             # 计算颗粒id
        movzx   eax, byte ptr [rax + 2147450880]   # 读取影子内存字节值
        test    al, al                             # 检查是否为零,如果是,地址在完全可寻址颗粒中
        j
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计