Android丨如何解决CFI检测报错

766 阅读11分钟

在Android的语境里,CFI有两种含义。一种叫作"Call Frame Information",是DWARF调试信息里的内容,主要用于回溯调用栈时找到每一帧的地址。另一种叫作"Control Flow Integrity",是Clang中引入的一种安全机制,通过指令插桩来防止某些恶意攻击。这里我们讨论的CFI属于第二种情况。

按理说,这种安全机制离我们应该很远,不了解也无所谓。但实际情况却是,开启了CFI的模块多少都遇到过CFI的检测报错。奇怪,在我们的测试环境里又没人恶意攻击,那这些检测报错到底是什么意思呢?下面列举几个常见的CFI的错误示例。

signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr --------
backtrace:
      #00 pc 0000000000136abc  /vendor/lib64/libA.so (__cfi_check_fail+24)
      #01 pc 00000000000167bc  /vendor/lib64/libB.so (funcB()+120)
signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr 0x0000007405daf1ec
backtrace:
      #00 pc 000000000000f1ec  /vendor/lib64/lib2A.so (__cfi_check+492)
      #01 pc 00000000000de3ac  /apex/com.android.runtime/bin/linker64 (__dl__ZN15CFIShadowWriter7CfiFailEmPvS0_S0_+140)
      #02 pc 00000000000cdb44  /apex/com.android.runtime/bin/linker64 (__loader_cfi_fail+72)
      #03 pc 00000000000041d0  /apex/com.android.runtime/lib64/bionic/libdl.so (__cfi_slowpath+68)
      #04 pc 0000000000011d40  /vendor/lib64/lib2B.so (func2B(int, void*) (.cfi)+816)
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x0000007b94000018
backtrace:
      #00 pc 00000000000041a8  /apex/com.android.runtime/lib64/bionic/libdl.so (__cfi_slowpath+28)
      #01 pc 0000000000011d40  /vendor/lib64/lib3A.so (func3A(int, void*) (.cfi)+816)

这些错误中,从最终信号类型来看,有SIGTRAP,也有SIGSEGV,且最终的CFI调用栈也不尽相同。但如果深究本后的成因,会发现它们是同源的。

下面来介绍下CFI的检测目标和整体流程。

在C++的世界里,函数跳转分为直接跳转和间接跳转。直接跳转表示跳转目标在编译期间就已经确定,编译器会把目标函数的地址或者相对偏移写入跳转指令,因此这类跳转很难被攻击。而间接跳转指的是跳转目标需要等到运行时才能确定,对应的跳转指令通常使用寄存器作为目标地址,因此只要我们有办法更改内存中的值,就可以通过间接跳转改变程序的执行逻辑。

所以CFI所需要保护的,正是间接跳转。具体来说,它会对三种情况做检测:

  1. 通过vtable进行虚函数跳转。
  2. 通过函数指针进行跳转。
  3. 通过static_cast、reinterpret_cast进行类型转换。

之所以增加第三种情况,原因有两个:

  1. 前两种只检测跳转目标的合法性,但不检测this对象。而非法的this对象依然可以篡改执行流。
  2. 它可以在造假的源头就把问题报出来,而不用等到vcall(虚函数)和icall(函数指针)再报出来。

介绍完CFI的检测目标,我们再来看看它的检测流程。先假设跳转只发生在单个DSO(Dynamic Shared Object,譬如Linux环境里的so和Windows环境里的dll)内部。【这一段原理较枯燥,不感兴趣的可以跳过单DSO部分】

单DSO的情况

首先是虚函数的检测。 它是所有检测里最复杂的,根据vtable的结构不同,又分为OVT(Ordered VTables)和IVT(Interleaved VTables)两个流派。OVT和IVT都会保证一个类和其子类的vtable在内存中连续,但区别在于OVT中每个vtable都是一个整体,而IVT会将vtable拆散重排。

IVT相较于OVT虽然能够节省约25%的性能开销和70%的体积开销,但是对于vtable的结构改动可能会产生诸多兼容性问题,因此Clang的主线依然采用OVT方案。

我们假设有这样三个类:

struct A {
  virtual void f1();
  virtual void f2();
  virtual void f3();
};
​
struct B : A {
  virtual void f1();
  virtual void f2();
  virtual void f3();
};
​
struct C : A {
  virtual void f1();
  virtual void f2();
  virtual void f3();
};

它们在OVT视角下的vtable排布如下:

VTable Layout

offset-to-top主要用来支持多继承;rtti则表示"Run-Time Type Information",它是一个指针,指向type_info对象,通过它我们可以在运行时得到类的名称和层级信息;之后跟的则是每个函数的具体地址。offset-to-top和rtti一般不怎么用,因此对象所存储的vptr实际上指向的是函数指针开始的区域,如上所示。

当我们拿着一个未知的vptr准备进行A::f2()虚函数跳转时,实际上会去检查这个vptr的合法性。检测分为两步进行:

  1. 检测vptr是否落在0~14的地址范围。这一步可以快速过滤掉一些vptr地址偏离较远的情况。
  2. 检测vptr是否能被当作A类来解析。Clang在编译期间会为每个类生成一个Bit Vector,我们以A来举例,只有指向地址2、7、12的vptr才是合法的,因为其他地址压根不是vptr该指向的位置。对B而言,只有指向地址7的vptr才是合法的,因为指向2的vptr表明对象的原类型为A,A是B的父类,它不能向下转换为B;而指向12的vptr表明对象的原类型为C,B和C之间共父,但二者不能互相转换。

VTable bit vectors

其次是函数指针的检测。 编译时会生成一系列跳板函数,这些跳板函数按签名聚合,也即相同参数返回值的跳板函数放在一起。这样一来,访问是否合法,只需要检测指针是否位于连续区间即可。以下是真实场景生成的检测代码,我们可以做个解释:编译期间可以得知函数指针的签名,根据它找到相符的跳板函数,共有N个,起始地址为0x15e40。由于跳板函数内部只有两条指令,共8个字节,所以可以右移3位(除8)计算出指针所在跳板函数的序号。如果序号小于N,那么就表明指向的跳板函数签名相同,否则需要进一步检查。

[检测代码]

adr x8, 0x15e40
sub   x8, x1, x8      // x8 = target_addr - base
ror   x8, x8, #3      // ÷8 得到 slot_index(假设函数对齐为8字节)
cmp   x8, #N          // N = 该组函数数量
b.lo  PASS            // 如果 slot_index < N,就直接通过

[跳板函数]

bti   c
b     0xf710

最后是类型转换的检测。 类型转换是否合法,在检测时等价为了vptr是否合法,因此检测方案和虚函数一致。

多DSO的情况

啰里八嗦说了一大堆,其实还没进入讨论的核心。上面的检测方案有个前提,即跳转只发生在单个DSO内部。但现实情况一定是多DSO,库之间跳来跳去的。当我们在A库里准备跳转到B库时,A库对地址的合法性是无法判断的,因此需要把检测交给B来做。这里分叉出两种情况:

  1. B库压根没有开启CFI,那么检测也就不需要了。换言之,所有对于B库的非间接跳转都是合法的。
  2. B库开启了CFI,它在编译期间会生成一个__cfi_check的函数,所有对于B库的非间接跳转都将交给它来判断合法性。

既然如此,那么就有一个问题,如何才能知道B库是否开启了CFI,以及如果开启了CFI,如何才能找到它的__cfi_check地址?

CFI Shadow

CFI开启时,内存中会映射出一块CFI Shadow Region的区域,任何指针都可以通过移位/对齐的操作找到它所对应的shadow value:

  1. 如果shadow value为Invalid(0),那么这个地址所指向的内存区域并非一个DSO。
  2. 如果shadow value为Unchecked(1),那么这个地址指向的DSO没有开启CFI检测。
  3. 如果shadow value为一个大于2的值,那么它可以通过一系列变换找到DSO内部的__cfi_check函数指针。既然是检测,那就涉及到两个东西的比较。因此__cfi_check除了传递vptr或funcptr以外,还会传递一个叫作CallSiteTypeId的东西,它是通过类型/函数签名计算得出的64位哈希值,编译时会内嵌到机器码中,这也是我们运行时所期望的类型或函数。

好的,说完这些原理性的东西,我们再回到开头的错误示例。

signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr --------
backtrace:
      #00 pc 0000000000136abc  /vendor/lib64/libA.so (__cfi_check_fail+24)
      #01 pc 00000000000167bc  /vendor/lib64/libB.so (funcB()+120)
signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr 0x0000007405daf1ec
backtrace:
      #00 pc 000000000000f1ec  /vendor/lib64/lib2A.so (__cfi_check+492)
      #01 pc 00000000000de3ac  /apex/com.android.runtime/bin/linker64 (__dl__ZN15CFIShadowWriter7CfiFailEmPvS0_S0_+140)
      #02 pc 00000000000cdb44  /apex/com.android.runtime/bin/linker64 (__loader_cfi_fail+72)
      #03 pc 00000000000041d0  /apex/com.android.runtime/lib64/bionic/libdl.so (__cfi_slowpath+68)
      #04 pc 0000000000011d40  /vendor/lib64/lib2B.so (func2B(int, void*) (.cfi)+816)
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x0000007b94000018
backtrace:
      #00 pc 00000000000041a8  /apex/com.android.runtime/lib64/bionic/libdl.so (__cfi_slowpath+28)
      #01 pc 0000000000011d40  /vendor/lib64/lib3A.so (func3A(int, void*) (.cfi)+816)

这些错误看似是CFI的检测报错,实则都是内存问题,或者更细致一点来说,是use-after-free的问题。当一个C++对象已经被释放,而我们继续使用它的vptr或者函数指针字段时,那么这时的vptr和函数指针可能为任意值,因为内存可能装填了新的数据。

系统在跨DSO的间接调用发生时,需要通过libdl.so里的__cfi_slowpath来读取shadow值,并根据shadow值的不同来决定后续的处理逻辑。

void __cfi_slowpath(uint64_t CallSiteTypeId, void* Ptr, void* DiagData) {
  uint16_t v = shadow_load(Ptr);
  switch (v) {
    case CFIShadow::kInvalidShadow:
      __loader_cfi_fail(CallSiteTypeId, Ptr, DiagData, __builtin_return_address(0));
      break;
    case CFIShadow::kUncheckedShadow:
      break;
    default:
      reinterpret_cast<CFIShadow::CFICheckFn>(cfi_check_addr(v, Ptr))(CallSiteTypeId, Ptr, DiagData);
  }
}

因此,上述的三份错误示例分别对应如下的三种情况。

CFI错误类型

  1. 当异常的vptr很离谱,看起来根本不是一个指针时,那么转换得到的CFI shadow指针可能落在不可读的区域(CFI Shadow Region并非所有区域都可读)。这时,读取shadow值的动作会直接触发SIGSEGV。
  2. 当异常的vptr转换得到的CFI shadow值为Invalid时,CFI会主动报错,并抛出SIGTRAP信号。
  3. 当异常的vptr转换得到一个不相关的DSO的__cfi_check时,接下来发生的事是未知的。但大概率会在一些读写指令时发生SIGSEGV的异常。

所以,实践中我们碰到CFI的检测报错,大概率不能将它当成CFI的错误来处理,而更应该审查原始C++对象是否已经被释放。如果代码逻辑清晰,那么业务方应该很容易定位出来;如果代码逻辑复杂,那么还得依赖HWASan和MTE等内存检测工具。

当然,CFI检测也会暴露一些真正的“安全”问题,譬如函数指针与真实目标之间某个参数类型不一致,访问虚函数时对象类型不匹配,等等。这些错误一眼就能看出,因此不必赘述。但还有一类情况需要特别提一嘴:当某个DSO被卸载后,它所对应的CFI shadow值会被置为Invalid。因此如果A库被卸载,但B库依然保留着指向A库的某个函数的指针时,一旦跳转就会发生CFI检测报错。

好了,CFI的内容说到这里也就差不多了,咱们下期再会!