浅谈「内存调试技术」

举报
天存信息 发表于 2021/05/11 11:46:23 2021/05/11
【摘要】 解决C/C++程序内存问题的动态检测技术整理

内存问题在 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 的内存页保护功能
    以 Electric Fence, Duma, GuardMalloc, Page Heap 为代表的工具,使用 CPU 的内存页保护功能:CPU 访问一个不可访问的内存页的时候,会触发异常。该类工具实现的内存分配函数,除了正常的分配内存,还在后面紧接着分配一个不可访问的内存页。程序如果访问内存越界,就访问到了后面的内存页,触发异常。这种办法的缺点是,如果程序需要分配很多的内存,会导致分配很多后面的不可访问的内存页,分配内存次数多,就会运行的很慢。并且这种方式无法检测到轻微的越界,比如分配了 5 个字节的内存,访问了第 6 个字节,因为内存对齐的原因,访问第 6 个字节的内存不会触发异常。
  • 使用填充区和填充标记
    DieHarder, Dmalloc 为代表,分配内存时,在被分配内存的前后,额外分配内存,并填充特殊的值,释放内存的时候,在被释放的内存里也填充特殊值。如果程序读到了这些特殊值,就表示程序访问内存越界了。这种方法的缺点是,无法及时检测到越界访问行为,只能在运行结束时分析特殊值是否被读取或改写来计算总结,这会导致一定的概率检测不到错误。

上面两者方法都只能用来检测堆内存上的问题。StackGuard 和 Propolice 利用同样的原理,在栈上面也填充一些特殊值,在程序返回的时候检测是否被改写,来发现问题。

实际的内存检测工具,往往多种技术并用,在细节上,算法上有所差异,导致工具的性能和准确度各有千秋。通常检测质量高的,效率比较低;效率高的,质量又会低。有的工具,会吃掉数倍甚至数十倍的内存,cpu 效率也降低到 1/10 的量级。AddressSanitizer 在多种工具的基础上,各取所长,显著提高质量和效率,综合只有 73% 的降低。

在 clang 和 gcc 中都实现了 AddressSanitizer。只需要编译的时候添加上 -fsanitize=address -fno-omit-frame-pointer 即可。该论文中提到,利用 AddressSanitizer 在 Chromium 浏览器中找到了 300 多个之前没有发现的 bug。效果拔群,值得推荐。(陈国 | 天存信息)

Ref.

  1. K. Serebryany, D. Bruening, A. Potapenko, D. Vyukov - AddressSanitizer: A Fast Address Sanity Checker
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。