浅谈「内存调试技术」
浅谈「内存调试技术」
-
一、影子内存(shadow memory)-
内存问题在 C/C++ 程序中十分常见,比如缓冲区溢出,使用已经释放的堆内存,内存泄露等。
程序大了以后,查找起来又特别的难。即使我们在写程序时非常的仔细小心,代码一多,还是难以保证没有问题。
内存问题除了造成程序崩溃引发意外,也很容易被当做漏洞利用,给程序安全带来隐患。诸多工具尝试通过静态代码分析或运行时动态检测来发现内存问题。
Mozilla 甚至因为内存问题专门发明了一个新的编程语言 Rust,一定程度上回避了程序员的失误,但不能完全解决。
无意间看到一篇讲解 AddressSanitizer 的论文 1,介绍了几种动态检测技术,分析了多种工具的原理和优缺点,在此整理分享。
一、影子内存(shadow memory)
Shadow Memory 姑且直译为影子内存。
为了说明影子内存,我们把程序正常运行使用的内存叫做 常规内存。
影子内存技术,就是使用额外的内存来存管理常规内存的分配和使用,这些额外的内存对于被检测程序不可见,因此叫影子内存。
每块常规内存都有对应的影子内存。
常规内存分配和释放的时候,在对应的影子内存里记录该常规内存的属性信息,比如是否可访问,是否已经被释放。在每次访问常规内存之前,都先检查对应的影子内存,看看该常规内存是否可访问。
为了快速找到常规内存对应的影子内存,通常使用某种映射算法,实现常规内存地址到影子内存地址的映射。
一种是查表,一种是用比例+偏移来直接映射。查表就是事先设置一个表,里面保存者常规内存和影子内存的对应关系。不多叙述。以下介绍一下比例+偏移的方式。
比例+偏移的映射算法
Malloc() 函数返回的地址通常至少 8 字节对齐。
这就意味着任何 8 字节对齐的堆内存可以有 9 种状态:全部可访问,或全部不可访问,或者是剩下 7 种的一种,前面的k字节 (0 <k < 8) 是可访问的,后面剩下的 8-k 字节是不可访问。这 8 个字节的常规内存的可否访问的状态,可以用一个字节的影子内存来编码保存。
也就是说,一个字节的影子内存,可以记录多个字节的常规内存的可访问信息,这样就可以按照一定的比例,使用较少的影子内存,记录较多的常规内存的信息。适当的设置一个偏移值 Offset,把影子内存放在合适的位置。
假设使用 8:1 的比例来映射,常规内存的地址是 Addr,那么影子内存的地址就是 (Addr>>3+Offset) 。
假设常规内存的最大地址是 Max-1, 选取的 Offset 应该满足如下约束:影子内存的地址段, 也就是 Offset 到 (Offset+Max)/8 的地址段,不能被应用程序用到。
比如在 32bit 的 linux 或 macOS 上, 虚拟地址空间为 0x00000000-0xffffffff,可以选取 Offset = 0x20000000(2^29)。
影子内存在整个地址空间的中间区域。影子内存自己的地址不可被程序当做常规内存访问,通过映射,落到 Bad 区域,访问它将出错。
之前说了,按照 8:1 的比例来编码。
8 个字节的常规内存,可使用一个字节的影子内存来记录可访问信息。
影子内存里 9 种状态的编码如下:
- 0, 表示所有 8 个字节都可以访问- k, (1<=k<=7) 表示前 k 个字节可以访问- 负数, 表示整个的 8 个字节都不可访问。可以使用不同的负数,表示不同的内存区域,比如堆内存,栈内存,全局变量的内存,已经释放的内存 直接映射的代表性的例子是 TaintTrace 和 LIFT。TaintTrace 按照 1:1 映射。缺点就是无法处理内存需求特别大的被检测程序 ,如果被检测程序使用了一半以上的地址空间,那就没有足够的地址空间来容纳影子内存了。相比来说,LIFT 使用 8:1 的比例设置影子内存。
间接映射的代表是 valgrind 和 Dr.Memory。他们设置多个影子内存段,然后配合查表法来完成映射。
二、插桩(instrumentation)
Instrumentation,指用仪器在系统的某些节点进行测量或干预。
这里指在程序的代码里,插入一些测量或是控制用的额外代码。这些额外代码,通常用于 shadow memory 的管理和检测。
Instrumentation 可以编译时完成,编译器生成代码时直接在原来的程序代码里插入一些额外的代码,也可以在编译后完成,修改程序的二进制代码,在里面插入一些额外代码。
在 8 字节对齐的环境里,程序访问一个 8 字节的常规内存时,可以插入以下代码来完成 shadow memory 的检测:
回顾影子内存的编码:0 表示可访问
ShadowAddr = (Addr >> 3) + Offset; if (*ShadowAddr != 0) ReportAndCrash(Addr);
如果程序访问的是长度为 1 或 2 或 4 字节的常规内存,稍微复杂一点,需要比较影子内存里的 k 值和常规内存地址的后 3 位:
(回顾影子内存的编码:0 表示可访问,k 表示前 k 字节可访问,负数表示 8 个字节都不能访问)
ShadowAddr = (Addr >> 3) + Offset; k = *ShadowAddr; if (k != 0 && ((Addr & 7) + AccessSize > k)) ReportAndCrash(Addr);
以下用 AddressSanitizer 的例子来说明 instrumentation。分别是 x64 环境里的 8 字节和 4 字节访问。
原本的函数是这样——
void foo(T *a) {<!-- --> *a = 0x1234; }
8 字节访问:
clang -O2 -faddress-sanitizer a.c -c -DT=long
插入代码以后是这样——
push %rax mov %rdi,%rax # %rdx是指针a shr $0x3,%rax mov $0x100000000000,%rcx or %rax,%rcx # 取得a的影子内存地址 cmpb $0x0,(%rcx) # 判断影子内存的值是否为0(0表示可访问) jne 23 <foo+0x23> # 不可访问,报错 movq $0x1234,(%rdi) # 否则,可访问,执行原赋值语句 *a = 0x1234; pop %rax retq callq __asan_report_store8 # Error
4 字节访问:
clang -O2 -faddress-sanitizer a.c -c -DT=int
插入代码以后是这样——
push %rax mov %rdi,%rax # %rdx是指针a shr $0x3,%rax mov $0x100000000000,%rcx or %rax,%rcx mov (%rcx),%al # 取得影子内存的值 test %al,%al je 27 <foo+0x27> # 值为0,跳到原来的赋值语句 mov %edi,%ecx and $0x7,%ecx add $0x3,%ecx # 取得被访问的常规内存的最后一字节相对于8字节对齐的偏移, 即(Addr & 7) + AccessSize cmp %al,%cl # 和影子内存的值k比较 jge 2f <foo+0x2f> # 不可访问,报错 movl $0x1234,(%rdi) # 可访问,执行原赋值语句 pop %rax retq callq __asan_report_store4 # Error
三、专用版内存函数
使用专用版本的内存分配和释放函数,替换系统的内存分配和释放函数,由此提供额外的内存管理功能,检测内存的异常使用,同时又不改变原来程序的流程。
这里又分两类:
- 利用 CPU 的内存页保护功能 DieHarder, Dmalloc 为代表,分配内存时,在被分配内存的前后,额外分配内存,并填充特殊的值,释放内存的时候,在被释放的内存里也填充特殊值。如果程序读到了这些特殊值,就表示程序访问内存越界了。这种方法的缺点是,无法及时检测到越界访问行为,只能在运行结束时分析特殊值是否被读取或改写来计算总结,这会导致一定的概率检测不到错误。
上面两者方法都只能用来检测堆内存上的问题。StackGuard 和 Propolice 利用同样的原理,在栈上面也填充一些特殊值,在程序返回的时候检测是否被改写,来发现问题。
实际的内存检测工具,往往多种技术并用,在细节上,算法上有所差异,导致工具的性能和准确度各有千秋。通常检测质量高的,效率比较低;效率高的,质量又会低。有的工具,会吃掉数倍甚至数十倍的内存,cpu 效率也降低到 1/10 的量级。AddressSanitizer 在多种工具的基础上,各取所长,显著提高质量和效率,综合只有 73% 的降低。
在 clang 和 gcc 中都实现了 AddressSanitizer。只需要编译的时候添加上 -fsanitize=address -fno-omit-frame-pointer 即可。该论文中提到,利用 AddressSanitizer 在 Chromium 浏览器中找到了 300 多个之前没有发现的 bug。效果拔群,值得推荐。
Ref.
- K. Serebryany, D. Bruening, A. Potapenko, D. Vyukov - AddressSanitizer: A Fast Address Sanity Checker