内存错误检测工具AddressSanitizer原理

原论文: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’.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值