C/C++内存检查原理

  • 一、影子内存(shadow memory)

    • 比例+偏移的映射算法
  • 二、插桩(instrumentation)

  • 三、专用版内存函数

内存问题在 C/C++ 程序中十分常见,比如缓冲区溢出,使用已经释放的堆内存,内存泄露等。

程序大了以后,查找起来又特别的难。即使我们在写程序时非常的仔细小心,代码一多,还是难以保证没有问题。

内存问题除了造成程序崩溃引发意外,也很容易被当做漏洞利用,给程序安全带来隐患。诸多工具尝试通过静态代码分析或运行时动态检测来发现内存问题。

Mozilla 甚至因为内存问题专门发明了一个新的编程语言 Rust,一定程度上回避了程序员的失误,但不能完全解决。

无意间看到一篇讲解 AddressSanitizer 的论文 1,介绍了几种动态检测技术,分析了多种工具的原理和优缺点,在此整理分享。

一、影子内存(shadow memory)

Shadow Memory 姑且直译为影子内存。

为了说明影子内存,我们把程序正常运行使用的内存叫做 常规内存

影子内存技术,就是使用额外的内存来存管理常规内存的分配和使用,这些额外的内存对于被检测程序不可见,因此叫影子内存。

每块常规内存都有对应的影子内存

常规内存分配和释放的时候,在对应的影子内存里记录该常规内存的属性信息,比如是否可访问,是否已经被释放。在每次访问常规内存之前,都先检查对应的影子内存,看看该常规内存是否可访问。

为了快速找到常规内存对应的影子内存,通常使用某种映射算法,实现常规内存地址到影子内存地址的映射。

一种是查表,一种是用比例+偏移来直接映射。查表就是事先设置一个表,里面保存者常规内存和影子内存的对应关系。不多叙述。以下介绍一下比例+偏移的方式。

比例+偏移的映射算法

Malloc() 函数返回的地址通常至少 8 字节对齐。

这就意味着任何 8 字节对齐的堆内存可以有 9 种状态:全部可访问,或全部不可访问,或者是剩下 7 种的一种,前面的k字节 (0

也就是说,一个字节的影子内存,可以记录多个字节的常规内存的可访问信息,这样就可以按照一定的比例,使用较少的影子内存,记录较多的常规内存的信息。适当的设置一个偏移值 Offset,把影子内存放在合适的位置。

假设使用 8:1 的比例来映射,常规内存的地址是 Addr,那么影子内存的地址就是 (Addr>>3+Offset) 。

假设常规内存的最大地址是 Max-1, 选取的 Offset 应该满足如下约束:影子内存的地址段, 也就是 Offset 到 (Offset+Max)/8 的地址段,不能被应用程序用到。

比如在 32bit 的 linux 或 macOS 上, 虚拟地址空间为 0x00000000-0xffffffff,可以选取 Offset = 0x20000000(2^29)。

影子内存在整个地址空间的中间区域。影子内存自己的地址不可被程序当做常规内存访问,通过映射,落到 Bad 区域,访问它将出错。

C/C++内存检查原理_第1张图片

之前说了,按照 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);
123

如果程序访问的是长度为 1 或 2 或 4 字节的常规内存,稍微复杂一点,需要比较影子内存里的 k 值和常规内存地址的后 3 位:

(回顾影子内存的编码:0 表示可访问,k 表示前 k 字节可访问,负数表示 8 个字节都不能访问)

ShadowAddr = (Addr >> 3) + Offset;
k = *ShadowAddr;
if (k != 0 && ((Addr & 7) + AccessSize > k))
ReportAndCrash(Addr);
1234

以下用 AddressSanitizer 的例子来说明 instrumentation。分别是 x64 环境里的 8 字节和 4 字节访问。

原本的函数是这样——

void foo(T *a) {
*a = 0x1234;
}
123

8 字节访问:

clang -O2 -faddress-sanitizer a.c -c -DT=long
1

插入代码以后是这样——

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
1234567891011

4 字节访问:

clang -O2 -faddress-sanitizer a.c -c -DT=int
1

插入代码以后是这样——

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
1234567891011121314151617

三、专用版内存函数

使用专用版本的内存分配和释放函数,替换系统的内存分配和释放函数,由此提供额外的内存管理功能,检测内存的异常使用,同时又不改变原来程序的流程。

这里又分两类:

  • 利用 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

1 内存检查两种插桩技术

1.1 运行时插桩

运行时插桩通常都会用到类似虚拟机的技术,如下图是动态二进制插桩框架Pin的基本框架,它由虚拟机(virtual machine, VM)、目标代码cache以及动态分析和工具插件Pintool组成。在Pin获得应用程序的控制权后,VM中的JIT(just in time)编译器动态地识别出程序中的trace并进行优化,结果被存入目标代码cache供后续执行使用。系统调用等无法直接执行的指令有VM中的emulator解释执行。

内存检查插桩工具作为在Pin上的一个插件,也就是一个Pintool。它完成两个主要工作。

\1. 识别动态内存申请,如全部malloc/new调用,在这些申请的内存周围设置保护区,目的是在内存块周围设立雷场,以便越界行为发生时能够及时引爆。

\2. 识别内存存取,在Load/Store指令后插入检查指令,检查Load/Store指令访问的内存是否越到了雷区,如果是则立即上报Error。

运行时插桩进行内存检查最大的劣势是引起严重性能下降,一般是10x以上的性能下降。另外一个劣势是只能检查堆内存问题,不能检查栈、全局buffer等内存bug(如变量未初始化、数组越界等)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sayd4a3O-1585282821108)(http://image.hw3static.com/hi/showimage-11568829-7948-f9bf64fd60a9f820bce1e024fd4bf313.jpg)]

业界常用动态二进制插桩框架:

l Valgrind

​ 来自Linux开源社区,只支持Linux。在其上已经开发了非常成功的测试套件,

如MemCheck、Cachegrind,Callgrind等。

l Pin

来自Intel,跨平台,支持Linux/Windows。商用工具 Intel Parallel Inspector基于Pin。

Pin本身不开源,但是可以免费在其开发API构建非商用工具。

l DynamoRIO

与Pin类似,也是跨平台的,支持Linux/Windows。DynamoRIO完全开源。

1.2 编译时插桩

由于运行时插桩引起严重性能下降以及不能检查栈、全局buffer等内存bug的局限性,业界开始实践将内存检查的功能迁移到编译器中。

从编译器的角度来说,编译器优化器(Optimizer)一般包含多趟优化过程,术语上为Pass,我形象翻译为一道工序。如大学编译原理课程中经常提到的提取公共字表达式优化、消除冗余代码等。有时编译过程中甚至要用到上百道工序。当然随着编译技术的发展,现在Optimizer已经超出了其字面含义,实际上还有很多静态检查和动态检查的Pass。

编译时内存检查插桩指的就是将内存检查插桩作为的一个Pass集成到Optimizer中,它完成两个主要工作与运行时插桩非常类似,也是识别内存申请和识别内存存取两个主要工作。区别有以下两点:1> 编译时插桩除了可以识别堆内存申请外,还可以识别栈、全局变量分配。2> 以上两个工作是在编译时做的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2iqmKVSx-1585282821109)(http://image.hw3static.com/hi/showimage-11568831-7948-13772d87a10d303d80ad7cde1d569a70.jpg)]

业界实践进展:

l GCC 4.8将集成AddressSanitizer和ThreadSanitizer。

AddressSanitizer是一个快速的内存错误检测器,能够检查堆、栈、全局buffer越界等内存bug。ThreadSantizer可以检查数据竞争。

l MSVC能够进行局部栈内存越界检查。

1.3 两种技术的优劣势分析

技术 优势 劣势
运行时插桩 l 通用性强,不依赖编译器,只需要可执行文件和符号数据库l 对堆内存检查支持非常完善l 技术成熟,开源框架多 l 性能差,一般下降10x+l 不能检查栈、全局变量、局部变量相关的内存bugl 工具自身调试复杂,虚拟机很难调试
-------------------------------------------------------- ------------------------------------------------------------
运行时插桩 l 通用性强,不依赖编译器,只需要可执行文件和符号数据库l 对堆内存检查支持非常完善l 技术成熟,开源框架多 l 性能差,一般下降10x+l 不能检查栈、全局变量、局部变量相关的内存bugl 工具自身调试复杂,虚拟机很难调试
编译时插桩 l 性能好,一般下降1.5x~2xl 除对内存bug外,还能够检查栈、全局变量、局部变量相关的内存bug l 依赖编译器,开源工具少,一般与编译器绑定l 需要额外编译插桩过程l 对堆的支持还不完善

部分ref https://blog.csdn.net/weixin_44555968/article/details/89879854

你可能感兴趣的:(C++/C内存问题检测工具)