【CC++内存问题检测⼯具】AddressSanitizer(Asan)介绍
与分析
Google出品的内存检测⼯具AddressSanitizer介绍与分析
介绍
是⼀个C/C++的内存检测⼯具,可以发现的问题包括:
悬空指针
它与传统的内存问题检测⼯具,例如 Valgrind ,有何区别?
⽤过 Valgrind 的朋友应该都清楚,其会极⼤的降低程序运⾏速度,⼤约降低10倍,⽽ AddressSanitizer ⼤约只降低2倍,这是什么概念,果然是Google⼤法好!
具体使⽤
在LLVM及⾼版本编译器中已经⾃带了该⼯具,编译时添加 -fsanitize=address 选项。
形容桥的成语正常运⾏程序,如有内存相关问题,即会打印异常信息。
⼯具原理
⼯具⽤法⽐较简单,这⾥想重点说说该⼯具的原理。
由于是内存检测⼯具,其需要对每⼀次内存读写操作进⾏检查:
*address = ...; // or: ... = *address;
进⾏如下的逻辑判断:
if (IsPoisoned(address)) {
ReportError(address, kAccessSize, kIsWrite);
}
*address = ...; // or: ... = *address;
如果指针读写异常,则统计及打印异常信息,可见整个⼯具的关键在于 IsPoisoned 如何实现,该函数需要快速⽽且准确。
内存映射气候变暖的危害
其将内存分为两块:
主内存:程序常规使⽤
影⼦内存:记录主内存是否可⽤等meta信息
如果有个函数 MemToShadow 可以根据主内存地址获取到对应的影⼦内存地址,那么内存检测的实现,可以改写为:
shadow_address = MemToShadow(address);
if (ShadowIsPoisoned(shadow_address)) {
ReportError(address, kAccessSize, kIsWrite);
}
影⼦内存
AddressSanitizer ⽤ 1 byte 的影⼦内存,记录主内存中 8 bytes 的数据。
为什么是 8 bytes ,因为malloc分配内存是按照 8 bytes 对齐。
这样,8 bytes 的主内存,共构成 9 种不同情况:
8 bytes 的数据可读写,影⼦内存中的value值为 0
8 bytes 的数据不可读写,影⼦内存中的value值为 负数
前 k bytes 可读写,后 (8 - k) bytes 不可读写,影⼦内存中的value值为 k 。石南叶
如果 malloc(13) ,根据 8 bytes 字节对齐的原则,需要 2 bytes 的影⼦内存,第⼀个byte的值为 0,第⼆个byte的值为 5。
这时,整个判断流程,可改写为:
byte *shadow_address = MemToShadow(address);
byte shadow_value = *shadow_address;
if (shadow_value) {
if (SlowPathCheck(shadow_value, address, kAccessSize)) {
ReportError(address, kAccessSize, kIsWrite);
铂金耳环
}
}
// Check the cas where we access first k bytes of the qword
// and the k bytes are unpoisoned.
bool SlowPathCheck(shadow_value, address, kAccessSize) {
last_accesd_byte = (address & 7) + kAccessSize - 1;
return (last_accesd_byte >= shadow_value);
}
主内存映射到影⼦内存
进程的虚拟内存空间被ASAN划分为2个独⽴的部分:
a) 主应⽤内存区 (Mem): 普通APP代码内存使⽤区。
b) 影⼦内存区 (Shadow): 该内存区仅ASAN感知,影⼦顾名思义是指该内存区与主应⽤内存区存在⼀种类似“影⼦”的对应关系。ASAN在将主内存区的⼀个字节标记为“中毒”状态时,也会在对应的影⼦内存区写⼀个特殊值,该值称为“影⼦值”。
这两个内存区需要精⼼划分,确保可以快速从主应⽤内存区映射到影⼦内存区(MemToShadow),ASAN将8字节的主应⽤区内存映射为1字节的影⼦区内存,如下图:
MemToShadow 采⽤简单直接映射的⽅式
64-bit Shadow = (Mem >> 3) + 0x7fff8000;
32-bit Shadow = (Mem >> 3) + 0x20000000;
苏秦
例⼦
如何检测数组访问越界:
void foo() {
char a[8];
...
return;
}
AddressSanitizer 将其改写为:
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_ba = MemToShadow(redzone1);
shadow_ba[0] = 0xffffffff; // poison redzone1
shadow_ba[1] = 0xffffff00; // poison redzone2, unpoison 'a'
shadow_ba[2] = 0xffffffff; // poison redzone3
...
shadow_ba[0] = shadow_ba[1] = shadow_ba[2] = 0; // unpoison all
return;
}
如图:
婚姻登记网上预约
将 char a[8] 两侧⽤ redzone 包夹,这样数组访问越界时,⽴马能够侦测。
原理
ASAN的原理是影⼦内存、编译插桩和替换运⾏库,这⾥介绍其基本思想,有兴趣可以了解原论⽂(篇幅不长),见附件。
影⼦内存(Shadow Memory)
Malloc分配的地址⼀般是⾄少按照8bytes对齐的,因此ASAN设计⼀种8:1的投影关系:每8bytes的内存对应1byte的影⼦内存,即在进程的内存中,约1/8的内存⽤作影⼦内存,其他内存才是编译器分配给程序使⽤的,如图1。⼀个内存地址可以通过偏移量获取对应的影⼦地址,如ShadowAddr = (Addr >> 3) + Offt。
8bytes存在9状态(0 <= k <= 8),即前k个字节是可寻址的⽽后8 – k个字节是不可寻址的,这个状态可以编码进对应1byte的影⼦内存中,⽽明显1byte还有⾜够的可编码空间(还有256 – 9余量),如1byte的值为负数时表⽰8bytes是不可寻址且不同负数值表⽰不同的不可寻址类型(如已经释放内存、已退栈的局部变量等)。
图1 内存投影
如当操作⼀个4字节内存区时,地址为Addr,编译器实际会在编译时插⼊类似这样的⼀段代码指令:
ShadowAddr = (Addr >> 3) + Offt;
if (*ShadowAddr != 4)
ReportAndCrash(Addr);
ReportAndCrash可以是⼀个函数或者⼀个硬件中断。值得⼀提的时,编译插桩是安排在编译的最后阶段,意味着这阶段的代码已经是优化后的,有些潜在的内存问题会因为优化⽽发现不了(关闭编译器优化)。
3 替换运⾏库(Run-time Library)
加上地址消毒选项的编译器会将默认的运⾏库(如glibc)替换为ASAN版本的运⾏库,运⾏库主要⽤于管理影⼦内存,另外是替换了malloc和free等内存管理相关的函数,⽤于堆内存的监控。
Malloc每分配⼀块内存,运⾏库实际上会多分配⼀些区域(红区,redZone),n块内存存在n + 1个红区,如图2。
图2 redZone
这些红区⽤于malloc保存内部数据,如线程ID、内存块⼤⼩等信息,因此每块红区设计最⼩为32bytes。这些红区已经被“下
毒”了,即红区对应的影⼦内存的byte都写为负数状态,应⽤程序代码⼀旦踩到红区的内存会报错。
当free⼀块内存时,这整块内存都会被“下毒”,且这个内存放在⼀个“隔离区”,未来⼀段时间内该内存不会再被malloc分配出去。⽬前该隔离区实现为⼀个FIFO,具有⼀个固定的总内存⼤⼩。
运⾏时库主要提供malloc/free等内存申请释放操作,动态加载后,会接管应⽤程序中的malloc/free等操作,malloc时会在应⽤程序分配内存前后增加redzone内存(成为红区)标记为“中毒”状态,⽽释放的内存则会被隔离起来(暂时不会分配出去)且也会被标记为“中毒”状态,后续如果访问中毒位置,则会被认为是越界访问;
5 漏报
存在以下⼏种情况,ASAN会检测不出来⽽漏报:
(1)不对齐寻址
int *a = new int[2]; // 8-aligned
int *u = (int*)((char*)a + 6);
*u = 1; // Access to range [6-9]
对于上述代码,由于ASAN的8:1地址投影特性,地址a + 1和地址u的影⼦地址是⼀样的,⽽a + 1是可寻址4字节的,因此即使u溢出了2字节也检测不出来。
(2)越界太离谱
由于红区的⼤⼩有限(⼀般为128bytes),访问越界太离谱⽽跨过红区⽽踩到别的有效内存,这种情况会漏报。
(3)隔离区溢出
ASAN运⾏库的内存隔离区(FIFO)⼤⼩有限,如256M,⽆法记录所有的已释放内存(太旧的、太⼤的),在操作已释放内存时,可能漏报,如以下代码:
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; // "u". May land in ’c’
(4)类/结构体内部(PODs)
避免破环结构体内存布局的向下兼容性,类和结构体内部的成员变量之间不设红区,因此结构体内变量的溢出将不会被检测到,该问题在gcc的4.8版本有效,不知其他版本有没解决优化。如以下代码:
struct S { char a[4]; int b; }
S s;
s.a[5] = 0;
(5)其他
对于memcpy的dest和src是在同⼀个malloc的内存块中时,内存重叠的情况⽆法检测到。
对于有些u-after-return,如访问已经退栈的内存,不能检测。
⽆法发现“操作未初始化”的问题,不过这个编译器原本就可以检测。
⼀些显⽽易见的访问⽆效内存操作可能会被编译器优化⽽会漏报。