Google Address Sanitizer

Google Address Sanitizer

Address Sanitizer是google的C/C++内存地址错误检查器。其在编译时和运行时发挥作用,已被集成进了各大编译器之中。它采用了CTI(Compile Time Instrumentation)技术,即在编译时进行代码插入,运行速度快,比传统的Valgrind等工具速度上要快一个数量级。它们的输出信息都非常详细,方便快速地定位问题。AddressSanitizer除了可以发现堆上内存越界外,还可以检查到栈及全局变量的越界访问,这是很多内存检查工具无法做到的。

官网地址

google/sanitizers

介绍

Sanitizer 项目,该项目是谷歌出品的一个开源项目,该项目包含了 ASAN、LSAN、MSAN、TSAN等内存、线程错误的检测工具,这里简单介绍一下这几个工具的作用:

  1. ASAN: 内存错误检测工具,在编译命令中添加-fsanitize=address启用
  2. LSAN: 内存泄漏检测工具,已经集成到 ASAN 中,可以通过设置环境变量ASAN_OPTIONS=detect_leaks=0来关闭ASAN上的LSAN,也可以使用-fsanitize=leak编译选项代替-fsanitize=address来关闭ASAN的内存错误检测,只开启内存泄漏检查。
  3. MSAN: 对程序中未初始化内存读取的检测工具,可以在编译命令中添加-fsanitize=memory -fPIE -pie启用,还可以添加-fsanitize-memory-track-origins选项来追溯到创建内存的位置
  4. TSAN: 对线程间数据竞争的检测工具,在编译命令中添加-fsanitize=thread启用

根据检测结果显示可能导致性能降低2倍左右,比Valgrind(官方给的数据大概是降低10-50倍)快了一个数量级。

而且相比于Valgrind只能检查到堆内存的越界访问和悬空指针的访问,ASAN不仅可以检测到堆内存的越界和悬空指针的访问,还能检测到栈和全局对象的越界访问

这也是 ASAN在众多内存检测工具的比较上出类拔萃的重要原因,基本上现在 C/C++ 项目都会使用ASAN来保证产品质量,尤其是大项目中更为需要。

Asan 处理范围

  • Use after free(dangling pointer dereference):内存释放后继续使用,悬挂指针问题。
  • Heap buffer overflow:堆内存溢出
  • Stack buffer overflow:栈内存溢出
  • Global buffer overflow:全局内存溢出(如全局变量)
  • Use after return:局部变量在函数返回后使用
  • Use after scope:局部变量在作用范围外使用
  • Initialization order bugs:初始化顺序问题
  • Memory leaks:内存泄漏

ASAN是一个执行速度非常快的工具,典型的程序在加上ASAN后,执行时间只会增加1倍。
ASAN工具由一个编译器插桩模块(当前实现为LLVM的一个pass)和一个运行库(替换malloc函数等)组成。

  1. Use after free (dangling pointer dereference):使用已释放的堆内存
// RUN: clang -O -g -fsanitize=address %t && ./a.out
int main(int argc, char **argv) {
  int *array = new int[100];
  delete [] array;
  return array[argc];  // BOOM
}
  1. Heap buffer overflow:堆内存溢出
// RUN: clang -O -g -fsanitize=address %t && ./a.out
int main(int argc, char **argv) {
  int *array = new int[100];
  array[0] = 0;
  int res = array[argc + 100];  // BOOM
  delete [] array;
  return res;
}
  1. Stack buffer overflow:栈内存溢出
// RUN: clang -O -g -fsanitize=address %t && ./a.out
int main(int argc, char **argv) {
  int stack_array[100];
  stack_array[1] = 0;
  return stack_array[argc + 100];  // BOOM
}
  1. Global buffer overflow:访问的区域是全局变量, 并且超过了分配给它的空间
// RUN: clang -O -g -fsanitize=address %t && ./a.out
int global_array[100] = {-1};
int main(int argc, char **argv) {
  return global_array[argc + 100];  // BOOM
}
  1. Use after return:默认不开启, 指: 函数在栈上的局部变量在函数返回后被使用
// RUN: clang -O -g -fsanitize=address %t && ./a.out
// By default, AddressSanitizer does not try to detect
// stack-use-after-return bugs.
// It may still find such bugs occasionally
// and report them as a hard-to-explain stack-buffer-overflow.
 
// You need to run the test with ASAN_OPTIONS=detect_stack_use_after_return=1
 
int *ptr;
__attribute__((noinline))
void FunctionThatEscapesLocalObject() {
  int local[100];
  ptr = &local[0];
}
 
int main(int argc, char **argv) {
  FunctionThatEscapesLocalObject();
  return ptr[argc];
}
  1. Use after scope:使用作用域之外的变量
// RUN: clang -O -g -fsanitize=address -fsanitize-address-use-after-scope \
//    use-after-scope.cpp -o /tmp/use-after-scope
// RUN: /tmp/use-after-scope
 
// Check can be disabled in run-time:
// RUN: ASAN_OPTIONS=detect_stack_use_after_scope=0 /tmp/use-after-scope
 
volatile int *p = 0;
 
int main() {
  {
    int x = 0;
    p = &x;
  }
  *p = 5;
  return 0;
}
  1. Initialization order bugs:默认不开启,检查全局变量或静态变量初始化的时候有没有利用未初始化的变量
$ cat tmp/init-order/example/a.cc
#include <stdio.h>
extern int extern_global;
int __attribute__((noinline)) read_extern_global() {
  return extern_global;
}
int x = read_extern_global() + 1;
int main() {
  printf("%d\n", x);
  return 0;
}
 
$ cat tmp/init-order/example/b.cc
int foo() { return 42; }
int extern_global = foo();
  1. Memory leaks:内存泄漏,检查未释放的堆内存
$ cat memory-leak.c 
#include <stdlib.h>
 
void *p;
 
int main() {
  p = malloc(7);
  p = 0; // The memory is leaked here.
  return 0;
}
$ clang -fsanitize=address -g memory-leak.c
$ ./a.out 
  1. double free 错误
// example1.cpp
// double-free error
int main() {

    int *x = new int[42];
    delete [] x;

    // ... some complex body of code

    delete [] x;
    return 0;
}

使用

从LLVM3.1、GCC4.8、XCode7.0、MSVC16.9开始ASAN就已经成为众多主流编译器的内置工具了,因此,要在项目中使用ASAN也是十分方便。

注意
打开了调试标志-g,这是因为当发现内存错误时调试符号可以帮助错误报告更准确的告知错误发生位置的堆栈信息,如果错误报告中的堆栈信息看起来不太正确,请尝试使用-fno-omit-frame-pointer来改善堆栈信息的生成情况。
如果构建代码时,编译和链接阶段分开执行,则必须在编译和链接阶段都添加-fsanitize=address选项。

ASAN的使用需编译器支持。GCC、clang最简单的使用方式是增加编译选项:-fsanitize=address。更多选项可以看官方文档:

clang的使用方式:

AddressSanitizer — Clang 16.0.0git documentation

GCC的使用方式:

Instrumentation Options (Using the GNU Compiler Collection (GCC))

在 clang 和 gcc 中都实现了 Address Sanitizer。只需要编译的时候添加上

-fsanitize=address -fno-omit-frame-pointer

即可.

GCC4.8之后
• 引入了一个新的内存错误检测工具: AddressSanitizer。
使用选项-fsanitize=address能打开此检测器。 该检测器会对访存指令插装,帮助快速检测堆、栈以及全局的缓冲区溢出,以及use-after-free bug。 这个检测工具可以在Intel/PowerPC Linux系统,以及Intel Darwin上使用, ARM还不行。
• 引入了一个新的data race检测器: ThreadSanitizer。 使用选项 -fsanitize=thread能打开此检测器

CFLAGS += -fsanitize=address  -fno-omit-frame-pointer -fsanitize=leak -static-libasan
LDFLAGS += -fsanitize=address  -fno-omit-frame-pointer -static-libasan
编译,链接时都加上libasan选项。
  1. -fsanitize=address 是开启内存越界检测
  2. -fsanitize=leak 开启内存泄漏检测
  3. -fno-omit-frame-pointer 是去使能栈溢出保护(omit-frame-pointer开启该选项,主要是用于去掉所有函数SFP(Stack Frame Pointer)的,即在函数调用时不保存栈帧指针SFP,代价是不能通过backtrace进行调试根据堆栈信息了。通过去掉SFP,可以提高程序运行速度,达到优化程序的目的。如果要打开栈指针,使用 -fno-omit-frame-pointer )
  4. -static-libasan 是静态链接asan
    静态链接的好处是,在编译时gcc会处理好asan,避免动态链接的asan依赖报错

所以使用的时候,如果你的编译分两个命令,注意编译的时候要加-g -fsanitize标志。 然后链接的时候要加-fsanitize -static-libasan标志。举例如下:

gcc -c main.c -fsanitize=address -g
gcc main.o -o main -fsanitize=address -static -libasan

当然编译命令也可以一步到位如下

gcc main.c -o main -fsanitize=address -static-libasan -g

也可以在 CMakeLists.txt 中这么写:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address")

通过 -fsanitize 来选择开启哪个 sanitizer,选项包括:

  1. address 开启 AddressSanitizer
  2. leak 开启 LeakSanitizer
  3. thread 开启 ThreadSanitizer
  4. undefined 开启 UndefinedBehaviorSanitizer
  5. memory 开启 MemorySanitizer
    我们通常在 Debug 模式下使用 Sanitizers,这样方便定位出问题的代码位置

Visual Studio

C/C++ 擦除器 | 用于运行时 bug 检测的检测代码

备注:windows上目前不支持内存泄露检测。

ASAN 的基本原理

ASAN的内存检测方法与Valgrind的AddrCheck工具很像,都是使用shadow内存来记录应用程序的每个字节是否可以被安全的访问,在访问内存时都对其映射的shadow内存进行检查

但是,ASAN使用一个更具效率的shadow内存映射机制和更加紧凑的内存编码来实现,并且除了堆内存外还能检测栈和全局对象中的错误访问,且比AddrCheck快一个数量级。

ASAN由两部分组成:代码插桩模块和运行时库

代码插桩模块会修改代码使其在访问内存时检查每块内存访问状态,称为shadow 状态,以及在内存两侧创建redzone的内存区域
运行时库则提供一组接口用来替代malloc和free以及相关的函数,使得在分配堆空间时在其周围创建redzone,并在内存出错时报告错误。

影子内存(shadow memory)

Shadow Memory 姑且直译为影子内存。
为了说明影子内存,我们把程序正常运行使用的内存叫做常规内存
影子内存技术,就是使用额外的内存来存管理常规内存的分配和使用,这些额外的内存对于被检测程序不可见,因此叫影子内存。

每块常规内存都有对应的影子内存

常规内存分配和释放的时候,在对应的影子内存里记录该常规内存的属性信息,比如是否可访问,是否已经被释放。在每次访问常规内存之前,都先检查对应的影子内存,看看该常规内存是否可访问。

为了快速找到常规内存对应的影子内存,通常使用某种映射算法,实现常规内存地址到影子内存地址的映射

  1. 一种是查表
  2. 一种是用比例+偏移来直接映射。查表就是事先设置一个表,里面保存者常规内存和影子内存的对应关系。

malloc() 函数返回的地址通常至少 8 字节对齐。

所谓的shadow内存就是在应用程序的虚拟地址空间中预留一段地址空间,用来存储映射应用程序访问的内存块中哪些字节可以被使用的信息,这些信息就是shadow状态。其中每1个字节的shadow 内存,映射到8个字节的应用程序内存,因此,shadow状态可能有3种:

  1. 0: 表示映射的8个字节均可以使用
  2. k(1<=k<=7): 表示表示映射的8个字节中只有前k个字节可以使用,后面剩下的 8-k 字节是不可访问。
  3. 负值: 表示映射的8个字节均不可使用,且不同的值表示所映射不同的内存类型(堆、栈、全局对象或已释放内存)

这 8 个字节的常规内存的可否访问的状态,可以用一个字节的影子内存来编码保存。

也就是说,一个字节的影子内存,可以记录多个字节的常规内存的可访问信息,这样就可以按照一定的比例,使用较少的影子内存,记录较多的常规内存的信息。
适当的设置一个偏移值Offset,把影子内存放在合适的位置

ASAN使用带有比例和偏移量的直接映射将应用程序地址转换为其对应的shadow内存地址

shadow_address = (addr >> 3) + offset

假设max - 1是虚拟地址空间中的最大有效地址, 选取的 Offset应该满足如下约束
offset的值应选择为在启动时不被占用的从offset到offset+Max/8的区域。

  • 在 32 位 linux 系统中,虚拟地址空间为:0x00000000-0xffffffff,offset = 0x20000000(2^29)。
  • 在 64 位系统中,ofsset = 0x0000100000000000(2^44)。
  • 在某些情况下(例如,在 Linux 上使用 -fPIE/-pie 编译器标志)可以使用零偏移来进一步简化检测。

以下是 32 位 linux 系统中的地址空间分布

 0x10000 0000 ---------------
              |   HIGH      |
              |   MEMORY    |
  0x4000 0000 ---------------
              | HIGH SHADOW |
  0x2800 0000 ---------------
              | BAD REGION  |
  0x2400 0000 ---------------
              | LOW SHADOW  |
  0x2000 0000 ---------------
              | LOW MEMORY  |
  0x0000 0000 ---------------

虚拟地址空间被划分为高低两部分,每个部分的内存地址映射到相应的shadow 内存。
影子内存在整个地址空间的中间区域.

注意:将shadow内存中的地址进行映射会得到Bad 区域中的地址,Bad区域是被页面保护标记为不可访问的地址空间。

shadow映射方式可以推导为

(addr >> scale) + offset

的形式,其中scale是的取值范围是1~7,当 scale=N时,shadow内存占用虚拟地址空间的1/2^N, red-zone的最小大小为2N字节(保证malloc()的对齐要求)。shadow内存中的每个字节描述了2N个内存字节的状态并有2^N + 1个不同的值。

redzone

ASAN会在应用程序使用的堆、栈、全局对象的内存周围分配额外内存,这个额外的内存叫做redzone,redzone会被shadow内存标记为不可使用状态,当应用程序访问redzone内存时说明已经溢出访问了,此时,ASAN检测redzone的shadow状态后就会报告相应错误。readzone越大,检测内存下溢和上溢的范围越大。

代码插桩

ASAN 会在应用程序访问内存的位置进行插桩,对于访问完整8字节内存的位置,插入以下代码检查内存对应的 shadow 内存,以此判断是否访问异常:

ShadowAddr = (Addr >> 3) + Offset;

if (*ShadowAddr != 0)
  ReportAndCrash(Addr);

由于应用程序访问8字节的内存,因此,其映射的shadow 内存的存储值必须是0,表示该8字节内存完全可用,否则,报错。

应用程序对 1、2、或者 4 字节内存的访问要复杂一些,如果访问的内存块对应的shadow内存的存储值如果不是负数,且不为0,或者将要访问内存块超过了shadow 内存表示的可用范围,意味着本次将访问到不可使用的内存:

ShadowAddr = (Addr >> 3) + Offset;
k = *ShadowAddr;
if (k != 0 && ((Addr & 7) + AccessSize > k))
  ReportAndCrash(Addr);

需要注意的是,ASAN对源代码的插桩时机是在LLVM对代码编译优化之后,也就意味着ASAN只能检测 LLVM优化后幸存下来的内存访问,例如:被 LLVM优化掉的对栈对象进行访问的代码将不会被ASAN所识别。

同时,ASAN也不会对 LLVM 生成的内存访问代码进行插桩,例如:寄存器溢出检查等等。

另外,即使错误报告代码ReportAndCrash(Addr)只会被调用一次,但由于会在代码中的许多位置进行插入,因此,错误报告代码也必须相当紧凑。

目前 ASAN使用了一个简单的函数调用来处理错误报告,当然还有另一个选择是插入一个硬件异常

以下用 AddressSanitizer 的例子来说明 instrumentation。分别是 x64 环境里的 8 字节和 4 字节访问。

原本的函数是这样——

void foo(T *a) 
{
    *a = 0x1234;
}

8 字节访问

clang -O2 -faddress-sanitizer a.c -c -DT=long

插入代码以后是这样——

push %rax
mov %rdi,%rax    # %rdx是指针a
shr $0x3,%rax 
mov $0x100000000000, %rcx
or %rax,%rcx     # 取得a的影子内存地址
cmpb $0x0,(%rcx) # 判断影子内存的值是否为0(0表示可访问)
jne 23 <foo+0x23> # 不可访问,报错
movq $0x1234,(%rdi) # 否则,可访问,执行原赋值语句 *a = 0x1234;
pop %rax
retq
callq __asan_report_store8 # Error

4 字节访问

clang -O2 -faddress-sanitizer a.c -c -DT=int

插入代码以后是这样——

push %rax
mov %rdi,%    # %rdx是指针a
shr $0x3,%rax
mov $0x100000000000,%rcx
or %rax,%rcx
mov (%rcx),%al    # 取得影子内存的值
test %al,%al
je 27 <foo+0x27>   # 值为0,跳到原来的赋值语句
mov %edi,%ecx 
and $0x7,%ecx 
add $0x3,%ecx   # 取得被访问的常规内存的最后一字节相对于8字节对齐的偏移, 即(Addr & 7) + AccessSize
cmp %al,%cl # 和影子内存的值k比较
jge 2f <foo+0x2f> # 不可访问,报错
movl $0x1234,(%rdi) # 可访问,执行原赋值语句
pop %rax
retq
callq __asan_report_store4 # Error

运行时库

在应用程序启动时,将映射整个shadow内存,因此程序的其他部分不能使用它。BAD区域也是受保护的,应用程序也不能访问。

在 linux 操作系统中,shadow内存区域不会被占用,因此,映射总是成功的。但在 MacOS 中可能需要禁用地址空间布局(ASLR)。

另外,根据 GOOGLE 工程师介绍,shadow 内存区域的布局也适用于 windows 操作系统

启用 ASAN 时,源代码中的 malloc 和 free 函数将会被替换为运行时库中的 malloc 和 free 函数

malloc 分配的内存区域被组织为为一个与对象大小相对应的空闲列表数组。当对应于所请求内存大小的空闲列表为空时,从操作系统(例如,使用mmap)分配带有redzone的内存区域。n个内存块,将分配n+1个redzone:

| redzone-1 | memory-1 | redzone-2 | memory-2 | redzone-3 |

free 函数会将整个内存区域置成不可使用并将其放入隔离区,这样该区域就不会马上被 malloc 分配给应用程序

目前,隔离区是使用一个 FIFO 队列实现的,它在任何时候都拥有一定数量的内存

默认情况下,malloc 和 free 记录当前调用堆栈,以便提供更多信息的错误报告。 malloc 调用堆栈存储在左侧 redzone 中(redzone 越大,可以存储的帧数越多),而 free 调用堆栈存储在内存区域本身的开头

到这里你应该已经明白了对于动态分配的内存,ASAN是怎么实现检测的,但你可能会产生疑惑:动态分配是通过 malloc 函数分配redzone来支持错误检测,那栈对象和全局对象这类没有malloc分类内存的对象是怎么实现的呢?其实原理也很简单:

  • 对于全局变量,redzone 在编译时创建,redzone 的地址在应用程序启动时传递给运行时库。运行时库函数会将redzone设置为不可使用并记录地址以供进一步错误报告。
  • 对于栈对象,redzone是在运行时创建和置为不可使用。目前,使用32字节的 redzone。例如以下代码片段:
void foo() {
  char a[10];
  <function body> 
}

经 ASAN 处理后的代码大致如下:

void foo() {
  char rz1[32]
  char arr[10];
  char rz2[32-10+32];

  unsigned * shadow = (unsigned*)(((long)rz1>>8)+Offset);

  // 将 redzone 设置为不可使用
  shadow[0] = 0xffffffff; // rz1
  shadow[1] = 0xffff0200; // arr and rz2
  shadow[2] = 0xffffffff; // rz2

  <function body>

  // 将所有内存设置成可以使用
  shadow[0] = shadow[1] = shadow[2] = 0; 
}
总结

ASAN 使用shadow内存和redzone来提供准确和即时的错误检测。

传统观点认为,shadow内存和redzone要么通过多级映射方案产生高开销,要么占用大量的程序内存。但,ASAN的使用的shadow映射机制和shadow状态编码减少了对内存空间占用。

最后,如果你觉得ASAN插桩代码和检测的对你某些的代码来说太慢了,那么可以使用编译器标志来禁用特定函数的,使ASAN跳过对代码中某个函数的插桩和检测, 跳过分析函数的编译器指令是:

__attribute__((no_sanitize_address))

参考资料

AddressSanitizer&ThreadSanitizer原理与应用
Sanitizers 系列之 address sanitizer 用法篇
内存检测工具AddressSanitizer
Google Address Sanitizer 学习

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值