论文分享 | AddressSanitizer: 一个快速的内存地址错误检查器

LLVM编译器框架的目的就是为了更方便地做程序优化,它提供的Pass链非常有助于对代码进行安全性分析。分享一篇发表于2012年USENIX会议的AddressSanitizer论文,结合项目源码和相关博客从原理和实践中记录学习笔记。

论文摘要

对于 C 和 C++ 等编程语言来说,内存访问错误,包括缓冲区溢出和使用已释放的堆内存,仍然是一个严重问题。现有许多内存错误检测器,但大多数检测器要么速度慢,要么只能检测有限的错误,或者两者兼而有之。

这篇论文介绍了 AddressSanitizer (通常简称为 ASan),一种新的内存错误检测器,能够找到堆栈以及全局对象的越界访问错误,同时还有内存释放后仍然被使用的错误。该工具采用了特殊的内存分配和代码插桩,能够很简单地实现在任意编译器、二进制转换系统,甚至是硬件系统中,已在 Chromium 浏览器中发现了超过300多个之前的未知错误。

1 背景介绍

现有的许多内存错误检测工具在速度、内存消耗、错误检测的类型和概率、支持的平台以及其他特征方面各有所不同,这些工具可以成功检测出大量的错误,但会产生较高的开销,又或者开销低但能检测出的错误少。
这篇论文提出了一个新的检测工具 AddressSanitizer ,它能够兼具性能和覆盖范围,以平均时间开销降速约73%和3.4倍的内存使用量增加的相对较低的成本,发现堆、栈和全局对象的越界访问错误,以及使用已释放的内存空间错误。

AddressSanitizer 又简称为 ASan ,由插桩模块运行时库两部分组成。插桩模块修改原有的程序代码来检查每一次内存访问时的影子状态(即 shadow 区域的赋值,用于反映实际内存的分配使用情况),在栈和全局对象的周围创建带毒的红区(即 poisoned redzones 用一些特殊的值来标记内存的分配类型和所在位置),从而检测程序的内存访问溢出行为。运行时库则替换了原有的mallocfree等内存相关函数,在分配的堆区域创建带毒的红区,延迟已释放堆区域的重复使用操作,并进行错误报告。

本笔记在第2章介绍ASan工具的项目相关资源和简单使用示例,第3章参考论文内容和技术博客探析ASan进行内存地址错误检查的基本原理,第4章通过阅读项目的关键部分源码掌握ASan工具的核心实现。

2 ASan工具

2.1 项目相关资源

ASan 由 Google 公司开发,从 LLVM 3.1 版本开始成为了其中的一部分,插桩模块被嵌入在了 Clang 项目中,运行时库则打包在了 compile-rt 项目中, GCC 则从 4.8 版本开始支持 ASan 进行内存错误检测。
Clang+LLVM 3.1 版本 测试运行在 Ubuntu 12.04 操作系统上,系统版本过早社区已不再提供支持。本笔记仅研究 3.1 版本的源码内容,而使用 16.0 版本的工具解释说明,可参考之前的笔记 论文分享 | LLVM 进行相关工具安装。

ASan 是一个持续更新的项目,随着版本的迭代该项目做了相当多的优化,在 Clang 官方文档中给出了 AddressSanitizer 项目的介绍。文档内容相对笼统,比较干货的说明发布在 sanitizers 项目的wiki库中。根据介绍可了解到,ASan 是一个针对 C/C++ 语言的内存错误检测器,能够找到如下内存错误类型:

  • Use after free
  • Heap buffer overflow
  • Stack buffer overflow
  • Global buffer overflow
  • Use after return
  • Use after scope
  • Initialization order bugs
  • Memory leaks

2.2 简单使用示例

以 Use after free 为例,创建use_after_free.c文件内容如下:

#include <stdlib.h>
int main() {
  char *x = (char*)malloc(10 * sizeof(char));
  free(x);
  return x[5]; // 访问了已经释放的内存地址
}

上述代码很简单,在堆上创建10字节大小的内存,然后释放该内存空间。然后又通过指针来访问已经被释放的内存地址,可想而知该访问会出错。通过clang进行编译,加上-fsanitize=address开启 ASan 检测,同时还加上-g选项添加必要的调试信息,编译运行命令如下:

$ clang-16 -fsanitize=address -g use_after_free.c -o use_after_free
$ ./use_after_free

来看看运行后产生的报错信息,第一部分如下:

=================================================================
==1947==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000015 at pc 0x56319c4c9a33 bp 0x7ffde97a9aa0 sp 0x7ffde97a9a98
READ of size 1 at 0x602000000015 thread T0
    #0 0x56319c4c9a32 in main /root/asan_test/use_after_free.c:5:10
    #1 0x7f27f3029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #2 0x7f27f3029e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #3 0x56319c3f52e4 in _start (/root/asan_test/use_after_free+0x1e2e4) (BuildId: acfd5d52426f424206fda94cae93ede3c7fd4bf4)

0x602000000015 is located 5 bytes inside of 10-byte region [0x602000000010,0x60200000001a)
freed by thread T0 here:
    #0 0x56319c48ee66 in free (/root/asan_test/use_after_free+0xb7e66) (BuildId: acfd5d52426f424206fda94cae93ede3c7fd4bf4)
    #1 0x56319c4c99f5 in main /root/asan_test/use_after_free.c:4:3
    #2 0x7f27f3029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

previously allocated by thread T0 here:
    #0 0x56319c48f10e in __interceptor_malloc (/root/asan_test/use_after_free+0xb810e) (BuildId: acfd5d52426f424206fda94cae93ede3c7fd4bf4)
    #1 0x56319c4c99e8 in main /root/asan_test/use_after_free.c:3:20
    #2 0x7f27f3029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

首先,给出了 ASan 工具检测出的错误,是heap-use-after-free类型,打印该错误所处的内存地址和相关寄存器内容。

==1947==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000015 at pc 0x56319c4c9a33 bp 0x7ffde97a9aa0 sp 0x7ffde97a9a98

然后,工具报告出错的位置在程序源文件的第5行,使用了释放后的堆内存。

READ of size 1 at 0x602000000015 thread T0
    #0 0x56319c4c9a32 in main /root/asan_test/use_after_free.c:5:10
    #1 0x7f27f3029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #2 0x7f27f3029e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #3 0x56319c3f52e4 in _start (/root/asan_test/use_after_free+0x1e2e4) (BuildId: acfd5d52426f424206fda94cae93ede3c7fd4bf4)

接着,继续回溯程序栈,找出该报错是由于被访问的内存空间,在程序源文件第4行已经释放了。

0x602000000015 is located 5 bytes inside of 10-byte region [0x602000000010,0x60200000001a)
freed by thread T0 here:
    #0 0x56319c48ee66 in free (/root/asan_test/use_after_free+0xb7e66) (BuildId: acfd5d52426f424206fda94cae93ede3c7fd4bf4)
    #1 0x56319c4c99f5 in main /root/asan_test/use_after_free.c:4:3
    #2 0x7f27f3029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

最后,确定这片堆内存是在程序源文件第3行进行了空间分配。

previously allocated by thread T0 here:
    #0 0x56319c48f10e in __interceptor_malloc (/root/asan_test/use_after_free+0xb810e) (BuildId: acfd5d52426f424206fda94cae93ede3c7fd4bf4)
    #1 0x56319c4c99e8 in main /root/asan_test/use_after_free.c:3:20
    #2 0x7f27f3029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

综上,给出了清晰的程序流程和报错原因。此外,第二部分的报错信息展示了对内存空间进行shadow映射后红区投毒的分布情况。

SUMMARY: AddressSanitizer: heap-use-after-free /root/asan_test/use_after_free.c:5:10 in main
Shadow bytes around the buggy address:
  0x601ffffffd80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x601ffffffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x601ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x601fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x601fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x602000000000: fa fa[fd]fd fa fa fa fa fa fa fa fa fa fa fa fa
  0x602000000080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x602000000100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x602000000180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x602000000200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x602000000280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
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
==1947==ABORTING

可以十分形象地看到,两个fd是被释放的堆内存空间。其中,1个fd映射了8字节实际内存空间,程序分配了10字节空间,为了对齐占用了2个8字节的堆内存,以完成程序的空间分配。

3 基本原理

ASan 的内存检测机制与动态分析工具 Valgrind 中检测未定义值错误的方法 AddrCheck 类似,使用影子内存记录应用程序所在内存的每个字节是否可以安全访问,并用代码插桩来检查当应用程序载入或存储时的影子内存状态。可参考博客 ASan 基本原理介绍的内容。

3.1 内存映射

ASan 直接使用地址缩放加上位置偏移,将应用程序的内存地址转换成影子地址。给定一个内存地址 A d d r \mathrm{Addr} Addr ,影子地址的计算方式为 ( A d d r > > S c a l e ) + O f f s e t (\mathrm{Addr} >> \mathrm{Scale}) + \mathrm{Offset} (Addr>>Scale)+Offset ,其中 S c a l e \mathrm{Scale} Scale 的取值从 1 1 1 7 7 7 。令 S c a l e = N \mathrm{Scale}=N Scale=N ,则影子内存会占据 1 / 2 N 1/2^N 1/2N 大小的虚拟地址空间,同时用于对齐内存最少需要 2 N 2^N 2N 个字节。每个影子内存的 1 1 1 个字节可以用来描述虚拟内存中 2 N 2^N 2N 个字节的状态,并且编码成 2 N + 1 2^N + 1 2N+1 个不同的值,更大的 S c a l e \mathrm{Scale} Scale 值占用更少的影子内存空间,但是需要更多的虚拟内存空间来满足对齐需求。假设 M a x − 1 \mathrm{Max}-1 Max1 是虚拟内存空间中最大的有效地址,那么偏移量 O f f s e t \mathrm{Offset} Offset 应当被设置为从当前偏移位置到 O f f s e t + M a x / 8 \mathrm{Offset}+\mathrm{Max}/8 Offset+Max/8 ,从而在程序启动时不被占用。

在 ASan 工具中, S c a l e Scale Scale 的值被设置为 3 3 3 ,将 8 8 8 字节的应用程序的虚拟内存映射为 1 1 1 字节的影子内存。由malloc函数返回的内存地址按至少 8 8 8 字节的大小进行对齐,任何对齐的 8 8 8 字节内存空间序列可以包含 9 9 9 种不同的状态:前 k ( 0 ≤ k ≤ 8 ) k (0 \leq k \leq 8) k(0k8) 个字节的内存空间是可寻址的,即表示内存分配可被应用程序使用,剩余 8 − k 8-k 8k 个字节的内存空间是不可寻址的。在典型的32位操作系统中,虚拟内存空间有4G,地址空间为0x00000000-0xffffffff,偏移量被设置为 O f f s e t = 0 x 20000000 ( 2 29 ) \mathrm{Offset}=0x20000000(2^{29}) Offset=0x20000000(229) 。于是,有如下的虚拟内存空间到影子内存空间的转换计算方式:

Shadow = (Mem >> 3) + 0x20000000

对应的内存区域划分如下表:

[0x40000000, 0xffffffff]HighMem
[0x28000000, 0x3fffffff]HighShadow
[0x24000000, 0x27ffffff]ShadowGap
[0x20000000, 0x23ffffff]LowShadow
[0x00000000, 0x1fffffff]LowMem

如图1所示,展示了在应用影子内存空间映射后的地址空间布局,应用程序的虚拟内存被划分了低地址内存(LowMem)和高地址内存(HighMem)两个部分,分别对应着低影子区域(LowShadow)和高影子区域(HighShadow)。而影子区域被映射后,分布于影子间隔区域(ShadowGap),也就是图中的 Bad 区域,该内存空间通过页保护机制被标记为不可寻址区域。后面用函数Shadow = MemToShadow(Mem)来表示地址映射过程。

图1 ASan内存空间映射

图1 ASan内存空间映射

3.2 插桩检测

ASan 工具通过对原有代码插桩,在每个进行内存操作的指令之前检查其映射的影子地址是否可正确访问,结合运行时库替换原有的mallocfree函数对指定影子内存空间进行投毒,具体分配红区和实施投毒的方法下一节再介绍,这部分先解释如何通过插桩检测错误。

在应用程序中,每个内存访问操作由编译器转换后无非是如下两种:

*address = ...; // 写操作,store存储指令
... = *address; // 读操作,load载入指令

ASan 工具会在编译过程中对所有内存在访问之前,检查被访问的地址是否被投毒,基本的代码插桩会将上述指令改成:

if (IsPoisoned(address)) {
  ReportError(address, kAccessSize, kIsWrite);
}
*address = ...;  // or: ... = *address;

其中,ReportError函数将通过运行时库被调用,进行对应的错误报告和打印输出。上述插桩仅仅是一个示意,实际在操作的时候 ASan 工具会应用到上一节的内存映射,首先将被访问的内存地址转换成影子地址,再检查影子地址的值。

当插桩一个8字节的内存地址时,ASan 工具直接对其影子地址检查存储的值是否为0:

ShadowAddr = MemToShadow(Addr)
if (*ShadowAddr != 0)
  ReportAndCrash(Addr)

当插桩1,2,或4字节的内存地址时,ASan 工具仅仅检查在8字节中的前k个字节是否为可寻址的,即比较内存地址最后3比特:

ShadowAddr = MemToShadow(Addr)
k = *ShadowAddr
if (k != 0 && ((Addr & 7) + AccessSize > k))
  ReportAndCrash(Addr)

举个例子,按8字节对齐分配了一个4字节的内存,即k = 4。如果地址访问从第1个字节开始(Addr & 7) = 0,访问大小为2时AccessSize = 2,那么不会触发内存错误;如果地址访问从第3个字节开始(Addr & 7) = 2,访问大小为4时AccessSize = 4,那么内存访问覆盖到了不可寻址的区域,将会触发内存错误。

值得注意的是,Clang 项目将上述 ASan 工具的插桩放置在了 LLVM 优化通道中的最后一个 Pass 里,因此只有最终的内存访问操作才会被插桩做检测。

3.3 红区投毒

运行时库的目的是管理影子内存空间,在程序启动时整个影子区域将被映射为不可使用的 ShadowGap 或 Bad 部分,这在第一节时已经做了详细介绍。运行时库中实现了被替换的mallocfree函数,会为程序变量分配被称为红区(redzone)的额外内存,包围着要返回的变量所在内存区域。内存区域被组织为一系列与被分配内存对象大小对应的空闲数组。对于n个区域,将分配n+1个红区,通常一个区域的右红区会是另一个区域的左红区。
∣ r z 1 ∣ m e m 1 ∣ r z 2 ∣ m e m 2 ∣ r z 3 ∣ m e m 3 ∣ r z 4 ∣ |rz1|mem1|rz2|mem2|rz3|mem3|rz4| rz1∣mem1∣rz2∣mem2∣rz3∣mem3∣rz4∣

在对应的影子内存空间中,红区被用来存储一些特定的内部数据,并采用32字节的大小进行对齐,举例一个检测栈对象溢出的情况,有如下程序:

void foo() {
  char a[8];
  ...
  return;
}

在变量分配时进行红区投毒的插桩后代码如下:

void foo() {
  char redzone1[32];  // 32字节对齐
  char a[8];          // 32字节对齐
  char redzone2[24];
  char redzone3[32];  // 32字节对齐
  int  *shadow_base = MemToShadow(redzone1);
  shadow_base[0] = 0xffffffff;  // 投毒redzone1
  shadow_base[1] = 0xffffff00;  // 投毒redzone2, 保留'a'
  shadow_base[2] = 0xffffffff;  // 投毒redzone3
  ...
  shadow_base[0] = shadow_base[1] = shadow_base[2] = 0; // 解毒所有
  return;
}

函数foo有一个在栈上占据8个字节的变量a,插桩之后分配了32字节的左红区redzone1和32字节的右红区redzone3,同时为了对齐还分配了24字节的中间红区redzone2。分配后计算这一段内存空间的影子地址,对红区进行投毒赋值,简单地将可寻址的部分赋值为0,而不可寻址的部分全部赋值为1。在具体的程序实现时,为了区分内存的不同区域,该赋值会根据所在位置进行调整,具体参考第4章内容。最后,当程序结束后,栈对象被释放的同时对影子地址所对应的红区进行解毒处理。

3.4 错误漏报

论文中给出了这种插桩方案可能会漏报一些比较罕见的错误。

  1. 部分未对齐的越界访问
int *a = new int[2]; // 8字节对齐
int *u = (int*)((char*)a + 6);
*u = 1; // 访问范围为[6-9]
  1. 越界访问落在其他分配
char *a = new char[100];
char *b = new char[1000];
a[500] = 0; // 落在变量b的某个位置
  1. 大量内存被分配又释放
char *a = new char[1 << 20]; // 1MB
delete [] a; // <<< "free"
char *b = new char[1 << 28]; // 256MB
delete [] b; // 清空隔离队列
char *c = new char[1 << 20]; // 1MB
a[0] = 0; // "use". 可能位于变量c

4 核心实现

ASan工具从 LLVM 3.1 版本开始支持,项目源码中包括了llvmcompiler-rtclang3个目录。

  • llvm提供中间表示和Pass管理的编译器架构,ASan工具的插桩代码实现在llvm/lib/Transforms/Instrumentation/AddressSanitizer.cpp文件中。
  • compiler-rt是 LLVM 项目运行时库,与ASan工具相关的库实现在compiler-rt/lib/asan目录中。
  • clang将编写的Pass加入到优化链中,相关操作写在了clang/lib/CodeGen/BckendUtil.cpp文件中。

4.1 代码插桩

ASan工具主要由代码插桩和运行时库组成,图2展示了AddressSanitizer.cpp文件中关键的函数调用和功能说明。

图2 AddressSanitizer.cpp关键函数调用关系图

图2 AddressSanitizer.cpp关键函数调用关系图

入口函数

入口函数runOnModule首先会检查函数列表,在LLVM构造函数中插入运行时库的__asan_init函数对ASan工具进行初始化。

AsanCtorFunction = Function::Create(
  FunctionType::get(Type::getVoidTy(*C), false),
  GlobalValue::InternalLinkage, kAsanModuleCtorName, &M);
BasicBlock *AsanCtorBB = BasicBlock::Create(*C, "", AsanCtorFunction);
CtorInsertBefore = ReturnInst::Create(*C, AsanCtorBB);

// call __asan_init in the module ctor.
IRBuilder<> IRB(CtorInsertBefore);
AsanInitFunction = cast<Function>(
  M.getOrInsertFunction(kAsanInitName, IRB.getVoidTy(), NULL));
AsanInitFunction->setLinkage(Function::ExternalLinkage);
IRB.CreateCall(AsanInitFunction);

然后调用insertGlobalRedzones函数对所有全局变量的尾部插入红区。

if (ClGlobals)
    Res |= insertGlobalRedzones(M);

遍历源码文件中的每一个函数,调用handleFunction函数识别关键指令进行插桩,主要关注这部分的插桩内容。

for (Module::iterator F = M.begin(), E = M.end(); F != E; ++F) {
  if (F->isDeclaration()) continue;
  Res |= handleFunction(M, *F);
}

handleFunction函数中,程序会遍历寻找内存相关的指令。

// Fill the set of memory operations to instrument.
for (Function::iterator FI = F.begin(), FE = F.end();
    FI != FE; ++FI) {
  TempsToInstrument.clear();
  for (BasicBlock::iterator BI = FI->begin(), BE = FI->end();
         BI != BE; ++BI) {
    ...  // 这部分判断指令是否为load、store,以及其他情况。
    ToInstrument.push_back(BI);
  }
}

然后对指令进行挨个插桩。

// Instrument.
int NumInstrumented = 0;
for (size_t i = 0, n = ToInstrument.size(); i != n; i++) {
  Instruction *Inst = ToInstrument[i];
  if (ClDebugMin < 0 || ClDebugMax < 0 ||
      (NumInstrumented >= ClDebugMin && NumInstrumented <= ClDebugMax)) {
    if (isa<StoreInst>(Inst) || isa<LoadInst>(Inst))
      instrumentMop(Inst);
    else
      instrumentMemIntrinsic(cast<MemIntrinsic>(Inst));
  }
  NumInstrumented++;
}

内存读写插桩

instrumentMop函数对内存读写指令loadstore进行插桩,程序对指令进行类型判断,然后调用instrumentAddress函数,该函数内容按照第3.2章节介绍的插桩检测原理,判断影子地址是否被投毒,然后调用对应的报错函数。

首先检查影子地址的值是否为0。

Value *AddrLong = IRB.CreatePointerCast(Addr, IntptrTy);

Type *ShadowTy  = IntegerType::get(
    *C, std::max(8U, TypeSize >> MappingScale));
Type *ShadowPtrTy = PointerType::get(ShadowTy, 0);
Value *ShadowPtr = memToShadow(AddrLong, IRB);
Value *CmpVal = Constant::getNullValue(ShadowTy);
Value *ShadowValue = IRB.CreateLoad(
    IRB.CreateIntToPtr(ShadowPtr, ShadowPtrTy));

Value *Cmp = IRB.CreateICmpNE(ShadowValue, CmpVal);

Instruction *CheckTerm = splitBlockAndInsertIfThen(
    cast<Instruction>(Cmp)->getNextNode(), Cmp);
IRBuilder<> IRB2(CheckTerm);

然后检查内存地址最后3比特。

size_t Granularity = 1 << MappingScale;
if (TypeSize < 8 * Granularity) {
  // Addr & (Granularity - 1)
  Value *Lower3Bits = IRB2.CreateAnd(
      AddrLong, ConstantInt::get(IntptrTy, Granularity - 1));
  // (Addr & (Granularity - 1)) + size - 1
  Value *LastAccessedByte = IRB2.CreateAdd(
      Lower3Bits, ConstantInt::get(IntptrTy, TypeSize / 8 - 1));
  // (uint8_t) ((Addr & (Granularity-1)) + size - 1)
  LastAccessedByte = IRB2.CreateIntCast(
      LastAccessedByte, IRB.getInt8Ty(), false);
  // ((uint8_t) ((Addr & (Granularity-1)) + size - 1)) >= ShadowValue
  Value *Cmp2 = IRB2.CreateICmpSGE(LastAccessedByte, ShadowValue);

  CheckTerm = splitBlockAndInsertIfThen(CheckTerm, Cmp2);
}

最后插入运行时库的__asan_report_error函数来产生错误报告。

IRBuilder<> IRB1(CheckTerm);
Instruction *Crash = generateCrashCode(IRB1, AddrLong, IsWrite, TypeSize);
Crash->setDebugLoc(OrigIns->getDebugLoc());
ReplaceInstWithInst(CheckTerm, new UnreachableInst(*C));

内存操作插桩

instrumentMemIntrinsic函数对内存操作指令memsetmemmovememcpy进行插桩,程序检查内存操作的长度。

Value *Dst = MI->getDest();
MemTransferInst *MemTran = dyn_cast<MemTransferInst>(MI);
Value *Src = MemTran ? MemTran->getSource() : NULL;
Value *Length = MI->getLength();
...
instrumentMemIntrinsicParam(MI, Dst, Length, InsertBefore, true);

然后根据参数调用instrumentMemIntrinsicParam函数,最终还是调用instrumentAddress函数进行插桩检测。

  // Check the first byte.
  {
    IRBuilder<> IRB(InsertBefore);
    instrumentAddress(OrigIns, IRB, Addr, 8, IsWrite);
  }
  // Check the last byte.
  {
    IRBuilder<> IRB(InsertBefore);
    Value *SizeMinusOne = IRB.CreateSub(
        Size, ConstantInt::get(Size->getType(), 1));
    SizeMinusOne = IRB.CreateIntCast(SizeMinusOne, IntptrTy, false);
    Value *AddrLong = IRB.CreatePointerCast(Addr, IntptrTy);
    Value *AddrPlusSizeMinisOne = IRB.CreateAdd(AddrLong, SizeMinusOne);
    instrumentAddress(OrigIns, IRB, AddrPlusSizeMinisOne, 8, IsWrite);
  }

在对内存相关指令进行插桩期间多次调用了splitBlockAndInsertIfThen函数,它的作用是将一个代码基本块拆分,并插入一个if-then指令进行条件分割,形成两个代码基本块,效果类似于:

之前:

Head
SplitBefore
Tail

之后:

Head
if (Cmp)
  NewBasicBlock
SplitBefore
Tail

对应的代码如下:

BasicBlock *Head = SplitBefore->getParent();
BasicBlock *Tail = Head->splitBasicBlock(SplitBefore);
TerminatorInst *HeadOldTerm = Head->getTerminator();
BasicBlock *NewBasicBlock =
    BasicBlock::Create(*C, "", Head->getParent());
BranchInst *HeadNewTerm = BranchInst::Create(/*ifTrue*/NewBasicBlock,
                                              /*ifFalse*/Tail,
                                              Cmp);
ReplaceInstWithInst(HeadOldTerm, HeadNewTerm);

BranchInst *CheckTerm = BranchInst::Create(Tail, NewBasicBlock);
return CheckTerm;

内存分配插桩

在进行完上述插桩后,要对内存在分配时对空间重新分配,划分红区并进行投毒,程序调用poisonStackInFunction函数识筛选出需要插桩的内存分配指令alloca

// Filter out Alloca instructions we want (and can) handle.
// Collect Ret instructions.
for (Function::iterator FI = F.begin(), FE = F.end();
      FI != FE; ++FI) {
  BasicBlock &BB = *FI;
  for (BasicBlock::iterator BI = BB.begin(), BE = BB.end();
        BI != BE; ++BI) {
    ...
  }
}

将原有的内存分配指令继续宁替换。

Type *ByteArrayTy = ArrayType::get(IRB.getInt8Ty(), LocalStackSize);
AllocaInst *MyAlloca =
    new AllocaInst(ByteArrayTy, "MyAlloca", InsBefore);
MyAlloca->setAlignment(RedzoneSize);
assert(MyAlloca->isStaticAlloca());
Value *OrigStackBase = IRB.CreatePointerCast(MyAlloca, IntptrTy);
Value *LocalStackBase = OrigStackBase;
...

调用PoisonStack函数对堆栈上的内存分配进行投毒。

// Poison the stack redzones at the entry.
Value *ShadowBase = memToShadow(LocalStackBase, IRB);
PoisonStack(ArrayRef<AllocaInst*>(AllocaVec), IRB, ShadowBase, true);

在投毒函数PoisonStack中,根据左右红区位置的不同,计算对应的投毒值。

Value *PoisonLeft  = ConstantInt::get(RZTy,
  ValueForPoison(DoPoison ? kAsanStackLeftRedzoneMagic : 0LL, ShadowRZSize));
Value *PoisonMid   = ConstantInt::get(RZTy,
  ValueForPoison(DoPoison ? kAsanStackMidRedzoneMagic : 0LL, ShadowRZSize));
Value *PoisonRight = ConstantInt::get(RZTy,
  ValueForPoison(DoPoison ? kAsanStackRightRedzoneMagic : 0LL, ShadowRZSize));

然后分别对左、中、右红区进行投毒赋值,需要注意的是中间红区的特殊性,需要计算对齐和剩余的部分,程序会调用PoisonShadowPartialRightRedzone函数完成对应计算。

// poison the first red zone.
IRB.CreateStore(PoisonLeft, IRB.CreateIntToPtr(ShadowBase, RZPtrTy));

// poison all other red zones.
uint64_t Pos = RedzoneSize;
for (size_t i = 0, n = AllocaVec.size(); i < n; i++) {

  ...

  // Poison the full redzone at right.
  Ptr = IRB.CreateAdd(ShadowBase,
                      ConstantInt::get(IntptrTy, Pos >> MappingScale));
  Value *Poison = i == AllocaVec.size() - 1 ? PoisonRight : PoisonMid;
  IRB.CreateStore(Poison, IRB.CreateIntToPtr(Ptr, RZPtrTy));
  Pos += RedzoneSize;
}

4.2 运行时库

运行时库替换了原有的指令,配合插桩一起完成内存地址的错误检测,这里主要关注上文提到的__asan_init__asan_report_error函数,位于compiler-rt/lib/asan/asan_rtl.cc文件中。

初始化

初始化方法__asan_init会读取环境变量存入工具的配置选项,一般以FLAG_xx命名。这里主要关注FLAG_v选项,它会打印出工具运行的细节,通过如下命令启动:

export ASAN_OPTIONS=verbosity=1

运行编译后的程序,在输出错误报告之前能看到调试信息,比较有趣的是打印了内存映射和关键参数。

|| `[0x10007fff8000, 0x7fffffffffff]` || HighMem    ||
|| `[0x02008fff7000, 0x10007fff7fff]` || HighShadow ||
|| `[0x00008fff7000, 0x02008fff6fff]` || ShadowGap  ||
|| `[0x00007fff8000, 0x00008fff6fff]` || LowShadow  ||
|| `[0x000000000000, 0x00007fff7fff]` || LowMem     ||
MemToShadow(shadow): 0x00008fff7000 0x000091ff6dff 0x004091ff6e00 0x02008fff6fff
redzone=16
max_redzone=2048
quarantine_size_mb=256M
thread_local_quarantine_size_kb=1024K
malloc_context_size=30
SHADOW_SCALE: 3
SHADOW_GRANULARITY: 8
SHADOW_OFFSET: 0x7fff8000

错误报告

错误报告方法__asan_report_error会根据被访问的地址,计算其影子地址,然后读取该影子地址对应的投毒值来判断错误类型。

Printf("=================================================================\n");
const char *bug_descr = "unknown-crash";
if (AddrIsInMem(addr)) {
  uint8_t *shadow_addr = (uint8_t*)MemToShadow(addr);
  // If we are accessing 16 bytes, look at the second shadow byte.
  if (*shadow_addr == 0 && access_size > SHADOW_GRANULARITY)
    shadow_addr++;
  // If we are in the partial right redzone, look at the next shadow byte.
  if (*shadow_addr > 0 && *shadow_addr < 128)
    shadow_addr++;
  switch (*shadow_addr) {
    case kAsanHeapLeftRedzoneMagic:
    case kAsanHeapRightRedzoneMagic:
      bug_descr = "heap-buffer-overflow";
      break;
    case kAsanHeapFreeMagic:
      bug_descr = "heap-use-after-free";
      break;
    case kAsanStackLeftRedzoneMagic:
      bug_descr = "stack-buffer-underflow";
      break;
    case kAsanStackMidRedzoneMagic:
    case kAsanStackRightRedzoneMagic:
    case kAsanStackPartialRedzoneMagic:
      bug_descr = "stack-buffer-overflow";
      break;
    case kAsanStackAfterReturnMagic:
      bug_descr = "stack-use-after-return";
      break;
    case kAsanUserPoisonedMemoryMagic:
      bug_descr = "use-after-poison";
      break;
    case kAsanGlobalRedzoneMagic:
      bug_descr = "global-buffer-overflow";
      break;
  }
}

上述部分代码可以了解到ASan工具都支持哪些类型的内存地址错误检测,而这些错误检测的类型是由红区以及投毒的特殊设计得以实现。

学习笔记

ASan借助LLVM统一的中间表示和优化器框架,插桩关键指令和替换核心函数。从思路上看还是比较简单的,但是在工程实现上需要注意的细节非常多。最后,附上文献引用和DOI链接:

Serebryany K, Bruening D, Potapenko A, et al. {AddressSanitizer}: A fast address sanity checker[C]//2012 USENIX annual technical conference (USENIX ATC 12). 2012: 309-318.

https://dl.acm.org/doi/10.5555/2342821.2342849

  • 16
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值