自操作系统诞生以来,编写内存安全的代码一直是一个比较困难的问题 (另一个问题则是保证线程安全)。来自微软安全响应中心(MSRC)近20年来的安全数据显示,有约 70% 的安全漏洞补丁是针对内存安全漏洞的修复。
由于 C/C++ 不是一门内存安全的语言,所以此类问题会经常遇到。在编写C++程序时,内存泄漏、内存溢出、内存踩踏等问题层出不穷,轻则导致程序内存浪费、计算错误,重则引起程序崩溃,难以排查和定位,严重影响项目进度和程序的稳定性。本文主要讲解如何使用AddressSanitizer工具来快速检测定位此类问题。
一、概述
1、常见内存问题场景
开始之前,先简单介绍下内存相关的几个异常概念:
- 野指针:指针未初始化就使用(非法的随机值)、指针越界非法访问,或指向一个已释放的对象等。
- 内存泄露:申请的堆内存使用完毕后忘记释放,内存还占着,但地址丢失,自己已经不能控制这块内存,而系统也不能再次将它分配给需要的程序。内存泄漏次数多了就会导致内存溢出。
- 内存溢出:Out Of Memory,简称OOM,指系统已经不能再分配出你所需要的空间。
- 内存踩踏:指访问了不合法的地址(访问了不属于自己的地址),如果访问的地址是其他变量的地址并进行了修改,就会破坏别人的数据,从而导致程序运行异常。常发生在buffer overflow,野指针操作,write after free等场景。
2、常见内存检测工具
在AddressSanitizer出现之前,市面上就已经存在了许多内存检测器,例如:
- Dr.Memory:检测未初始化的内存访问、double free、use after free 等错误
- Mudflap:检测指针的解引用,静态插桩
- Insure++:检测内存泄漏
- Valgrind:可以检测非常多的内存错误
其中,Dr.Memory、Insure++ 和 Mudflap 虽然在运行时造成的额外损耗比较少,但是检测场景有限;Valgrind 虽然能够在许多场景的检测出错误,但是它实现了自己的一套 ISA 并在其之上运行目标程序,因此它会严重拖慢目标程序的速度。而 AddressSanitizer 在设计时就综合考虑了检测场景、速度的影响因素,结合了 Mudflap 的静态插桩、Valgrind 的多场景检测能力,故本文主要讲解AddressSanitizer。
3、什么是AddressSanitizer
AddressSanitizer即地址消毒技术,简称ASan,是一个快速的内存错误检测工具。它可以用来检测内存问题,例如缓冲区溢出或对悬空指针的非法访问等。
检测类型:
- Use after free(dangling pointer dereference):释放后使用(堆上分配的空间free之后被再次使用)。
- Heap buffer overflow:堆缓冲区溢出(访问的区域在堆上, 且超过了分配的空间)。
- Stack buffer overflow:栈缓冲区溢出(访问的区域在栈上, 且超过了分配给它的空间)。
- Global buffer overflow:全局缓冲区溢出(访问的区域是全局变量, 且超过了分配给它的空间)。
- Use after return:Return后使用(函数在栈上的局部变量在函数返回后被使用默认不开启)。
- Use after scope:在作用域外使用(局部变量离开作用域以后继续使用)。
- Initialization order bugs:初始化顺序错误(检查全局变量或静态变量初始化的时候有没有利用未初始化的变量,默认不开启)。
- Memory leaks:内存泄漏(未释放堆上分配的内存)。
据谷歌的工程师介绍 ,ASan 已在 chromium 项目上检测出了300多个潜在的未知bug,而且在使用 ASan 作为内存错误检测工具对程序性能损耗也是及其可观的。根据检测结果显示可能导致性能降低2倍左右,比Valgrind(官方给的数据大概是降低10-50倍)快了一个数量级。而且相比于Valgrind只能检查到堆内存的越界访问和悬空指针的访问,ASan 不仅可以检测到堆内存的越界和悬空指针的访问,还能检测到栈和全局对象的越界访问。这也是 ASan 在众多内存检测工具的比较上出类拔萃的重要原因,基本上现在 C/C++ 项目都会使用ASan来保证产品质量,尤其是大项目中更为需要。
从gcc 4.8开始,AddressSanitizer成为gcc的一部分,但还不完善。要获得更好的体验,建议使用4.9及以上版本。
4、AddressSanitizer检测原理
ASan接管了每次内存分配/释放,并且每一次对内存的读/写都加上了一个检查 (需要编译器的配合)。
算法思路:如果想防住Buffer Overflow漏洞,只需要在每块内存区域右端(或两端,能防overflow和underflow)加一块区域(RedZone),使RedZone的区域的影子内存(Shadow Memory)设置为不可写即可。
防护缓冲区溢出的基本步骤:
- 在被保护的全局变量、堆、栈前后创建 redzone,并将 redzone 标记为中毒状态。
- 将缓冲区和 redzone 每 8 字节对应 1 字节的映射方式建立影子内存区(影子内存区使用函数 MemToShadow 获取)。
- 出现对 redzone 的访问(读写执行)行为时,由于 redzone 对应的影子内存区被标记为中毒状态触发报错。
- 报错信息包含发生错误的进程号、错误类型、出错的源文件名、行号、函数调用关系、影子内存状态。其中影子内存状态信息中出错的部分用中括号标识出来。
- 中毒状态:内存对应的 shadow 区标记该内存不能访问的状态。
ASan主要包括两部分:插桩(Instrumentation)和动态运行库(Run-time library)。
- 插桩:主要是针对在llvm编译器级别对访问内存的操作(store,load,alloca等),将它们进行处理。为了防止buffer overflow,需要将原来分配的内存两边分配额外的内存Redzone,并将这两边的内存加锁,设为不能访问状态(中毒状态)。
- 动态运行库:主要提供一些运行时的复杂的功能(比如poison/unpoison shadow memory)以及将malloc,free等系统调用函数hook住。在使用函数 free 释放内存时,所释放的内存被隔离开来(暂时不会被分配出去),并被标记为与RedZone相同的中毒状态,中毒的内存一旦被访问,即可被检测到。ASan 使用 shadow memory 跟踪哪些字节为正常内存,哪些字节为中毒内存。字节可以标记为完全正常(shadow memory 值为 0)、完全中毒(shadow memory 值为负值)或前面 k 个字节未中毒(shadow memory 值为 k)。如果 shadow memory 显示某个字节中毒,则 ASan 会使程序崩溃,并输出有用的调试信息,包括调用堆栈、影子内存映射、内存违例类型、读取或写入的内容、导致违例的计算机以及内存内容。
插桩示例:
// 原始代码:
void foo() {
char a[8];
...
return;
}
// 插桩后的检测代码:
void foo() {
char redzone1[32]; // 32-byte aligned
char a[8]; // 32-byte aligned
char redzone2[24];
char redzone3[32]; // 32-byte aligned
int *shadow_base = MemToShadow(redzone1);
shadow_base[0] = 0xffffffff; // poison redzone1
shadow_base[1] = 0xffffff00; // poison redzone2, unpoison 'a'
shadow_base[2] = 0xffffffff; // poison redzone3
...
shadow_base[0] = shadow_base[1] = shadow_base[2] = 0; // unpoison all
return;
}
从以上示例中可以看到ASan将malloc/free函数进行了替换,在malloc函数中额外的分配了Redzone区域的内存,将与Redzone区域对应的影子内存加锁,主要的内存区域对应的影子内存不加锁。free函数将所有分配的内存区域加锁,并放到了隔离区域的队列中(保证在一定的时间内不会再被malloc函数分配),可检测Use after free类的问题。
二、使用
1、用法
1.1、启用AddressSanitizer
用-fsanitize=address选项编译和链接你的程序,用-fno-omit-frame-pointer编译,以得到更容易理解stack trace:
gcc -Werror-rdynamic
-fsanitize=address -fno-omit-frame-pointer -g test.cc -o test
或在CMakeLists.txt中配置:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror -rdynamic -fsanitize=address -fno-omit-frame-pointer -g")
1.2、export编译选项
ASAN_OPTIONS是Address-Sanitizier的运行选项环境变量,可以根据需要选择性设置:
- halt_on_error=0:检测内存错误后继续运行
- detect_leaks=1:使能内存泄露检测
- malloc_context_size=15:内存错误发生时,显示的调用栈层数为15
- log_path=/home/xos/asan.log:内存检查问题日志存放文件路径
- suppressions=$SUPP_FILE:屏蔽打印某些内存错误
- detect_stack_use_after_return=1:检查访问指向已被释放的栈空间
- handle_segv=1:处理段错误;也可以添加handle_sigill=1处理SIGILL信号
- quarantine_size=4194304:内存cache可缓存free内存大小4M
例如:
export ASAN_OPTIONS=halt_on_error=0:detect_leaks=1:malloc_context_size=15:log_path=./asan.log
2、场景测试
2.1、 (heap) use after free 释放后使用
下面的代码中,分配array数组并释放,然后返回它的一个元素。
$ vim test_mem.cc
1 /**
2 * @file test_mem.cc
3 * @brief mem问题asan检测测试
4 * @author yanjingang
5 * @date 2023-3-13
6 * @note g++ test_mem.cc -std=c++11 -Wall -Werror -rdynamic -fsanitize=address -fno-omit-frame-pointer -g -o build/test_mem
7 */
8
9
10 // 堆上分配的空间被free之后再次使用
11 int use_after_free(){
12 int *array = new int[100];
13 delete[] array;
14 return array[1];
15 }
16
17 int main(int argc, char **argv){
18 use_after_free();
19 }
// build
$ g++ test_mem.cc -std=c++11 -Wall -Werror -rdynamic -fsanitize=address -fno-omit-frame-pointer -g -o build/test_mem
// test
$ ./build/test_mem
从下图提示的错误信息中,我们可以非常明确的看到内存异常访问信息:
- ERROR:异常类型为heap-use-after-free堆内存释放后被使用。
- READ:异常操作类型为读,在T0线程,位置在test_mem.cc:14行。
- freed:内存释放位置在test_mem.cc:13行。
- previously allocated:内存分配位置在test_mem.cc:12行。
- fa/fd:最下方的堆内存中,fa表示Redzone防护缓冲区,fd表示已被free释放的堆内存区域。
2.2、 heap buffer overflow 堆缓存访问溢出
如下代码中,访问的位置超出堆上数组array的边界。
17 // 堆缓冲区溢出
18 int heap_buffer_overflow(){
19 int* array = new int[100];
20 int res = array[100];
21 delete [] array;
22 return res;
23 }
...
28 heap_buffer_overflow();
下图提示的错误信息指出:
- ERROR:异常类型为heap-buffer-overflow堆缓冲区溢出。
- READ:异常操作类型为读,在T0线程,位置在test_mem.cc:20行。
- allocated:内存分配位置在test_mem.cc:19行。
- fa:最下方的堆内存中,fa表示Redzone防护缓冲毒区,被异常访问。
2.3、 stack buffer overflow 栈缓存访问溢出
如下代码中,访问的位置超出栈上数组array的边界。
24 // 栈缓冲区溢出
25 int stack_buffer_overflow(){
26 int array[100];
27 return array[100];
28 }
...
35 stack_buffer_overflow();
下图提示的错误信息指出:
- ERROR:异常类型为stack-buffer-overflow栈缓冲区溢出。
- READ:异常操作类型为读,在T0线程,位置在test_mem.cc:27行。
- Address:栈块在线程T0的栈上448偏移位置上,Memory access at offset 448 overflows this variable。
- f1/f3:f1为Stack Left Redzone防护缓冲毒区,f3为Stack Right Redzone防护缓冲毒区,这里被异常访问。
2.4、 global buffer overflow 全局缓冲访问溢出
如下代码中,访问的位置超出全局数组array的边界。
29 // 全局缓冲区溢出
30 int array[100];
31 int global_buffer_overflow(){
32 return array[100];
33 }
...
40 global_buffer_overflow();
下图提示的信息指出:
- ERROR:异常类型为global-buffer-overflow全局缓冲区溢出。
- READ:异常操作类型为读,在T0线程,位置在test_mem.cc:32行。
- global variable:全局缓存块在test_mem.cc:30行定义。
- f9:f9为Global Redzone防护缓冲毒区,这里被异常访问。
2.5、 memory leaks 内存泄露
检测内存的LeakSanitizer是集成在AddressSanitizer中的一个相对独立的工具,它工作在检查过程的最后阶段。下面代码中,p指向的内存没有释放。
34 // 内存泄漏
35 void* p; // p指向的内存没有释放
36 int memory_leaks(){
37 p = malloc(7);
38 p = 0;
39 return 0;
40 }
...
49 memory_leaks();
下图的错误信息指出:
- 异常类型为memory leaks内存泄漏。
- 缓存块在test_mem.cc:37行定义,但未释放。
三、其他
ASan也不是万能的,它在打开的情况下对运行性能有明显影响,在某些情况下也会出现误报,在实际的使用过程中可以作为一个测试流水线环节进行检测,以提高系统的稳定性。
1、内存泄漏误报场景
- 结构体非 4 字节对齐:报错提示结构体 A 内存泄漏,A 内存的指针存放在结构体 B 中,A 内存指针在结构体 B 中的偏移量非 4 的整数倍,由于 ASan 扫描内存时是按照 4 字节偏移进行,从而扫描不到 A 内存指针导致误报。解决方法:对非4字节对齐的结构体进行整改。
- 信号栈内存:该内存是在信号处理函数执行时做栈内存用的,其指针会保存在内核中,所以在用户态的 ASan 扫描不到,产生误报;
- 内存指针偏移后保存:
- 存在ASan未监控的内存接口:
- 越界太离谱,越界访问的地址不在 buffer 的 redzone 内:
- 对于memcpy的dest和src是在同一个malloc的内存块中时,内存重叠的情况无法检测到。
- ASan对于overflow的检测依赖于安全区,而安全区总归是有大小的。它可能是64bytes,128bytes或者其他什么值,但不管怎么样终归是有限的。如果某次踩踏跨过了安全区,踩踏到另一片可寻址的内存区域,ASan同样不会报错。这是ASan的另一种漏检。
- ASan对于UseAfterFree的检测依赖于隔离区,而隔离时间是非永久的。也就意味着已经free的区域过一段时间后又会重新被分配给其他人。当它被重新分配给其他人后,原先的持有者再次访问此块区域将不会报错。因为这一块区域的shadow memory不再是0xfd。所以这算是ASan漏检的一种情况。
2、在项目中的应用注意事项
- 项目的构建方案应当有编译选项,能随时启用/关闭ASan。
- 项目送测阶段可以打开ASan,以帮助暴露更多的低概率诡异问题。
- 请勿在生产版本中启用ASan,其会降低程序运行速度大概2-5倍,并会出现内存持续增长现象(占用的RedZone并不会自动释放,所以会出现内存溢出的假象,关闭ASan现象即会消失)。
- 实际开发测试过程中通过ASan扫出的常见问题有:多线程下临界资源未加保护导致同时出现读写访问,解决方案一般是对该资源恰当地加锁即可;内存越界,如申请了N字节的内存却向其内存地址拷贝大于N字节的数据,这种情况在没有开启ASan的情况下一般都很难发现。
- 一些显而易见的访问无效内存操作可能会被编译器优化而会漏报。
yan 3.13
参考:
https://github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm
https://blog.csdn.net/u013171226/article/details/126876335
https://blog.csdn.net/yuanbinquan/article/details/106767635
https://www.jianshu.com/p/3a2df9b7c353