AddressSanitizer 漏洞检测技术剖析
2022-10-8 15:7:22 Author: mp.weixin.qq.com(查看原文) 阅读量:3 收藏

  类似AFL之类的Fuzzing技术不断强大的一个核心原因就是漏洞检测能力的不断增强,作为AFL这款经典工具的核心,ASAN的漏洞检测核心能力值得关注。

ASAN简介

  ASAN其实本身是作为LLVM项目的一部分存在于Clang里面,其作用就是一个强大的内存错误检测器,它由一个编译插桩模块和一个运行时库组成,据官网介绍其可以检测以下类型的漏洞:

  1. 1. Out-of-bounds accesses to heap, stack and globals.

  2. 2. Use-after-free

  3. 3. Use-after-return

  4. 4. Use-after-scpe

  5. 5. Double-free, invalid free

  6. 6. Memory leaks (experimental)

  7. 7. initialization order checking

  ASAN的使用方法非常简单,在进行clang编译的时候加上-fsanitize=address参数,这样ASAN的run time library就会被链接到可执行文件里面,但是ASAN并不支持对于共享库的链接。显而易见的是使用ASAN会导致性能降低,因此需要配合clang的一些优化参数,关于这一部分本文只做简单的使用示范不追究其原理,因为作者在性能优化这块就是个彩笔。

      ASAN官方Demo:

int main(int argc, char **argv) {
  int *array = new int[100];
  delete [] array;
  return array[argc];  // BOOM
}
// clang++ -O1 -g -fsanitize=address -fno-omit-frame-pointer example_UseAfterFree.cc

  如果ASAN检测到一个bug之后就会把相关的信息打印出来,同时ASAN也会直接退出,这是因为:

  1. 1. 这使得ASAN在编译插桩阶段产生更小更快的代码。

  2. 2. 一旦产生内存异常,程序就会进入inconsistent state(大致意思就是跟原来程序员预想的状态不同),这就会导致如果不终止ASAN就可能其在接下来的运行中产生误报。

  这就是ASAN的基本用法了,关于使用可以看参考链接。

以下漏洞检测中,如果是简单常用的漏洞类型就不针对漏洞原理进行介绍,可以自行查找资料。

ASAN算法

  ASAN主要是检测内存,所以其算法也主要是对内存操作,因此对于ASAN来说,其第一步要做的就是接管目标的内存管理。ASAN的具体做法是通过runtime library替代原有的malloc和free,同时将malloc分配的内存周围的区域标记为red-zones(red-zones内存状态被称为为(poisoned)中毒状态),同时将free掉的内存单独隔离并标记为中毒状态,并且每一次程序访问内存的操作都会被修改为如下:

编译前:

*address = ...;  // or: ... = *address;

编译后:

if (IsPoisoned(address)) {
  ReportError(address, kAccessSize, kIsWrite);
}
*address = ...;  // or: ... = *address;

那么此时会存在一些问题:

  1. 1. 如何快速实现IsPoisoned?

  2. 2. 如何更好的输出错误?

  3. 3. 所有的内存访问都应该被检查吗?(本文核心关注点)

  ASAN官方专门回答了第三个问题,根据官方的解释,ASAN不应该插桩所有的内存访问,因为在程序的运行过程当中需要大量访问相同位置的内存,如下:

void inc(int *a) {
  (*a)++;
}

  此时同时存在对同一个地址的访问和存储操作,事实上对于内存访问错误,只用检测其中的一次操作就够了,而像下面的代码逻辑:

if (...)
  *a = ...
*a = ...

或者:

*a = ...
if (...)
  *a = ...

  其实都是只用检测一次内存访问就够了,还有循环之类的操作,其实没必要对循环内的每一次内存访问全部都插桩处理,还有很多其它的优化情况比如变量的数据流传递过程中,没必要对未发生实际变量内存地址改变的情况下对每次一关于该变量的内存访问都做检查,又或者对于全局常量的内存访问检查很可能是没有意义的。根据官方解释,这些优化目前还没有完全应用到ASAN,有兴趣的可以自行探索一下。

  简单说一下ASAN的优化思路之后回到其内存管理,ASAN会将全部的虚拟内存分为两大部分:

Main application memory:这块内存主要用于程序常规的内存分配。

Shadow memory:该内存区域保存着一些元数据,假如Main mem里面的某一个bit的数据被标记为中毒状态,那么在对应的Shadow memory里面都有所记录。

  这两种内存相互配合,因此一旦Main mem里面有内存被标记,那么对应的Shadow memory应该被快速计算出来。

shadow_address = MemToShadow(address);
if (ShadowIsPoisoned(shadow_address)) {
  ReportError(address, kAccessSize, kIsWrite);
}

上面代码的意思应该是不允许存在多次中毒标记同一地址。

  Main mem和Shadow memory之间的映射关系是8字节的Main mem对应1字节的Shadow memory,这一点应该很好理解,存在这样一种机制的核心作用还是确定那些内存是可访问的,那些内存是不可访问的,关于具体的映射细节可以看这里,非常简单。

  接下来介绍,ASAN是如何报告错误的:

复制内存异常地址到rax(eax),execute ud2 (generates SIGILL) SIGILL是一个signal信号,当处理器遇到非法指令的时候就会发出该信号。该信号中断进程并进行core dump。

  用一个字节编码异常地址访问类型和大小,全部的三个步骤大概需要5-6字节的机器码。通过上述内容已经基本了解堆内存的管理办法,那么栈内存该如何处理呢?

Demo:

void foo() {
  char a[8];
  ...
  return;
}

编译插桩后:

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;
}

其处理办法也是类似的,将程序中分配的栈空间周围内存进行标记来观察接下来的代码访问过程中是否会存在内存越界操作。

  在整个漏洞检测中除了内存监控算法之外,还有一个比较重要的就是call stack算法,关于call stack,ASAN主要收集以下三个事件相关的stack:

  1. 1. malloc and free

  2. 2. Thread create

  3. 3. Failure

  对于ASAN来说,其收集stack trace相关的信息是利用了LLVM项目里面的另一个工具llvm-symbolizer,llvm-symboilzer的作用是从命令行接收目标文件名和地址,然后打印地址对应的源码位置到标准输出。ASAN利用llvm-symboilzer可以将地址全部符号化,从而实现对stack trace的符号化记录,因此在report error的时候就可以看到更多详细信息。

到此关于ASAN中内存相关的基础算法介绍结束,下面主要剖析具体漏洞类型的检测。

漏洞检测

OOB

  通过对上述算法的了解我们就能知道OOB的检测来源于ASAN中的red zones算法。

UAF

  其实在上面的基本算法介绍完了之后就应该明白其UAF的检测原理,每一次的free之后,ASAN并不会直接释放内存,而是对其进行标记和隔离,那么下一次对释放内存进行访问时就可以被监视到,然后输出错误报告。

UAR

  默认条件下ASAN并不检测这个bug,这种类型的漏洞其实也很少被提及,可能是利用条件比较苛刻的原因(个人猜测),可以看下官方demo:

// 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];
}

  对于这种漏洞的检测,ASAN其实采用的也是类似heap uaf的做法,但是在具体的实现方法上存在的差别还是很大的。对于栈帧比较了解的人应该清楚,一旦一个函数return,那么它的栈就会被回收然后在下一次栈分配的时候被重复利用,如此来看通过red-zones类似的方法显然是行不通的,ASAN的做法是将栈迁移到堆上:未迁移前:

void foo() {
  int local;
  escape_addr(&local);
}

迁移后:

void foo() {
  char redzone1[32];
  int local;
  char redzone2[32+28];
  char *fake_stack = __asan_stack_malloc(&local, 96);
  poison_redzones(fake_stack);  // Done by the inlined instrumentation code.
  escape_addr(fake_stack + 32);
  __asan_stack_free(stack, &local, 96)
}

  __asan_stack_malloc(real_stack, frame_size)函数会从fake stack(ASAN实现的一个thread-local heap-like structure)分配一个大小为framz_size的fake frame,所有的fake frame都来自未被标记为中毒状态的内存,但是如果被使用(如上demo)就会被poison_redzones标记。__asan_stack_free(fake_stack, real_stack, frame_size)函数则会将所有的fake frame标记为中毒状态并进行释放。那么如果存在UAR的时候会因访问被标记为中毒的内存而被检测出异常。

  从上面可以看出这种检测方法还是挺消耗内存的,fake stack 分配器会为每个线程分配固定大小的内存,大小从2的6次方到2的16次方字节不等,每个线程对应的内存也会被分成一定数量的chunk,如果chunk被用完,那么接下来的栈分配就会使用程序原本的stack,此时的UAR检测也会实效,因此越好的检测效果就代表越高的内存消耗。

UAS

  UAS同样知名度不高,先看官方Demo:

// 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;
}

  大致意思就是作用域内定义的变量在作用域外被访问,ASAN检测这种漏洞的办法是随着程序的执行流不断的标记被局部变量使用的内存,当执行流到达一个作用域的时候,相关局部变量的内存被标记为good,当执行流到达一个作用域的结尾时,相关内存被标记为bad,看下面的demo:

编译前:

void f() {
  int *p;
  if (b) {
    int x[10];
    p = x;
  }
  *p = 1;
}

编译后:

void f() {
  int *p;
  if (b) {
    __asan_unpoison_stack_memory(x);
    int x[10];
    p = x;
    __asan_poison_stack_memory(x);
  }
  *p = 1;
   __asan_unpoison_stack_memory(frame);
}

  因为栈是会被复用的,所以在函数return之前必须将相关内存取消中毒标记。

Double free and invalid free

参考UAF。

Memory leaks (experimental)

  试验级别的先不说,ASAN专门集成了LeakSanitizer来研究这类漏洞的检测,可以参考这里

initialization order checking

  Static initialization order fiasco,这在C++程序静态全局变量初始化过程中很常见。但是这种漏洞其实比较难以检测,因为C++静态全局变量的初始化出现在Main函数执行之前。至于漏洞模型,其实也很简单,假设在A.cpp和B.cpp里面分别存在两个全局静态类C和D,假设D在初始化过程中依赖C中的某些方法但是D初始化在C之前,那么就可能会导致crash。

官方demo:

$ 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();

  官方demo表明假如foo先初始化,那么就会输出43,否则就会输出1,间接表明了初始化顺序可能导致的一些安全问题。

  ASAN对于这里漏洞的扫描默认是关闭的,可以参考这里开启,它的检测方式分为很多种:

Loose init-order checking

  ASAN的这个检测方式很简单,就是在一个全局变量初始化过程中访问另一个全局变量之前检测要访问的全局变量是否已经完成初始化,但是很明显,这种动态检测在上述demo输出43的时候不会报告错误。

Strict init-order checking

  这个只是相对于Loose init-order checking更为严格了,只要进行访问就报告错误,这虽然能发现潜在的错误,但是也可能会触发误报。所以其实这两种方法各有千秋。为了解决这些问题,ASAN的此类漏洞扫描存在黑名单机制,把不想扫描的全局变量可以加入Blacklist来防止误报,但是可能会让漏洞研究人员多花点心思。

参考链接

https://clang.llvm.org/docs/AddressSanitizer.html

https://github.com/google/sanitizers/wiki/AddressSanitizer

https://isocpp.org/wiki/faq/ctors#static-init-order


文章来源: https://mp.weixin.qq.com/s?__biz=Mzg3NzczOTA3OQ==&mid=2247485751&idx=1&sn=912bbcd7ff29b312ccbbd4ae02c859ac&chksm=cf1f241ff868ad09c1b32846a65553fddb223b944f0f2a71c6cc38cf5d7d359fb1214b1c2847&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh