0
点赞
收藏
分享

微信扫一扫

【Asan】工欲善其事必先利其器——AddressSanitizer

Xin_So 2022-05-05 阅读 75

【Asan】工欲善其事必先利其器——AddressSanitizer

背景介绍

自操作系统诞生以来,编写内存安全的代码一直是一个比较困难的问题 (另一个问题则是保证线程安全)。2004 年以来,微软安全响应中心(MSRC)已对所有报告过的微软安全漏洞进行了分类。根据他们提供的数据,所有微软年度补丁中约有 70% 是针对内存安全漏洞的修复程序。

由于 C/C++ 不是一门内存安全的语言,所以此类问题会经常遇到。而在项目开发中,相关 bug 的定位、解决速度可能影响着项目的整个进度,因此开发者们亟需一个内存检测器来诊断、发现这类错误。实际在 ASan 出现之前,市面上就已经存在了许多内存检测器,如

  1. Dr.Memory 检测未初始化的内存访问、double free、use after free 等错误
  2. Mudflap 检测指针的解引用,静态插桩
  3. Insure++ 检测内存泄漏
  4. Valgrind 可以检测非常多的内存错误
    但是无一例外,Dr.Memory、Insure++ 和 Mudflap 虽然在运行时造成的额外损耗比较少,但是检测场景有限;Valgrind 虽然能够在许多场景的检测出错误,但是它实现了自己的一套 ISA 并在其之上运行目标程序,因此它会严重拖慢目标程序的速度。而 ASan 在设计时就综合考虑了检测场景、速度的影响因素,结合了 Mudflap 的静态插桩、Valgrind 的多场景检测。ASan 由两部分组成:一个是静态插桩模块,将内存访问判断的逻辑直接插入在了二进制中,保证了检测逻辑的执行速度;另一部分则是运行时库,提供部分功能的开启、报错函数和 malloc/free/memcpy 等函数的 asan 检测版本。

**PS: 如果只是为了 memcpy/memset/strcpy 等位于 string.h 头文件内相关函数的调用检测,可以使用 _FORTIFY_SOURCE**

# 1 的话是仅在编译时检查
# 2 的话是还会在运行时检查,其实是将 memcpy 替换成 memcpy_chk 版本,然后检查目标内存的长度是否满足要求
gcc -D_FORTIFY_SOURCE=1 -Og -g

当然,这样开启之后会与 ASan 打架,如果已经使用 ASan 则可以不用考虑这个了。

作为 ASan 的使用者,熟悉它的原理才能更好地理解它、利用它提供的机制。

ASan 的作用

ASan 是一种结合编译器插桩和运行时的一种快速内存检测工具,主要用于检测代码中的部分 内存安全 问题:

  • 缓冲区溢出, ASan 提供 stack-buffer-underflow, stack-buffer-overflow, heap-buffer-underflow, heap-buffer-overflow, global-buffer-overflow 情况下的检测

  • 空指针引用, ASan 支持

  • 悬垂指针,ASan 支持

  • 使用未初始化的内存,ASan 不支持,可以由 MemorySanitizer 提供

  • 非法释放内存 (重复释放内存或者释放一个未经分配的指针),ASan 支持

特别地 ASan 还支持上面列表中未列出的一些特性:

  • stack-use-after-scope 栈变量在作用域失效后被使用
  • stack-use-after-return 栈变量在函数体返回后被使用
  • global-init-order 全局变量的初始化顺序检测

ASan 原理

前面提到 ASan 主要由 2 个模块组成:

  • instrument 静态插桩模块,对栈上对象、全局对象、动态分配的对象分配 redzone,以及针对这些内存做访问检测

  • runtime 运行时库,替换 malloc/free/memcpy/memset等实现、提供报错函数

针对每一次内存读写 (指针比较、相减也是支持的,但是由于在 STL 中指针前闭后开,end 指针经常会越界,从而会产生许多误报,故默认状态下关闭),编译器都会插入判断逻辑,判断地址是否被投毒(poisoned)。

PS: 通过以下方式来开启对指针比较、相减的 ASan 插桩检测及运行时的选项开启。

# 编译时
# 开启指针的比较、指针的相减
clang++ a.cpp -fsanitize=address -mllvm -asan-detect-invalid-pointer-pair=true

# 如果在 clang 11 下 那么可以更有针对性地开启
-fsanitize=address,pointer-compare,pointer-subtract

# 运行时选项
# 默认为 0,表示不检测
# 如果为 1,那么仅当两个指针都不是 nullptr 的时候才检测
# 其他情况下会判定这 2 个指针是否处于同一个对象内,即通过两指针是否处于同一栈空间、同一堆空间还是全局变量来判定
# 多个运行时选项通过冒号来分隔
ASAN_OPTIONS=detect_invalid_pointer_pairs=1 ./a.out

在插桩前,代码是这样的:

*addr = ...; // or ... = *addr;

在插桩后,代码就变成了这样:

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

这里的关键就是 IsPoisoned() 函数的实现,ASan 使用 shadow memory 的技术来存储一个地址是否是 poisoned 的状态。当然 ASan 也不是 shadow memory 用法的开创者,在此之前许多工具都已经在使用了,思想类似。例如:

  1. Valgrind 与 Dr.Memory 单独分配 shadow memory 表,然后在 shadow memory 中查找 addr 是否被投毒了
  2. Umbra 使用了 scale + offset 的方式

ASan 为了尽可能快速地判断,采取了跟 Umbra 一样的做法,这样转换函数也会变得比较简单:

shadow = (addr >> scale) + offset;

其中 offset 根据不同的平台、操作系统的不同而不同。在 x86-64 Linux 平台下,这个 offset 默认值为 0x7fff8000,点我查看 offset 计算。默认情况下 asan-mapping-scale 为 3,也就代表着 1 字节的 shadow memory 可以表示 8 字节的普通内存状态。而 shadow memory 的值也可以分成 3 种状态来讨论:

  1. 8 字节的数据可读写,则 shadow memory 的值为 0
  2. 8 字节的数据不可读写,则 shadow memory 的值为负数,如 0xfa 表示堆左边的 redzone、0xf1 表示栈左边的 redzone. ASan 也根据这个值在报错的时候输出对应的错误类型,如区分 heap-buffer-underflow/stack-buffer-underflow
  3. 前 k 个字节可读写,后 8 - k 个字节不可读写,则 shadow memory 的值为 k,k 的取值范围为 [1, 7]

PS: 这里有一个折中点,如果为了形式上的统一第 1 种情况可以被归类在第 3 点中,即 8 字节可读写时它的 shadow memory 值为 8。但是一般来说内存越界的情况始终是少数,所以为了能够快速判断 8 个字节完全可读写的情形将这一种情况独立出来,当然这样的折中最终也会导致非对齐访问无法被检测出来,这个问题后面会详细说明。

如果想修改 scale/offset, 可以增加如下编译参数:

# 修改 scale
clang++ a.cpp -mllvm -asan-mapping-scale=4

# 修改 offset 为自定义值
# 如果 offset 满足 2^n, 那么原来的 (addr >> scale) + offset 可以被优化成 (addr >> scale) | offset
# 在 x86 平台下 OR 操作比 ADD 操作还是快一点的
clang++ a.cpp -mllvm -asan-mapping-offset=0x7fffffff

scale 的值最小为 3,修改它会带来以下几点影响:

  1. scale 越大,所需的 shadow memory 就越小,因为一个字节的 shadow memory 可以表示更多的状态了
  2. scale 越大,对应 redzone 的 alignment 也会变大,导致它的检测范围也就越大,当然也会带来更大的内存开销
  3. 影响 1, 2, … 2^(scale-1) 字节访问的判断,但是相对的 2^scale 字节的内存访问会变快。考虑在 x86-64 上,修改 scale 为 4 会令 8 字节的访问检测也进入慢流程

考虑默认情况下 scale 为 3 的场景,IsPoisoned() 会遇到 2 种情况:

  1. 部分访问,即实际访问的字节数小于 8 字节
  2. 完全访问,即实际访问的字节数大于等于 8 字节

在第 1 种情况下,访问的内存可能处于最末端抑或是内存有效长度小于当前的访问字节数的大小。

example

  1. 当访问 a[0]、a[1] 的时候,发现对应的 shadow memory 为 0x00, 判定为安全
  2. 当访问 a[2]、 a[3] 的时候,发现对应的 shadow memory 为 0x00, 判定为安全
  3. 当访问 a[4]、 a[5] 的时候,发现对应的 shadow memory 为 0x00, 判定为安全
  4. 当访问 a[6] 的时候,发现对应的 shadow memory 不为 0x00, 那么访问这块内存可能是不安全的,需要更详细的判断才能确定访问是否安全

已知当前访问的地址为 addr、访问字节数为 size, 且 memToShadow(addr) 不为 0. 显然,当前的访问操作涉及的地址范围为 [addr, addr + size),而它实际安全的访问范围为 [p, p + memToShadow(addr)), 其中 p 为 addr & ~0x7 表示当前以 8 字节为单元的起始地址,memToShadow(addr)表示 addr 地址对应 shadow memory 的内存值。显然 p <= addr, addr - p == (addr & 0x7).

isPoisoned()

即只要满足 addr + size - 1 >= p + memToShadow(addr) 就能够说明当前的访问已经越界了。将这个表达式化简可以得到 (addr & 0x7) + size - 1 >= memToShadow(addr). 此外也可以证明,在访问到 redzone 时 memToShadow(addr) 为负数,表达式恒成立。

在第 2 种情况下,由于访问的字节数已经大于等于 8 了,所以可以直接检测对应的 memToShadow(addr) 的值,如果不为 0 那么一定是有问题的。

综上,可以用如下的伪代码来描述 IsPoisoned() 的逻辑:

const uint8_t s = memToShadow(addr);
if (size < 8) {
    if (s != 0) {
        if ((addr & 0x7) + size - 1 >= s) {
            ReportError(...);
        }
    }
} else {
    if (s != 0) {
        ReportError(...);
    }
}

栈上对象检测

ASan 针对栈上对象主要提供 3 种检测手段,分别是

* stack-buffer-overflow/stack-buffer-underflow, 针对栈上变量 out-of-bound 的检测

* stack-use-after-scope, 栈上变量在作用域失效后仍被使用

* stack-use-after-return, 栈上变量在函数返回后仍被使用(默认未开启)

栈上变量 OOB 访问检测

// clang++ a.cpp -fsanitize=address
int main() {
    char a[13];
    a[13] = 7; // or a[-1] = 7;
}

stack-buffer-overflow

如图所示,在 char a[13] 被初始化后它的内存状态如上。栈上变量左侧的 redzone 至少有 32 bytes 大小,因此 ASan 选择在左侧 redzone 上记录一些信息供调试使用:

  1. 0x41B58AB3 是一个表示当前栈帧的魔数
  2. ___asan_gen_ 是一个描述当前栈上变量信息的字符串。第一个 1 表示当前栈上总共有 1 个变量,32 表示变量对应当前栈的 offset,13 表示变量的大小,1 表示变量名字的长度,最后再跟着变量的名字。字符串格式形如这样的正则 VarsNum [Offset Size NameLength Name]*
  3. ptr to current function 如字面意思,存储着当前函数的地址
  4. 还剩下 8 字节未使用

用户访问栈上对象时 ASan 会对访问的内存(在本例子里是 a+13) 进行 IsPoisoned() 判定。如果诊断出来这块内存是 Poisoned 那么 ASan 会输出具体原因、局部 shadow memory 的信息以及当时的调用栈并停止当前程序。在本例子中,由于 memToShadow(a+13) 的值不为 0,因此需要再详细判定。因为 a 必然是按照 8 字节对齐的 (LLVM 生成代码时会保证栈上对象至少对齐至 2^scale),所以 ((a+13)&0x7) + 1 - 1 >= 5 表达式成立,判定出当前已越界。

如下是上述代码执行的结果。

=================================================================
==9160==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffefbb8c94d at pc 0x0000004ca0fc bp 0x7ffefbb8c910 sp 0x7ffefbb8c908
WRITE of size 1 at 0x7ffefbb8c94d thread T0
    #0 0x4ca0fb  (/tmp/a.out+0x4ca0fb)
    #1 0x7f987feda554  (/lib64/libc.so.6+0x22554)
    #2 0x41c31b  (/tmp/a.out+0x41c31b)

Address 0x7ffefbb8c94d is located in stack of thread T0 at offset 45 in frame
    #0 0x4c9fff  (/tmp/a.out+0x4c9fff)

  This frame has 1 object(s):
    [32, 45) 'a' <== Memory access at offset 45 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (/tmp/a.out+0x4ca0fb)
Shadow bytes around the buggy address:
  0x10005f7698d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f7698e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f7698f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f769900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f769910: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10005f769920: 00 00 00 00 f1 f1 f1 f1 00[05]f3 f3 00 00 00 00
  0x10005f769930: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f769940: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f769950: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f769960: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10005f769970: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
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
  Shadow gap:              cc
==9160==ABORTING

观察 Shadow bytes around the buggy address 项,它描述了当前内存对应的 shadow memory 的内存布局,显示 f1 f1 f1 f1 00 [05] f3 f3 正好可以说明它原始目的是想访问栈上变量的尾部内存,但是却发生了越界。为什么可以得出来是栈上变量越界而不是堆上变量呢?因为前后 redzone 对应的 shadow memory 内存值为 0xf1 与 0xf3,这正好表示是栈上变量左边/右边的 redzone。

如果这个函数里有多个局部变量,那么实际的内存布局会变成这样:

int main() {
    char a[13];
    char b[13];

    a[-1] = 0;
    b[13] = 0;
}

redzone between 2 variables

变量之间的 redzone 对应的 shadow memory 为 0xf2.

栈上变量作用域失效后仍被使用

int main() {
    char* p = nullptr;
    {
        char a[13];
        p = a;
    }
    p[13] = 7;
}

在一个变量作用域开始的时候 (在 @llvm.lifetime.start 之后) ASan 会自动 unpoison; 在一个变量作用域结束的时候 (在 @llvm.lifetime.end 之前) ASan 会自动 poison,将此变量标记为过期。

stack-use-after-scope

如图所示,原来合法访问的 13 bytes 的 shadow memory 对应为 0x00 0x05, 现在在作用域结束后被 poison 成了 0xf8 0xf8。如果再次对这块内存进行读写,那么显然会被判定为 stack-use-after-scope 报错退出。

=================================================================
==29438==ERROR: AddressSanitizer: stack-use-after-scope on address 0x7ffd698223ed at pc 0x0000004ca110 bp 0x7ffd698223b0 sp 0x7ffd698223a8
WRITE of size 1 at 0x7ffd698223ed thread T0
    #0 0x4ca10f  (/tmp/a.out+0x4ca10f)
    #1 0x7f1a6c599554  (/lib64/libc.so.6+0x22554)
    #2 0x41c31b  (/tmp/a.out+0x41c31b)

Address 0x7ffd698223ed is located in stack of thread T0 at offset 45 in frame
    #0 0x4c9fff  (/tmp/a.out+0x4c9fff)

  This frame has 1 object(s):
    [32, 45) 'a' <== Memory access at offset 45 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-use-after-scope (/tmp/a.out+0x4ca10f)
Shadow bytes around the buggy address:
  0x10002d2fc420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10002d2fc470: 00 00 00 00 00 00 00 00 f1 f1 f1 f1 f8[f8]f3 f3
  0x10002d2fc480: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10002d2fc4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

栈上变量在函数返回后仍被使用

注意: 此功能默认未开启,需要在运行时单独开启。

ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out

通常情况下,一个 lifetime 短的变量被一个 lifetime 更长的变量引用可以用 clang-tidy/clang-analyzer (这两者的区别 clang-tidy vs clang-analyzer) 等静态检查工具检测以提前避免绝大部分的此类错误。这里谈一下检测 stack-use-after-return 的算法、所面临的问题。

在连续调用函数时,栈帧会被复用。如果为判断 stack-use-after-return 仅在栈上进行 poison 操作,那么后一个函数会因为先前函数的 poison 而发生莫名的错误,自然这种 poison 可以认为无效。ASan 的做法则是将栈上的变量分配到堆上,这样即使有重复调用同一个函数,函数的栈帧也会因为被分配到了堆上保证了不会复用。

char redzone1[32]; // instrumented
char a[13];
char redzone2[19]; // instrumented

如上述代码所示,原来这 64 字节会统一分配在栈上,而在开启 stack-use-after-return 检测后会将它分配在堆上(分配操作由 FakeStackAllocator 提供),继而在函数返回时这块内存仍可以被保存下来避免被复用。FakeStackAllocator 是一个由 11 种不同大小的 chunk 所组成的 slab 分配器,默认每一种 chunk 类型的 slab 总大小为 1M,可以通过设置环境变量 ASAN_OPTIONS=min_uar_stack_size_log=16:max_uar_stack_size_log=20 来自定义。并且 FakeStackAllocator 是 threadlocal 的,也就是默认情况下每一个线程都会占用 11M 的额外内存。实际上会发现 FakeStack::Create 总会使用 max_uar_stack_size_log 来做为栈大小。

PS: 如果确定不需要检测 stack-use-after-return, 那么可以通过

clang++ -fsanitize=address -mllvm -asan-use-after-return=false a.cpp 

来彻底关闭,这样可以减少一点 binary size.

以一个例子来说明:

char* gp = nullptr;
void foo() {
    char a[13];
    gp = a;
}

int main() {
    foo();
    *gp = '1'
}

在 foo 函数中,创建了一个临时变量 a, 全局变量 gp 引用了局部变量 a,之后通过全局变量 gp 间接访问了那个曾经位于 foo 函数栈帧内的临时变量。显然这种情况下 ASan 会检测出来,那么它是怎么做的呢?

前面已经提到了 FakeStackAllocator,这是一个有着 64 字节、128字节… 64k 字节 11 种 chunk 类型的分配器,默认每一种 chunk slab 的大小为 1M。结合上面例子来说,foo 函数内的只有一个大小为 13 的变量,变量左侧的 redzone 最小为 32 字节,变量右侧补齐至 32 字节后总共需要 64 字节。于是这个栈帧可以通过 __asan_stack_malloc_0 (最小分配即为 64 字节,所以以 0 开头) 来分配,其定义可以见 DEFINE_STACK_MALLOC_FREE_WITH_CLASS_ID

其中每一类 chunk 的分配采用 round robin 算法。如果当前 chunk 的 flag 为 false,那么表示此 chunk “可以”被分配、使用,否则会继续寻找下一个 chunk 直到达到搜索上限次数(上限次数 = 1M/64 = 16K 次)。如果最后还是没有找到“可以”被分配的 chunk 则会返回 nullptr,在这种情况下栈帧不会被分配在堆上,即 stack-use-after-return 对当前栈帧不会开启。如果找到了合适的 chunk,那就会标记它的 flag 为 true 以保证在信号发生时仍能够正常工作。为了能够快速标记一个 chunk 是否“可以”被使用,ASan 会提前在 chunk 尾部 (位于 redzone 中) 存储指向其 flag 的指针 (注意“可以”是打引号的)。

stack-use-after-return

在栈帧退出时 (函数 return 前),ASan 会将此 chunk 整体 poison 成 0xf5,如果对这块内存进行读写则会报 stack-use-after-return 错。此外还会将本 chunk 对应的 flag 标记为 false 表示“可以”被使用。虽然这会对 stack-use-after-return 的准确性造成影响,但是因为使用的是 round robin 算法,想要使用当前这块 chunk 则需要经过 16k 次的分配后才会重新考虑它

以一个例子来体现一下这个问题:

char* gp = nullptr;

// 每一个栈帧都会有 64 字节被分配,其中的 a 会被分配在堆上,可以通过 gp 观察
// 已知 64 字节的 chunk 最多只有 16384 个,所以在开始分配第 16385 个的时候会复用第 1 个 chunk
// 如果我们打印 32768 次,那么在去除之后应当只有 16384 个结果
void foo() {
    char a[13];
    gp = a;
}

int main() {
    for (int i = 0; i < 32768; ++i) {
        foo();
        __builtin_printf("%p\n", gp);
    }
}

// clang++ return.cc -fsanitize=address
// ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out | sort | uniq -c | wc -l
// 16384
// ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out | sort | uniq -c | awk '{ print $1 }' | uniq
// 2

上面程序实际打印了 32768 次,但是观察上面顺序打印出来的结果,在去重之后只有 16384 次说明每一个地址都出现了 2 次。

由于 stack-use-after-return 有以下几点缺点,所以猜测它没有被默认开启:

  1. 栈上内存分配在堆上,会造成额外的内存消耗。默认每个线程都会有 11M 的内存分配,如果线程数过大则带来的内存负担也会更大
  2. 不是所有的栈都会被重新分配在堆上,如果计算出来栈上变量经过插桩后超过 64k,那么这个栈则不会被分配在堆上。在这种情况下则 stack-use-after-return 不生效
  3. 如果调用栈过深 (如每个栈占 64 但是深度超过 16k),那么也会因为对应的 chunk slab 内存不足导致部分栈未被分配在堆上而导致检测不生效
  4. 在函数返回时 poison 过后的栈可能会被重新使用,见上述例子

栈上动态对象检测

前一节主要关注于栈上静态对象的检测,实际上 ASan 也支持栈上动态变量的检测。栈上动态变量主要由 vla 扩展及直接的 man 3 alloca 调用产生,与栈上静态对象的插桩相比除 poison 所使用的魔数、前后都至少要有 32 字节的 redzone 外并无其他差别。

// ./a.out $(yes 1 | head -n 12 | paste -s -d' ')
int main(int argc, char* argv[]) {
    char a[argc];
    a[20] = 0;
}

上述程序在运行时会在 main 函数内创建一个 13 长度的数组,但是因为越界访问被 ASan 检测出来而停止。它的内存布局如下图所示,其中图中的 %n 为 13.

alloca-overflow

全局对象检测

char arr[13];
int main() {
    // 这种情况 ASan 检测不出来,且听慢慢分析
    // arr[-1] = 0;
    arr[30] = 0;
}

global-buffer-overflow

它的实际内存布局如上图所示,ASan 会为全局变量分配一个右侧的 redzone,并且变量 sizeof 越大,对应的 redzone 也越大。但是,不是所有的全局变量都会被 ASan 插桩,如果一个全局变量的 alignment 大于 32 或者这个全局变量是 thread_local 的,则不会对此对象插桩。

这里还有一点需要注意,全局变量左侧的 redzone 不存在,只有右侧的 redzone。只不过当两个同时被 ASan 插桩过的全局变量相邻,那么左边变量右侧的 redzone 可以看成右边变量左侧的 redzone,但是这种布局很容易被破坏。 当用户使用了 -fdata-sections 时 (将全局变量放到不同的 section 里,通常结合 -gc-sections 使用),链接器有权将这些 sections 重排,近而这些变量的内存布局会有别于源代码中的顺序。

回到上述例子中,arr[-1] = 0 因为 arr 左侧没有 redzone 所以 arr[-1] 的 underflow 无法被检测出来;而 arr[30] 地地址则位于它右侧的 redzone 中,很顺利地检测出来了。

=================================================================
==40996==ERROR: AddressSanitizer: global-buffer-overflow on address 0x000000e23f1e at pc 0x0000004ca057 bp 0x7ffcce04b8d0 sp 0x7ffcce04b8c8
WRITE of size 1 at 0x000000e23f1e thread T0
    #0 0x4ca056  (/tmp/a.out+0x4ca056)
    #1 0x7fa780419554  (/lib64/libc.so.6+0x22554)
    #2 0x41c31b  (/tmp/a.out+0x41c31b)

0x000000e23f1e is located 17 bytes to the right of global variable 'a' defined in 'global.cc:1:6' (0xe23f00) of size 13
SUMMARY: AddressSanitizer: global-buffer-overflow (/tmp/a.out+0x4ca056)
Shadow bytes around the buggy address:
  0x0000801bc790: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc7a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc7b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc7c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc7d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0000801bc7e0: 00 05 f9[f9]f9 f9 f9 f9 00 00 00 00 00 00 00 00
  0x0000801bc7f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc800: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc810: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc820: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0000801bc830: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

由于全局变量是当前模块可对外暴露的符号,对全局变量的插桩会影响整个模块,需要在模块加载时对变量进行 poison、在退出时 unpoison。不然用户加载一个 .so 时可能会被分配在那些未被 unpoison 的地址附近,那么访问 .so 里的对象可能会出现莫名的 ASan 报错。

global-buffer-overflow

如图所示,ASan 对于所有会被插桩的全局对象会生成一份 metadata 用来描述这个全局对象,主要有以下属性:

  • beg 指向这个全局变量的起始地址
  • size 存储这个全局变量的大小
  • size_with_redzone 存储这个全局变量的大小 + 对应 redzone 的大小
  • name 指向这个全局变量的名字
  • module_name 指向当前模块的名字
  • has_dynamic_init 表示当前对象是否需要动态初始化。例如 std::string 这种 non-trivial 的对象就需要动态初始化
  • source_location 指向当前对象的源码信息,存储着模块名字、行号、列号
  • odr_indicator 可能指向当前变量的一个私有存储,用来更加准确地检测当前变量是否违背 ODR 规则

为了禁止用户代码访问 metadata 数据,ASan 还会对这些内存 poison,这样也避免了用户指针跑飞而导致 ASan 元数据损坏的情况。注意到当前内容还未涉及 metadata 里的 odr_indicatorhas_dynamic_init 2 个属性,容小生慢慢道来。

在仅使用可执行文件的场景下,违背 ODR 规则会导致编译器报错,因此这种情况比较容易被发现。但是如果有同时使用到 DSO,且在可执行文件、DSO 中存在一个同名的变量, 这种违背 ODR 规则的现象就无法在编译阶段得知。

考虑如下例子:

// gcc a.c -shared -fPIC -o liba.so
char foo[4] = "abc";
void callme() {
    __builtin_printf("%c\n", foo[0]); // 'a' is EXPECTED?
}


// gcc main.c liba.so -Wl,-rpath=$(pwd)
int foo = 0x42424242;
extern void callme();
int main() {
    callme();
}

// ./a.out
// B

但是它的输出结果却是 B. 这是因为 ld.so 在进行符号解析时是按照这样的顺序进行的:

executable -> preload0 -> preload1 -> needed0 -> needed1 -> ...

其中 preload0、preload1 可以由 LD_PRELOAD 引入,needed0、needed1 是可执行文件原来的依赖。在上述例子中实际上只涉及到 executable 和 needed0. 在解析 foo 符号时发现它在可执行文件中,符号搜索就会立即停止。

上述例子中出现了 2 个模块,分别是 a.out 与 liba.so,它们都会先后进行各自模块的初始化,ASan 也会在这过程中依次将模块内的全局变量注册、检测是否违背 ODR 规则、对 redzone 进行 poison,最后插入至一个单向的全局链表中供模块卸载时使用(如果此变量还需要动态初始化则还会将其插入至 dynamic_init_globals 链表以记录,后面会详细说明)。ODR 检测主要有 2 种方式:

  1. 在默认不使用 odr_indicator 的情况下,ODR 的检测依赖此全局变量 redzone 的状态。如果发现对应的 redzone 已经被 poison 了那就说明这不是变量的第 1 次注册,即违反 ODR 原则。
  2. 在使用 odr_indicator 的情况下 (-mllvm -asan-use-odr-indicator=true),此变量的 odr_indicator 会指向一个 __odr_asan_gen_XXXX 的变量 (externally visible, XXXX 是其指向变量的名字) 来记录变量的注册状态。如果是第 2 次注册,那么显然 odr_indicator 的值会是「已注册」从而可以检测出多次定义。

这里要提醒一下,第 1 种方式有时候是无法检测出来的,如下图例子所示:

odr-indicator=false

在 a.out 里先注册变量的 size 比在 liba.so 内变量的 size_with_redzone 还大,之后再是 liba.so 内变量注册、检查 redzone 对应的 shadow memory 值时会发现都是 0 从而认为此时变量尚未 poison 而造成误判。在误判的情况下,会把这块内存给重新 poison,将 foo 原来 [0, 128) 可连续访问区间给截断了。因此误判情况下可能会造成 ASan 误报。

回到最初始的例子,正好 foo 在 2 个模块内所定义变量的大小一样,所以比较巧合地不会引起上面的问题。

这里还有一个比较有趣的问题,也是由 symbol interposition 引起 D92078.

// clang -shared a.c -o liba.so -fPIC -fsanitize=address
char foo[4] = "abc";
void dummy() {
    __builtin_printf("%c\n", foo[0]); // 'a' is EXPECTED?
}
int visit(void* p) {
    return *(int*)p;
}


// clang -c main.c -o main.o
// clang main.o liba.so -fsanitize=address -Wl,-rpath=$(pwd)
int foo = 0x42424242;
int bar = 0x43434343;

extern void dummy();
extern int visit(void*);

int main() {
    dummy();
    __builtin_printf("%x\n", visit(&foo));
    __builtin_printf("%x\n", visit(&bar));
}

// ./a.out
// ASan 报错 global-buffer-overflow

symbol-interpositon

它的根因是一个会被插桩的变量在符号解析时被解析到了一个不会被插桩的变量处,即在 liba.so 里的 foo 实际被解析到了 a.out 中,a.out 里的 foo 不会向 ASan 注册这个全局变量 (因为 main.o 编译时未带 -fsanitize=address 选项),所以当 liba.so 里的 foo 向 ASan 注册时判断不出此变量是否违背 ODR 规则,近而会认为 foo 右侧还有 60 字节的 redzone,故将其 poison。而 a.out 里的 bar 变量与 foo 相邻,bar 对象的地址正好落在了 foo 变量被 poison 的区域,于是透过 visit(&bar) 访问会因为踩中 foo 的 “redzone” 而报错。可以在编译 liba.so 时使用 -mllvm -asan-use-private-alias=1 编译参数,这样就不会间接影响在 a.out 里的 foo 了,自然也就无法检测出违背 ODR 规则的情况了。

ASan 还支持针对 non-trivial 对象的 Static Initialization Order Fiasco 检测,如 std::string, std::vector. 还是以一个例子来说明:

// foo.h
class Foo {
public:
    Foo(int x) : a_(x) {}

    Foo(Foo&&) = default;
    Foo(const Foo&) = default;
    Foo& operator=(Foo&&) = default;
    Foo& operator=(const Foo&) = default;

    int a() const noexcept {
        return a_;
    }

private:
    int a_;
};
// mod.cc
#include "foo.h"

Foo foo_in_other_mod(98);
// main.cc
// clang++ main.cc mod.cc -fsanitize=address -std=c++11
#include "foo.h"

extern Foo foo_in_other_mod;
Foo local(foo_in_other_mod.a());

int main() {
    return local.a();
}

// ASAN_OPTIONS=check_initialization_order=1 ./a.out
// ASan 报错

在不同 TU 里全局变量初始化顺序是未定义的,从最终结果来看 main.cc 内的 local 变量引用了另一模块里的变量 foo_in_other_mod,但是 foo_in_other_mod 的初始化晚于 local,在初始化 local 变量时对 foo_in_other_mod 变量的读写是 UB.

siof

前面提到 ASan 会在模块初始化时将全局变量注册,其中那些需要动态初始化的变量则会再单独记录在 dynamic_init_globals 链表中。在这个例子当中,foo_in_other_mod 没有定义在 main.cc 中,因此在 main.cc 模块全局变量初始化时会将这种定义不在本模块的全局变量 poison 成 0xf6 表示处于检测 Global init order 状态中,访问此种状态的全局变量 ASan 自然能够检测出来。如果当前 TU 里的所有全局变量初始化都没有问题,那么除这些已经初始化完毕的全局变量外都会重新 poison,接下来再按照这算法进行下一个 TU 的全局变量初始化。
在这里插入图片描述

如上图所示,如果 local 没有依赖 foo_in_other_mod,那么则会发生:

  1. 先将未定义在 main.cc 内的变量给 poison 成 0xf6,随后正常初始化 local 变量,顺利完成 main.cc 的全局变量初始化流程
  2. 再重新 poison 那些未定义在 main.cc 内变量的
  3. 再进行下一个 TU 内的全局变量初始化

所以总结一下,全局对象的 ASan 在以下情况下会不生效、可能会导致程序误报:

  1. 全局变量 alignment 超过 32 或者是 thread_local 则 ASan 不生效
  2. 如果全局变量左侧没有一个被 ASan 插桩过的全局变量,则此对象的 underflow 无法检测到,这通常发生在一个 TU 里的首个全局变量上。并且一旦用户使用 -fdata-sections 编译选项,这种情况会更加恶化
  3. 违背 ODR 规则且 ASan 未开启 odr_indicator 来精确检测,可能会导致部分正常可访问内存被 poison 近而导致 ASan 莫名其妙的误报
  4. 尽量不要混编 -fsanitize=address,例子见上

动态分配对象的检测

ASan 针对堆上对象主要提供 3 种检测手段,分别是:

  • heap-buffer-overflow, 针对堆上对象 out-of-bound 的检测
  • heap-use-after-free, 针对堆上对象被释放后仍被使用
  • double-free, 针对堆上对象的重复释放

除此之外,ASan 还会在 alloc/dealloc 方法不匹配时报错。

堆上对象 OOB 访问检测

int main() {
    char* p = new char[13];
    p[-1] = 'a';
}

运行上述程序会遇到 heap-buffer-overflow 报错而退出,这其中除 ASan 针对内存访问进行 poison 判断外,还涉及到 malloc/free 等运行时函数的替换。通常情况下 malloc/free 等符号由 glibc (libc.so) 提供,因为它定义在 libc.so 中所以可以被 interposition 替换。ASan 通过如下方式将默认的 malloc alias 至自己的实现以达到接管 malloc 的目的。

// 主要讨论 elf 平台
extern "C" void* malloc(size_t sz) __attribute__((weak, alias("__interceptor_malloc"), visibility("default")));

extern "C" __attribute__((visibility("default"))) void* __interceptor_malloc(size_t sz) {
    if (UNLIKELY(UseLocalPool())
        // HACK: dlsym calls malloc before REAL(malloc) is retrieved from dlsym.
        return AllocateFromLocalPool(sz);
    return asan_malloc(sz);
}

除 malloc/free 外 ASan 还会增强 memset/memcpy 等函数。如在调用 memcpy(dst, src, n) 时会先检查 dst 和 src 是否有 n 的有效长度然后再调用真实的 memcpy,这里真实的 memcpy 通过 dlsym 方式可以获得,其他运行时函数同理。

回到示例程序,用户调用 new char[13] 时实际会从 ASan 的内存分配器中分配,并会分配额外内存用于 redzone。如下图所示

dynamic-buffer-overflow

当用户请求 13 字节的内存时实际会分配 32 字节的内存,其中 3 字节用于将 13 对齐至 16 字节,剩下 16 字节作为 redzone 分布在左侧。上图中,它的右侧可能还会存在一个 redzone,与全局变量的情况类似,实际上这个 redzone 是其他 chunk 左侧的 redzone。而且 ASan 为了尽可能减少内存占用,将相关 metadata 也存储在了左侧 redzone 中:

  • chunk_state 表示当前 chunk 状态,在当前函数结束后为 CHUNK_ALLOCATED
    • CHUNK_AVAILABLE = 0 表示它在 freelist 中可以被分配
    • CHUNK_ALLOCATED = 2 表示它已经被分配但是尚未被释放
    • CHUNK_QUARANRINE = 3 表示它已经被释放并且移动到了隔离区
  • alloc_tid 存储「分配此内存」的线程 id
  • free_tid 存储「释放此内存」的线程 id,当前尚未涉及
  • from_memalign 表示此内存是否由 memalign 分配
  • alloc_type 表示此内存是由哪个内存分配函数调用,在本例子中为 FROM_NEW_BR
    • FROM_MALLOC = 1 表示由 malloc, callocrealloc 函数调用
    • FRON_NEW = 2 表示由 operator new 调用
    • FROM_NEW_BR = 3 表示由 operator new[] 调用
  • lsan_tag LSan 是否开启
  • user_requested_size 和 user_requested_alignment_log 存储用户原始请求分配内存的大小 13 及对应 alignment 的取 log 值 (3, 默认 alignment 都为 8)
  • alloc_context_id 存储分配此内存时的栈帧 id
  • free_context_id 存储释放此内存时的栈帧 id,当前尚未涉及

如果 redzone 大于 16 字节 (redzone 必须为 16 的倍数,虚线部分),那么还会在 redzone 的最前端存储一个魔数 0xCC6E96B9 和一个指向 chunk begin 的指针供调试时使用。

内存分配完成之后就是正常的 poison、访问被检测、越界报错,不再赘述。

堆上对象释放后仍被使用

int main() {
    char* p = new char[13];
    delete[] p;
    p[0] = 0;
}

ASan 的动态内存分配器由两部分组成:PrimaryAllocator 和 SecondaryAllocator。首先会尝试在 PrimaryAllocator 内分配,如果对象大小超过其上限 (Linux 下为 128K ) 则在 SecondaryAllocator 里分配。从实现角度来看,PrimaryAllocator 是一个小块内存的 slab 分配器,而 SecondaryAllocator 则是一个 mmap 的封装,以下讨论它们在 Linux x86-64 下的实现。

PrimaryAllocator

PrimaryAllocator 的实现与 stack-use-after-return 一节里描述的分配器非常相似,每一个不同大小的 slab 都有固定个数的 chunk。不同的是这 53 (实际在平均分配 4T 时向上取整至 64 方便计算, 第 54 ~ 64 个 slab 空置不使用) 个 slab 的总大小是 4T (由 kAllocatorSize 确定),且每一个 slab 有一个 free_array 来维护当前剩余可用的 chunk。但是 ASan 没有直接使用 PrimaryAllocator, 而是在其基础之上再封装了一个 AllocatorCache 接口方便以 cache 形式管理被用户释放的小内存。

检测 heap-use-after-free 需要在用户释放内存时将对应的内存 poison 并保存起来。
在这里插入图片描述

原来的 0x00 0x05 被 poison 成了 0xfd 0xfd, 这样用户再访问 p 数组就会被检测出 heap-use-after-free。为了与正常内存区分,ASan 会将用户已释放的内存放置在隔离区(quarantine cache list)。隔离区默认大小为 256M,当隔离区内存储的总大小超过上限时会将旧的多余部分踢出以保证不会无休止分配内存导致 OOM。也是因为这一点,有时候 heap-use-after-free 无法被检测出来。

int main() {
    char* a = new char[1 << 20]; // 1M
    delete[] a;

    char* b = new char[1 << 28]; // 256M
    delete[] b;                  // drains the quarantine queue

    char* c = new char[1 << 20]; // 1M 
    a[0] = 0;                    // may land in 'c'
    delete[] c;
}

这里解释一下,默认 PrimaryAllocator 无法分配大于 128K 的内存,所以这些内存都是经过 mmap 分配的。在第一次 new[]、delete[] 之后这块内存被放置到隔离区。而在第二次 new[], delete[] 256M 的 b 时,由于默认的隔离区上限为 256M,随着 b 的加入触发了隔离区的清理,所以先前在这隔离的内存 a 会被归还给系统。而后分配同样大小的 c 大概率会直接分配在原来 a 变量的地址上,之后访问 a[0] 就如同 c[0] 无法检测出来对 a 的 heap-use-after-free。不过在扩大隔离区之后,由于 a, b 变量都一直待在隔离区,后续 a[0] 的内存访问就可以很容易判断出来。

ASAN_OPTIONS=quarantine_size_mb=512 ./a.out
# 或者修改
ASAN_OPTIONS=thread_local_quarantine_size_kb=512000

堆上对象重复释放

int main() {
    char* p = new char[13];
    delete[] p;
    delete[] p;
}

在第一次 delete[] p 时会判断 p 的 chunk_state (metadata, 见堆上内存 OOB 检测一节) 是否为 CHUNK_ALLOCATED, 如果不是则报错,否则置为 CHUNK_QUARANTINE。但是在第二次 delete[] p 时会发现 p 的 chunk_state 已经为 CHUNK_QUARANTINE,表明此内存块已经位于隔离块中,因此可以报 double-free 错误。

类似的

int main() {
    char* p = new char[13];
    free(p);
}

在 free§ 的时候会检测当前的 alloc_type 与 p 分配时的 alloc_type 属性是否一致,如果不一致则会报 alloc-dealloc-mismatch。

其他功能

array cookie 保护
在 cxxabi array-cookies 要求中,对于 non-trivial 类型的 operator new[] 实际需要在内存前端存储实际分配的元素个数 (使用 std::size_t 类型来记录)。

#include <string>
#include <cstring>
#include <cstdint>

int main() {
    std::string* p = new std::string[13];
    for (std::size_t i = 0; i < 13; ++i) {
        p[i] = "hello";
    }

    std::memset((char*)p - 8, 0, 8); // overwrite array cookies
    delete[] p;
}

如上图所示,如果不小心内存越界将 array-cookies 给写没了,那么在 delete[] non-trivial 类型时它们的 dtor 就没法正确被调用,因而无法正确析构。

array-cookie-overwrite

在开启 ASan 之后对 array-cookies 的写就可以被检测到了。

容器 overflow

// clang++ -fsanitize=address -stdlib=libc++ -lc++ -lc++abi vec.cc
#include <vector>
#include <cstdint>
#include <cassert>

int main() {
    // 用 int64_t 是因为正好 8 字节,现象好观察一点
    std::vector<int64_t> vec;
    vec.push_back(0);
    vec.push_back(1);
    vec.push_back(2);
    assert(vec.size() == 3);
    assert(vec.capacity() >= 4);
	
    int64_t* p = &vec[0];
    return p[3];
}

在这里插入图片描述

已知 libc++ 内的 std::vector 容器实现是按 2 的指数倍扩增,因此在连续 push_back 3 个元素之后它当前的实际 capacity 会是 4, size 是3。从逻辑上来看 vec[3] 是不可访问的,但是在内存分配器角度来看因为它已经被分配给了用户自然是可以访问的。libc++ 借助 ASan 所提供的 API 将容器逻辑的有效性体现出现而非直接地暴露内存。

C++ 类间成员 overflow

clang 支持在类内各个成员之间的 padding 处插桩。当然不是所有的 C++ 类都会插入额外的 padding 来作为 redzone,如果它满足以下几点

  • 使用了 __attribute__((packed))
  • 是一个 union
  • 满足 std::is_trivially_copyablestd::is_trivially_destructiblestd:::standard_layout
  • 被 ASan 黑名单排除

那么它就不会产生额外的类间 padding.

// clang++ -fsanitize=address -fsanitize-address-field-padding=1 intra.cc
class Foo {
public:
    Foo() : pre1(1), pre2(2), post1(3), post2(4) {}

    virtual ~Foo() {}

    void set(int i, int val) {
        a[i] = val;
    }
	
private:
    int pre1, pre2;
    int a[11];
    int post1, post2;
};

int main() {
    Foo* p = new Foo;
    p->set(12, 42);
    delete p;
}

对于以上 Foo 类型,它的内存布局是这样的:

在这里插入图片描述

在示例中正好访问了 a[12] 即踩中了 a 变量右边的 redzone。此选项基本无法在复杂项目下使用,只能做为演示用。如果存在 ABI 依赖,打开此选项后会类的 sizeof 可能会变化引起 ABI 不一致; 网络协议相关的库强要求类的大小不能改变,在这种强要求下甚至可能会编译失败 (如可能有这样的判断 static_assert(sizeof(Foo) == 40))。因此如果想使用此特性,需要结合 ASan 黑名单一同使用。

Tips

在部分函数中屏蔽 ASan 插桩

__attribute__((no_sanitize("address"))) int foo_no_instrumented(int* p) {
    return *p;
}

int foo(int* p) {
    return *p;
}

如图所示,带有 no_sanitize(“address”) attribute 的函数就不会被 ASan 检测了。

用户数据结构集成 ASan

// clang++ stack.cc -fsanitize=address
#include <sanitizer/asan_interface.h>

#include <cassert>
#include <cstddef>
#include <cstdint>
#include <string>
#include <type_traits>

template <typename T, std::size_t N>
class Stack {
public:
    Stack() noexcept : sz_(0) {
        // poison
        ASAN_POISON_MEMORY_REGION(data(), sizeof(T) * N);
    }

    T* data() noexcept {
        return static_cast<T*>(static_cast<void*>(&arr_));
    }

    void push(T x) noexcept(std::is_nothrow_move_constructible_v<T>) {
        // precondition: sufficient space for a new one
        assert(sz_ < N);

        // unpoison for the new one
        ASAN_UNPOISON_MEMORY_REGION(data() + sz_, sizeof(T));

        new (data() + sz_) T(std::move(x));
        ++sz_;
    }

    void pop() noexcept(std::is_nothrow_destructible_v<T>) {
        // precondition: pop on a non-empty stack
        assert(sz_ > 0);

        T* p = data() + --sz_;
        p->~T();

        // poison back
        ASAN_POISON_MEMORY_REGION(p, sizeof(T));
    }

private:
    std::aligned_storage_t<sizeof(T) * N, alignof(T)> arr_;
    std::size_t sz_;
};

int main() {
    Stack<std::int64_t, 10> stk;
    stk.push(0);
    stk.push(1);

    assert(stk.data()[0] == 0);
    assert(stk.data()[1] == 1);

    stk.pop();
    assert(stk.data()[1] == 1); // ASan should panic

    return 0;
}

上述代码描述了一个简单、固定长度的 Stack,由于使用的是静态空间所以默认状态下这些空间都是可以访问的。这里将 Stack 的逻辑与 ASan 结合,严格保证仅 [0, sz) 区间的可以被访问,[sz, N) 区间访问禁止。

绕过 gRPC 挂在 tcp_flush 函数的问题

在进行稳定性测试时通常需要发现更多的业务错误,但是经常会遇到 gRPC 的问题被 ASan 检测出来而提前退出。例如 gRPC 的 tcp_flush 函数,即使将函数标记为 no_address_sanitize 仍然会检测出 heap-use-after-free。这是因为 ASan 不仅会对 memset/memcpy 等函数检测,还会覆盖一些 posix 函数,被覆盖的 posix 函数在执行完原始函数之后也会进行 ASan 检测。通过 tcp_flush 函数的调用栈可以明显观测到是由 sendmsg 引起的,这里不关心 gRPC 的错误,因此可以通过如下运行时选项

ASAN_OPTIONS=intercept_send=0 ./a.out

来屏蔽 ASan 对于

  • sendmmsg
  • sendmsg
  • sendto
  • send

的 检测。

ASan 屏蔽 malloc/free 等函数失效

此问题与上一小节的问题类似,ASan 令 malloc 解析至 __interceptor_malloc (ASan 自己的 malloc alias)。__interceptor_malloc 是一个定义在 libclang_rt.asan-x86_64.a 内的强符号,链接时的 -fsanitize=address 选项会将此静态库链接至最终的可执行文件中 。根据 ELF 符号解析规则

executable -> preload0 -> preload1 -> needed0 -> needed1

因为 __interceptor_malloc 符号直接存在于可执行文件中,无法通过 LD_PRELOAD 等方式进行 interposition 替换。那么想在部分静态库中关闭 malloc 的 ASan 检测还有什么其他办法呢?考虑将此部分库内的 call malloc 重写成 call malloc_no_sanitize (malloc_no_sanitize 为用户自己提供的没有 ASan 检测功能的函数,free 也需要替换),那么就会存在一部分静态库使用 malloc 另一部分使用 malloc_no_sanitize 混用的情况,一旦出现跨 lib 的 malloc/free (如 getline 函数) 就会因为内存分配器实现不同而导致程序 coredump。另一种是在插桩时将 malloc 全部替换,显然这会造成大面积误伤,因此目前屏蔽 malloc/free 等函数无解。

ASan 报错之后继续运行

ASan 实际使用的报错函数有 3 个 版本:

  • __asan_report_ 正常都是这种
  • __asan_report_exp_ 目前尚未实现相关 experiments
  • __asan_report_*_no_abort 报错后可以不退出

可以通过传递 -fsantize-recover=addressclang driver (相当于 -mllvm -asan-recover=true) 令 ASan 生成的报错函数版本为 __asan_repot_*_no_abort,但是想令它报错后不退出还依赖用户提供的运行时变量 halt_on_error (默认为 1)。 也就是当用户使用 -fsantize-recover=address 编译并且在运行时设置 ASAN_OPTIONS=halt_on_error=0 才能令 ASan 忽略报错继续运行。

非对齐内存访问越界

int main() {
    char* buf[8];
    int* p = (int*)(buf + 6);
    *p = 1; // [6, 9]
}

在默认 scale = 3 的情况下内存布局如下:
在这里插入图片描述

在确定 p 地址是否可读写时会首先判断其对应的 shadow memory 值是否为 0,在上述例子中这显然正好为 0。正是因为判断 8 字节读写仅需快速与 0 比较即可而导致实际访问的内存越界无法被检测出来。通过修改 asan-mapping-scale 可以让 1 字节 shadow memory 表示更多的内存,在 scale = 4 时它的内存布局如下:
在这里插入图片描述

那么在这个时候就可以继续判断访问 p 是否有越界行为。遗憾的是当前 ASan 对于 scale != 3 的情况下报错信息基本处于不可用的状态… (本人正在尝试修复中,希望
@MaskRay
宋教授不要把这个活给抢了 QAQ)

=================================================================
==2337==ERROR: AddressSanitizer: unknown-crash on address 0x7ffc7f8cc5a6 at pc 0x0000004ca104 bp 0x7ffc7f8cc570 sp 0x7ffc7f8cc568
WRITE of size 4 at 0x7ffc7f8cc5a6 thread T0
    #0 0x4ca103  (/tmp/a.out+0x4ca103)
    #1 0x7fca6296f554  (/lib64/libc.so.6+0x22554)
    #2 0x41c31b  (/tmp/a.out+0x41c31b)

Address 0x7ffc7f8cc5a6 is located in stack of thread T0
SUMMARY: AddressSanitizer: unknown-crash (/tmp/a.out+0x4ca103)
Shadow bytes around the buggy address:
  0x10000ff11860: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff11870: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff11880: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff11890: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff118a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10000ff118b0: 00 00 00 00[00]00 00 00 00 00 00 00 00 00 00 00
  0x10000ff118c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff118d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff118e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff118f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10000ff11900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

不过好在还可以利用输出的 backtrace 信息,通过设置 ASAN_SYMBOLIZER_PATH=/path/to/llvm-symbolizer ./a.out 运行来获得可读的准确源码位置信息。此外未对齐的访问也可以交给 UBSan 来检测。

参考资料

  • AddressSanitizer: A Fast Address Sanity Checker
  • -fno-semantic-interposition
  • google/sanitizer
  • AddressSanitizer
  • A proactive approach to more secure code

参考链接:

知乎版

举报

相关推荐

0 条评论