代码 bug 嗅探器:Sanitizer

代码 bug 总在不经意间出现,导演了一出出 crash 的悲剧。为了扼杀 bug 于襁褓之中,本文介绍的主角 Sanitize 挺身而出,致力于解决内存泄露、缓冲区溢出和未定义行为。

本文将从原理来探索 Sanitize 的相关能力实现,介绍 ASan、MSan、UBSan、TSan,而关于如何使用,在官方教程都有,不会过多介绍。

AddressSanitizer (ASan)

ASan 是用来检测 释放后使用(use-after-free)多次释放(double-free)缓冲区溢出(buffer overflows)下溢(underflows) 的内存问题。

该工具包含有编译器插桩模块(a compiler instrumentation module,现在是一个 LLVM 的 pass 了),还有一个运行时的库用来替换 malloc 函数。

插桩模块主要用在栈内存上,而运行时库主要用在堆内存上。

run-time library

ASan 的运行时库会使用 __asan_malloc 来替换掉所有对 malloc 的调用。

在 malloc 函数所分配的字节周围,插入雷区,对于 free 释放掉的内存进行投毒,从而跟踪 malloc / free 行为。

void *__asan_malloc(size_t sz) {
    void *rz = malloc(RED_SZ);  // 上接雷区
    Poison(rz, RED_SZ);
    void *addr = malloc(sz);    // 真正分配的内存
    UnPoison(addr, sz);
    rz = malloc(RED_SZ);        // 下接雷区
    Poison(rz, RED_SZ);
    return addr;                // 返回分配的内存首地址
}

Instrumentation

ASan 的编译器插桩模块,会对每一个 store 和 load 的内存读写指令,进行编译时的插桩。

代码 bug 嗅探器:Sanitizer

代码 bug 嗅探器:Sanitizer

可以在上图的代码部分看到,ASan 在使用的内存周围,插入了 instrumented code。

代码 bug 嗅探器:Sanitizer

在 x86_64 平台上,其插桩后的指令如下:

# long load8(long *a) { return *a; }                 # 原函数实现
0000000000000030 <load8>:
  30:   48 89 f8                mov    %rdi,%rax               # 读取参数
  33:   48 c1 e8 03             shr    $0x3,%rax               # 右移 3 位,获取影子内存的操作
  37:   80 b8 00 80 ff 7f 00    cmpb   $0x0,0x7fff8000(%rax)   # 对对应的影子内存判断值是否异常
  3e:   75 04                   jne    44 <load8+0x14>         # 如果不正常则跳到第 9 行,0x44
  40:   48 8b 07                mov    (%rdi),%rax   <<<<<< original load 原本的操作
  43:   c3                      retq   
  44:   52                      push   %rdx
  45:   e8 00 00 00 00          callq  __asan_report_load8      # 报错

Mapping

虚拟内存空间会被划分为以下两个类别:

  • Main application memory 主应用程序内存:即应用程序代码所使用的内存。
  • Shadow memory 影子内存:即包含有影子数据的内存。与主应用程序内存相关,对主存的一个字节投毒,意味着在对应影子内存中写入特殊数据。
shadow_address = MemToShadow(address);
if (ShadowIsPoisoned(shadow_address)) {
  ReportError(address, kAccessSize, kIsWrite);
}

ASan 会把 8 字节的程序内存映射为 1 字节的影子内存。

这 1 字节会保存一个值,来表示程序内存的状态(对齐状态下):

  • 0,表示整个 8 字节内存都正常
  • 负数,表示整个 8 字节内存都被投毒了
  • k,表示头 k 个字节正常、后 8-k 个字节被投毒了

对于不对齐的内存读取,需要采用特殊处理。以下是越界读取(out-of-bound,OOB)的案例:

int *x = new int[2]; // 8 bytes: [0,7].
int *u = (int*)((char*)x + 6);
*u = 1;  // Access to range [6-9]

不对齐的内存读取,其开销是很大的,所以编译器和运行时内存分配器一般会优化代码,来使大多数的内存读取是对齐的。

对比

代码 bug 嗅探器:Sanitizer

ASan 性能影响大概 ~2x,内存影响大概 ~2x。

MemorySanitizer (MSan)

ASan 无法覆盖到未初始化的内存,对于未初始化的内存,进行读取的行为同样危险,这时候就需要 MSan 出马了。

MSan 实现的是一个 bit to bit 的影子内存,每一比特都有映射,所以在计算影子内存的位置时,十分高效。

代码 bug 嗅探器:Sanitizer

0 代表已初始化过的,1 代表未初始化的,所以开始时,影子内存填满了 0xFF。

MSan 的性能开销大概 ~3x,内存开销大概 ~2x。

Shadow propagation

大多数编译器,是允许加载未初始化 int 或 float 值,来达到期望的效果的。

而且在类或结构体中,对其中内存对齐的部分的加载,也应该是合乎逻辑的。

所以 MSan 允许对未初始化内存的复制、以及一些安全的操作的。为了能达到这一目的,MSan 实现了 shadow propagation。

对于未初始化内存的读取结果是 undefined value(未定义值),它会被临时赋值给一个 shadow value(影子值) 来管理,然后当 application value(程序里的变量值) 被保存时,把这个 shadow value 存到对应的影子内存中。

某一些对 application value 的操作,需要是已初始化的,包括 conditional branch(有状态跳转)、system call(系统调用)、pointer dereference(解指针)。

instrumentation 主要就是对这些操作,进行封装插桩。

代码 bug 嗅探器:Sanitizer

代码 bug 嗅探器:Sanitizer

MSan 还能开启溯源操作,将源头的 shadow value 传播出去。

代码 bug 嗅探器:Sanitizer

run-time library

除了插桩指令以外,MSan 同样需要运行时库的支持,用来在启动时,将低地址内存设置为不可读,然后映射为影子内存。

被 malloc 返回的内存、以及被 dealloc 的内存、局部的栈对象都会被标记为未初始化的。

UndefinedBehaviorSanitizer (UBSan)

UBSan 主要就是检查未定义行为(undefined behavior),包括有符号整型溢出、空指针的使用、除 0 操作、越界读取等。

UBSan 在编译时对可疑操作进行插桩,以捕获程序运行时的未定义行为,如果检查到是 UB 行为,则会调用 __ubsan_handle_ 函数进行处理,输出报错信息。

UBSan 会有 ~1.25x 的性能开销,不会有内存开销。

ThreadSanitizer (TSan)

TSan 用来检查数据争用(data race)、死锁(deadlock) 的。

当多个线程同时操作同一个变量的时候,而至少一个的操作是写操作时,就会发生数据争用。

当多个线程因争夺资源而造成的一种互相等待的想象,若无外力介入,它们都无法继续推进,就是死锁。

TSan 需要所有代码都以 -fsanitize=thread 编译参数进行编译,不然某些代码可能会导致误判。

TSan 会有 2x~20x 的性能开销,有 5x~10x 的内存开销。

state machine

TSan 的运行时主要是维护了状态机的使用,对 libc/pthread 的大部分函数也进行了拦截操作。

程序执行可以看作是一连串的事件:

  • 内存存取事件:读、写
  • 同步事件:锁、happen-before(signal & wait)

代码 bug 嗅探器:Sanitizer

TSan 包含有全局的和 内存 ID (per-ID,a unique ID of a memory location) 的状态。全局状态是关于同步事件的信息,而 per-ID 状态是内存地址的信息。

下图中的 SS 代表 set of segment,segment 表示一个线程上包含的一连串的内存存取事件,比如对于一个内存 ID 而言,SSWr 表示对某个内存 ID 进行读操作的事件的集合。

代码 bug 嗅探器:Sanitizer

判断是否存在数据争用,其主要逻辑是:对同一内存 ID 进行操作时,却没有进行相同的锁来限制。

代码 bug 嗅探器:Sanitizer

还有很多的状态机算法,就不过多介绍了,在文末论文链接可以查看。

Instrumentation

在编译时,TSan 会在每一个内存存取操作、函数的调用、函数的 entry 和 exit 进行插桩。

代码 bug 嗅探器:Sanitizer

代码 bug 嗅探器:Sanitizer

同样,TSan 会采用影子内存来存取相关的信息。

代码 bug 嗅探器:Sanitizer

代码 bug 嗅探器:Sanitizer

参考

sanitizers:https://github.com/google/sanitizers

Be Wise, Sanitize - Keeping Your C++ Code Free From Bugs:https://m-peko.github.io/craft-cpp/posts/be-wise-sanitize-keeping-your-cpp-code-free-from-bugs/

AddressSanitizer:https://clang.llvm.org/docs/AddressSanitizer.html

AddressSanitizer - wiki:https://github.com/google/sanitizers/wiki/AddressSanitizer

Automated Test-Case Generation:Address Sanitizer:https://ece.uwaterloo.ca/~agurfink/stqam.w20/assets/pdf/W04-ASan.pdf

《MemorySanitizer: fast detector of uninitialized memory use in C++》:https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/43308.pdf

UBSan: run-time undefined behavior sanity checker:https://lwn.net/Articles/617364/

《ThreadSanitizer – data race detection in practice》:https://static.googleusercontent.com/media/research.google.com/zh-TW//pubs/archive/35604.pdf

Finding races and memory errorswith LLVM instrumentation:https://llvm.org/devmtg/2011-11/Serebryany_FindingRacesMemoryErrors.pdf

ThreadSanitizer, MemorySanitizer:https://llvm.org/devmtg/2012-11

上一篇:设计模式【13】-- 模板模式怎么弄?


下一篇:d化简属性.