头图

asan 的原理

概述

如何判定进程能否访问一片内存区域?一个直观的想法是: 给每个字节做个记号(poison state),将进程不能够访问的字节标记为 poisoned,然后在每次访问内存之前先检查它的 poison state,如果是 poisoned,那么就可以判断发生了非法访问。asan 的核心算法就是基于这个想法,asan 会使用一片专门的内存来保存 application memory 每个字节的 poison state,这片内存的专业名称是 shallow memory,在访问地址 addr 之前,首先在 shadow memory 中检查 addr 的 poison state,如果 poison state 是 poisoned,那么就可以判定发生了非法访问,asan 会及时报告错误,否则程序正常运行。

实际上,asan 的实现远比上面描述的要复杂, 概括地说,asan 由两部分组成:

通过上述内容可以看出:asan 的实现既依赖于 compiler 在编译时对源代码做特殊的转换(由 instrumentation module 实现),还依赖一个运行时库,下文将对上述内容进行更加详细的介绍。

Shadow Memory

asan 会将进程的 memory space 分为两大类:

  • Application Memory(简称为 Mem)

这部分内存属于应用进程,由应用进程进行使用。

  • Shadow Memory(简称为 Shadow)

这部分内存用于存放 shadow value,shadow value 其实就是 Mem 各个 byte 的 poison state。”Shadow“的字面意思是“影子”,在 asan 的具体实现上,Mem 和 Shadow 之间存在着一一对应的关系,这就相当于现实生活中的"影子"。按照 Mem 和 Shadow 之间的一一对应的关系,将 Mem 中的一个 byte 标记为“poisoned”是通过将这个 byte 对应的 Shadow 中的 byte 设置为特殊值来实现的。

Shadow memory 是内存错误检查工具中普遍采用的一种技术,在使用这种技术时,会涉及如下两个问题:

  1. Application Memory to Shadow Memory mapping (MemToShadow),即如何将 Application Memory 中的每个字节映射到 Shadow Memory 中。
    在 asan 的具体实现中, 这两类内存的组织方式、映射方式应使计算 Application Memory 对应的 Shadow Memory 的速度快 。
  2. Shadow encoding,即 Shadow Memory 如何紧凑地保存  Application Memory 的 poison state。

下文进行详细介绍:

  • MemToShadow
    asan 的实现基于一个事实前提:由 malloc 返回的内存地址总是至少 8 字节对齐,这个特性保证能使用一个统一的地址映射公式来将 Application Memory 中的一个字节映射到 Shadow Memory 中,无需对特殊情况进行特殊的实现。除此之外,这个特性也蕴含了应用程序的 heap memory 的任何 8 字节对齐的 8 字节序列处于 9 种不同状态之一:前 k(0 ≤ k ≤ 8)字节是可以访问的(可寻址),其余 8-k 字节不可访问。显然这 9 种状态可以被编码到 Shadow Memory 的单个字节中(一个字节包含 8bit,是能够编码 9 种状态的)。基于此,asan 将应用程序的 heap memory 的 8 个字节映射到 Shadow Memory 的 1 个字节中,因此理论上 Shadow Memor 将耗费八分之一的虚拟地址空间。
    asan 的 MemToShadow 方案为:给定位于 Application Memory 的地址  Addr,其对应的 Shadow Memory 的地址为:

(Addr>>Scale)+Offset

Scale 表示缩放比例,当使用前面描述的方案时,Scale 取值为 3,这是因为 Application Memory 的连续 8(8 等于 2 的 3 次方)个字节被映射到 Shadow Memory 的单个字节中。

Offset 表示偏移,asan 为不同的平台选取了不同的 Offset 值:

  1. 在 32 位 Linux 或 MacOS 系统上,asan 选取的 Offset = 0x20000000
  2. 在具有 47 个有效地址位的 64 位系统上,asan选取的 Offset =0x0000100000000000

下图显示开启了 asan 后进程的 memory space 的空间布局。Application Memory(图中的 Memory 区域)分为两部分(低和高),映射到相应的 Shadow Memory(图中的 Shadow 区域)。如果对 Shadow Memory 中的地址执行上述映射公式,映射公式保证它输出的地址将位于图中的 Bad 区域,在实现中,通过 page protection 机制将 Bad 区域标记为不可访问从而保证能够及时发现错误。

  • Shadow encoding
    通过前面的介绍可知:asan 将应用程序 heap memory 的 8 个字节映射到 Shadow Memory 的 1 个字节,asan 按照如下规则来编码 Shadow Memory 的每个字节的值:
  1. 0 表示对应的应用程序内存区域中的所有8个字节都是可寻址的
  2. k(1 ≤ k ≤ 7)表示前 k 个字节是可寻址的
  3. 任何负值表示整个 8 字节字不可寻址,asan 使用不同的负值来区分不同类型的不可寻址内存(heap redzones、stack redzones、global redzones、freed memory)

Instrumentation 

在 asan 论文中,将它的 Instrumentation 归入了 compile-time instrumentation(CTI)的范畴,这是由于 asan 要求编译器在编译时对源程序进行转换,因此任何支持 asan 的编译器都需要按照 asan 的算法进行特殊的实现。

在开启了 asan 后,编译器会将程序中的内存访问按照以下方式进行转换:

转换前: 


*address = ...;  //  write、store
variable = *address; // read、load

转换后:

byte *shadow_address = MemToShadow(address);
byte shadow_value = *shadow_address;
if (shadow_value)
{
  if (SlowPathCheck(shadow_value, address, kAccessSize))
  {
    ReportError(address, kAccessSize, kIsWrite);
  }
}

*address = ...;  //  write、store
variable = *address; // read、load

// Check the cases where we access first k bytes of the qword
// and these k bytes are unpoisoned.
bool SlowPathCheck(shadow_value, address, kAccessSize)
{
  last_accessed_byte = (address & 7) + kAccessSize - 1;
  return (last_accessed_byte >= shadow_value);
}

上述伪代码描述了 asan 的检查逻辑,其中 kAccessSize 表示的内存访问的大小,asan 假设 N 字节访问的地址与 N 对齐,所以 address 是和 kAccessSize 对齐的,下面对上述伪代码进行详细分析:

一、当 kAccessSize 的值为 8,表示 8-byte memory access,那么必须 8-byte 全部可寻址才能访问,否则就报错,显然此时只需要检查 Shadow_value 的值是否为 0 即可,这个过程可以简化为如下方式:

byte *shadow_address = MemToShadow(address);
byte shadow_value = *shadow_address;
if (shadow_value != 0)
    ReportAndCrash(address);

二、当 kAccessSize 的值为1、2、4 时,分别表示 1-byte memory access、2-byte memory access、4-byte memory access,如果 8-byte 全部可寻址是最好的,即使不是全部可寻址也不可以直接报错,还需要将地址的最后 3 位与 Shadow_value 进行比较。

在这两种情况下,asan 只为原始代码中的每个内存访问插入一个内存读取(读取shallow memory)。asan 假设 N 字节访问的地址与 N 对齐,如果实际情况并非如此,asan 可能会错过由未对齐访问引起的错误,在后面的 False Negatives 章节会进行介绍。

Poisoned redone

在前面的章节中已经提及了 poisoned redone,本节将对它进行专门的介绍。

poisoned redone 本质上是一片内存区域,其中所有字节都被标记为 poisoned。这就是意味中,一旦访问 poisoned redzone 就相当于“踩红线”了,就会触发 asan 报错。在 asan 中,它被用于检测 out of bound(越界访问)内存错误,asan 会在 object 的周围创建 poisoned redone,一旦发生了 out of bound,如果进入到 poisoned redone 就会触发 asan 报错。理论上 poisoned redzone 越大,那么通过它检测到 out of bound 内存错误的概率就越大,但在实际中,由于内存大小有限,asan 会选择一个合适大小的 poisoned redone。

需要注意的是: 这种方式在一些极限的情况下会失效,这在后面的 False Negatives 章节会进行介绍。

Stack And Globals

为了检测对 global object 和 stack object 的越界访问,asan 必须在这些对象周围创建“poisoned redzone”。

对于 global object,redzone 是在编译时创建,redzone 的地址在应用程序启动时传递给 run-time library。run-time library 的函数会将该 redzone 标记为 poisoned 并记录地址以供进一步错误报告。

对于 stack,redzone 是在运行时创建并标记为 poisoned 的。目前,使用 32 字节的 redzone(加上最多 31 字节用于对齐)。下表描述了 asan 的转换:

image.png

在上述例子中,asan 使用 poisoned redone 实现对 stack object a 的保护。

Run-time library

在前面已经概述了 asan 的 run-time library 的主要功能。在开启 asan 后,生成的产物会动态链接 asan 的 run-time library。

asan 的 run-time library 的目的之一是用它的特殊实现的 memory allocator 替换标准库的 memory allocator,以发现 heap object 的错误。

asan 的 run-time library 的 malloc 和 free 函数的原理如下:

malloc 函数会在返回的 heap 区域周围分配 poisoned redzone 以发现越界访问。

free 函数会将释放的内存区域全部标记为 poisoned 并将其置于 quarantine(隔离区),这样该区域就不会很快被 memory allocator 重新分配,因此在一段时间内如果再次访问这片已经释放的内存区域,就会触发 asan 报错,这样做的目的是发现 use-after-free 类错误。目前,quarantine 是用 FIFO 队列实现的,它在任何时候都拥有固定数量的内存。需要注意的是:这种方式在一些极限的情况下会失效,在后文的 False Negatives 章节会进行介绍。

asan 的 run-time library 的另外一个目的是管理 Shadow Memory。在应用程序启动时,整个 Shadow Memory 都被映射(mapped,仅仅占用地址空间,并未分配内存),因此程序的其他部分不能使用它。

asan 的准确性

理论上,asan 不会产生 false positive(误报),但是会存在 false negative(遗漏),下面结合具体例子进行说明。

False Negatives 
false negative 指的是实际存在错误但 asan 没有检测出,本节主要描述 asan false negative 这种情况。

  1. an unaligned access that is partially out-of-bounds

样例代码如下:

int *a = new int[2]; // 8-aligned
int *u = (int *)((char *)a + 6);
*u = 1; // Access to range [6-9],

u = 1 就是典型的“unaligned access that is partiallyout-of-bounds”。查看完整代码:

https://github.com/dengking/s...

目前 asan 忽略了这种类型的错误,因为所有提出的解决方案都会减慢通用程序执行路径。

  1. 越界访问时访问了很远的地方,超过前后的 redzone 的范围,访问到其他部分的应用数据,而检测不出越界访问错误。可以通过扩大 redzone 的方法来解决,但是开销也更大。

样例代码如下:

char *a = new char[100];
char *b = new char[1000];
a[500] = 0; // may end up somewhere in b

查看完整代码: 

https://github.com/dengking/s...

如果内存不是一个严重的限制,建议使用高达 128 字节的 redzone。

  1. 频繁分配、释放大量到堆内存,导致内存块过快地离开了 quarantine,因而检测不出 use-after-free 的错误。

样例代码如下:

char *a = new char[1 << 20]; // 1MB
delete[] a;                  // <<< "free"
char *b = new char[1 << 28]; // 256MB
delete[] b;                  // drains the quarantine queue.
char *c = new char[1 << 20]; // 1MB
a[0] = 0;                    // "use". May land in ’c’.

查看完整代码: 

https://github.com/dengking/s...

STL Code annotation

官方文档:

https://github.com/google/san...

为了帮助定位与 STL 相关的一些错误,LLVM libc++ 采用了 code annotation 技术,AddressSanitizerContainerOverflow 就是通过对 std::vector 添加 annotation 实现的。

对 STL 容器添加 annotation 的另外好处是提高 LeakSanitizer 的灵敏度。下面的示例存在一个泄漏,因为使用 pop_back 从全局向量中删除了一个指针,如果没有对 std::vector 添加 annotation 进行特殊实现,那么 LeakSanitizer 会将刚刚从 vector 中 pop 出来的指针视为“live pointer”,因为它仍然保留在 std::vector 的存储中。

#include <vector>
std::vector<int *> *v;
int main(int argc, char **argv) {
  v = new std::vector<int *>;
  v->push_back(new int [10]);
  v->push_back(new int [20]);
  v->push_back(new int [30]);
  v->push_back(new int [40]);
  v->pop_back();  // The last element leaks now.
}

调节 asan 的 resource usage 的 flag

官方文档:

https://github.com/google/san...

asan 提供了三个调节它 Resource Usage 的 run-time flag,本节将对此进行介绍。

malloc_context_size

堆栈展开的深度(默认值:30)。在每次调用 malloc 和 free 时,asan 都需要展开调用堆栈,以便当发现错误的时候,asan 输出的错误消息能够包含更多有价值的信息。此选项会影响 asan 的速度,尤其是在应用程序调用 malloc 频率较高的情况下。它不会影响内存占用和错误发现能力。它需要被设置为一个合理值,如果设置太小,那么在检测到问题后,可能由于 stack trace 太短而无法分析出错误的原因。

quarantine_size_mb

隔离区大小(默认值:256MB)。此值控制查找 use-after- free 错误的能力,它不影响性能。

redzone、max_redzone 
heap poisoned redzone 的大小(默认值:128 字节)。此选项会影响查找 heap-buffer-overflow 错误的能力。较大的值可能会显著减慢运行速度、增加内存使用量,尤其是在测试程序动态分配许多较小 heap memory 的情况下。由于 redzone 用于存储 malloc 调用堆栈,因此减小 redzone 会自动减小函数调用堆栈的最大展开深度。

Hardware Support

asan 的性能优势允许在各种情况下使用。但是,对于性能要求较高的应用程序以及在二进制产物大小非常敏感的情况下,asan 的开销可能无法接受。为了突破 asan 本身的限制,可以考虑在硬件层面实现 asan 的算法,本节对这个主题进行探讨。

硬件指令 checkN

这种方式是 asan 论文中提出的。asan 执行的检测可以被一个新的硬件指令 checkN 替换(例如,“check4 Addr”用于 4 字节访问)。带参数 Addr 的 checkN 指令应该等价于如下程序:

ShadowAddr = (Addr >> Scale) + Offset;
k = *ShadowAddr;
if (k != 0 && ((Addr & 7) + N > k)
    GenerateException();

Offset 和 Scale 的值可以存储在特殊寄存器中,并在应用程序启动时设置,这样的指令通过降低 icache 压力、结合简单的算术运算和更好的分支预测来提高 asan 的性能。它还将显着减小二进制产物的大小。

默认情况下,checkN 指令可以是空操作,并且只能由特殊的 CPU 标志启用。

Hardware-assisted asan

关于 hardware-assisted asan,参见:

https://clang.llvm.org/docs/H...

目前 clang 和 gcc 都在一定程度上支持它。

Benchmark

https://github.com/google/san...

中进行了非常详细地对比,本文不再赘述。


网易数智
619 声望140 粉丝

欢迎关注网易云信 GitHub: