原论文:AddressSanitizer: A Fast Address Sanity Checker
谷歌官方文档:AddressSanitizerAlgorithm
参考博客:Introduction to AddressSanitizer
论文解读:AddressSanitizer论文解读
Intro
内存访问错误,简单地说就是访问了不该访问的区域。如果我们能够记录所有有效的内存区域,并且在访问内存之前进行检查,就可以有效地抓住这些bug。这篇paper介绍的AddressSanitizer (ASAN) 是一个内存错误检测工具,可以检测出heap, stack, global objects的越界访问以及use-after-free错误。
AddressSanitizer主要包括三个部分:
阴影内存 shadow memory:ASAN算法的基石,占用一部分的内存区域,用于记录哪些内存地址是可以安全访问的,哪些内存地址是不可以访问的。
检测模块 instrumentation module:编译期间,对代码进行修改,主要增加了以下两个内容
- 在每一次内存访问前,先检查对应的内存地址的shadow状态,确定可以安全访问时才访问,否则报错。
- 在应用数据的前后增加毒区(poisoned redzones),毒区设置为不可寻址,用于检测stack和global objects类型的越界访问的错误。
运行时库 run-time library:运行期间,对代码进行修改,主要是修改了malloc和free函数的实现方法,在heap上分配内存时,在应用数据前后创建毒区(poisoned redzones),用于检测heap类型的越界访问的错误。
Algorithm
Shadow Memory
- 阴影内存空间 Shadow Memory
- 应用数据内存空间 Application Memory
很多内存检测工具也会使用Shadow Memory来检测内存错误,比如TaintTrace的设计比较粗糙,一对一映射,使得Shadow Memory与Application Memory占用的内存空间一样大,开销很大、影响性能。
相比之下,ASAN的Shadow Memory设计更加高效和灵活,不仅可以将Shadow Memory所需空间缩小为Application Memory的1/n,而且Shadow Memory的缩小倍数和它在内存中的起始位置也可以调整。
编码规则
首先,ASAN认为,既然(32位编译系统下)malloc函数分配内存是8byte对齐的,所以他们初步把压缩的倍数设置为8,即Application Memory的8byte映射到Shadow Memory的1byte上。那么对于Application Memory中每8byte的内存空间,对应的Shadow Memory用1byte来表示其状态,编码规则:
- shadow value = 0,代表8byte全部都是可寻址的,全都放满了应用数据。
- shadow value = k (1 ≤ k ≤ 7) ,代表前k byte的空间是可寻址的,剩余的8-k byte的空间是不可寻址的。
- shadow value < 0,负数代表8byte空间全部不可寻址,不同的负数代表不同的情况(heap redzones, stack redzones, global redzones, freed memory)
映射
从Application Memory地址映射到Shadow Memory地址的公式是
(Addr>>Scale)+Offset
Scale的取值可以是1~7中的任一数字,如果按照上面所说的8byte映射到1byte的设定,那么Scale=3。相当于Application Memory的地址右移3位后再加上一个Offset偏移后,就是对应的Shadow Memory地址。
Offset是事先确定好的,确定好之后就固定住,设置Offset的值的要求是:假设Max-1是虚拟内存中的最大地址,那么要求从地址Offset到Offset+Max/8这一部分都是空的,全部用作Shadow Memory。
一个例子
在32bit机器里,虚拟内存地址是0x00000000-0xffffffff, 可以设置Offset = 0x20000000 (2^29)
映射规则公式
Shadow = (Mem >> 3) + 0x20000000;
地址空间布局
地址区间 | |
---|---|
[0x40000000, 0xffffffff] | HighMem |
[0x28000000, 0x3fffffff] | HighShadow |
[0x24000000, 0x27ffffff] | ShadowGap |
[0x20000000, 0x23ffffff] | LowShadow |
[0x00000000, 0x1fffffff] | LowMem |
映射图
虚拟内存空间中,Application Memory被分成了高地址和低地址两个部分,对这两部分的地址空间使用映射公式,就会分别映射到对应的两个Shadow Memory。
除此之外,对Shadow Memory的地址使用映射公式会被映射到Bad Memory去,这个空间也叫做ShadowGap,被ASAN设置为不可寻址。这个空间的作用后面会讲到。
Instrumentation
Shadow Memory的编码规则、内存地址布局和映射规则都讲完了,现在的疑问就是,
- 检测过程:是怎么根据shadow value的值去判断Application Memory地址是否可以安全访问。
- 构建Shadow:(Instrumentation部分在编译期间修改代码,负责构建Stack and Globals类型的应用数据的redzone和shadow memory)。
检测过程
访问address前先检查对应的shadow值,此时还需要考虑这次内存访问是多少字节对齐的,
- 如果是8字节对齐,那么必须8byte全部可寻址才能访问,否则都报错。
- 如果是1-, 2-, or 4- bytes对齐,8byte全部可寻址最好,但是即使不是全部可寻址也不可以直接报错,还需要具体看shadow_value的值才能判断。
检测代码
byte *shadow_address = MemToShadow(address);
byte shadow_value = *shadow_address;
if (shadow_value) {
if (SlowPathCheck(shadow_value, address, kAccessSize)) {//shadow_value != 0,需要调用SlowPathCheck做进一步的判断
ReportError(address, kAccessSize, kIsWrite); //报错
}
}
//访问address
可以看到,每次访问内存地址前都必须调用MemToShadow函数来获取对应的Shadow地址,然后检查其中的值。而如果代码中想要直接访问Shadow部分的值,调用MemToShadow后就会映射得到Bad Memory部分的地址,而Bad Memory部分是不可寻址的,无法读取其中的内容。因此,任何想要直接访问Shadow内容的代码都会报错。
SlowPathCheck函数
// Check the cases where we access first k bytes of the qword
// and these k bytes are unpoisoned.
bool SlowPathCheck(shadow_value, address, kAccessSize) {//参数kAccessSize代表这一次内存访问是kAccessSize字节对齐的
last_accessed_byte = (address & 7) + kAccessSize - 1;
return (last_accessed_byte >= shadow_value); //看这次要读的最后一个byte是否超出8byte里可寻址的范围
}
可以看到,使用ASAN检测后,每次访问内存都需要多读一次内存(读shadow),增加了一定的开销。
构建Shadow
接下来看如何构建Shadow Memory,应用数据主要存储在几个地方,包括stack、global、heap等。由于heap是运行时才分配,而Instrumentation是在编译阶段修改代码来构建Shadow Memory的,所以这里只有对Stack 和 Globals的操作。
Stack
以Stack为例子,一个函数的原始代码,会在栈上分配8个byte的内存空间:
void foo() {
char a[8];
...
return;
}
那么ASAN会怎么构建Shadow Memory来检测stack的越界访问错误呢。
直接看重构后的代码:
void foo() {
char redzone1[32]; // 32-byte aligned
char a[8]; // 32-byte aligned
char redzone2[24];
char redzone3[32]; // 32-byte aligned
int *shadow_base = MemToShadow(redzone1);
shadow_base[0] = 0xffffffff; // poison redzone1
shadow_base[1] = 0xffffff00; // poison redzone2, unpoison 'a'
shadow_base[2] = 0xffffffff; // poison redzone3
...
shadow_base[0] = shadow_base[1] = shadow_base[2] = 0; // unpoison all
return;
}
首先ASAN会在应用数据的前后增设大小为32byte的redzones,然后再给对应的Shadow地址赋值。
可以看到,每8byte的redzones对应的1byte的Shadow的值都为0xff,是负数-1,代表全部8byte都不可寻址。
补码的表示方法是: 最高位是符号位,0为正数,1为负数。
1、正数的补码就是其本身
2、负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1. (即在反码的基础上+1)
[+1] = [00000001]原 = [00000001]反 = [00000001]补
[-1] = [10000001]原 = [11111110]反 = [11111111]补
而8byte的应用数据对应的1byte的Shadow的值为0x00,代表全部8byte都可以寻址。
这样构建完redzone和shadow之后,每次访问stack的数据时经过前面章节的检测过程后,就可以判断到是否有越界访问的问题了。
最后函数结束的时候,再把shadow值全部置0。
Globals
Global的做法也类似,不同的地方在于,global的redzone是编译期间分配内存的,stack的redzone是运行期间分配内存的。
Run-time library
上面已经讲完了如何构建Stack和Global类型应用数据的redzone和shadow,接下来讲Heap类型。
运行过程中在heap上分配内存的相关函数就是malloc和free,而ASAN的运行时库会对这两个函数进行改造。
- malloc:也是同样的做法,在分配的应用内存的前后加上redzone,以及设置好对应的shadow的值。
- free:释放内存时,会把这块内存对应的Shadow值设置为不可寻址,并且把内存放进一个隔离队列中,隔离队列是一个先进先出(FIFO)队列,容量固定,满了之后才把最旧的内存放出来。这样做的目的是防止内存释放后马上被malloc,然后被之前残留的指针错读里面的内容,可以检测出use-after-free的错误(没有被malloc但是被访问了,shadow为不可寻址,报错)。
总结
总的来说,ASAN是一个快速的内存错误检测工具,可以检测出对heap、stack、globals的越界访问以及use-after-free错误,相比其他的同行工具更加高效,开销更低。而且从gcc 4.8开始,ASAN就已经成为了gcc的一部分,只需要加上编译选项-fsanitize=address就可以启用ASAN。
缺点
ASAN也有一些使用限制以及检测不出内存错误的情况。比如
1、运行ASAN需要消耗CPU和内存资源的,它的性能相比于之前的工具确实有了质的提升,但平均仍然会拖慢程序73%和消耗3.4倍的内存,因此也还是无法适用于某些压力测试场景,尤其是流量较高的服务,打开ASAN调试某些奇葩问题时,系统总会因为负载过重而跑不起来,内存使用率直接飙上99%。(这个也是我没法在线上机器用ASAN的原因)
2、内存访问不对齐。ASAN只能检测对齐的内存访问,比如1-, 2-, 4-, 8-bytes对齐等。
int *a = new int[2]; // 8-aligned
int *u = (int*)((char*)a + 6);
*u = 1; // Access to range [6-9]
3、越界访问时访问了很远的地方,超过前后的redzone的范围,访问到其他部分的应用数据,而检测不出越界访问错误。可以通过扩大redzone的方法来解决,但是开销也更大。
char *a = new char[100];
char *b = new char[1000];
a[500] = 0; // may end up somewhere in b
4、频繁分配、释放大量到堆内存,导致内存块过快地离开了隔离队列,因而检测不出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’.