在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所需要保护的,正是间接跳转。具体来说,它会对三种情况做检测:
- 通过vtable进行虚函数跳转。
- 通过函数指针进行跳转。
- 通过static_cast、reinterpret_cast进行类型转换。
之所以增加第三种情况,原因有两个:
- 前两种只检测跳转目标的合法性,但不检测this对象。而非法的this对象依然可以篡改执行流。
- 它可以在造假的源头就把问题报出来,而不用等到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排布如下:
offset-to-top主要用来支持多继承;rtti则表示"Run-Time Type Information",它是一个指针,指向type_info对象,通过它我们可以在运行时得到类的名称和层级信息;之后跟的则是每个函数的具体地址。offset-to-top和rtti一般不怎么用,因此对象所存储的vptr实际上指向的是函数指针开始的区域,如上所示。
当我们拿着一个未知的vptr准备进行A::f2()虚函数跳转时,实际上会去检查这个vptr的合法性。检测分为两步进行:
- 检测vptr是否落在0~14的地址范围。这一步可以快速过滤掉一些vptr地址偏离较远的情况。
- 检测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之间共父,但二者不能互相转换。
其次是函数指针的检测。 编译时会生成一系列跳板函数,这些跳板函数按签名聚合,也即相同参数返回值的跳板函数放在一起。这样一来,访问是否合法,只需要检测指针是否位于连续区间即可。以下是真实场景生成的检测代码,我们可以做个解释:编译期间可以得知函数指针的签名,根据它找到相符的跳板函数,共有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来做。这里分叉出两种情况:
- B库压根没有开启CFI,那么检测也就不需要了。换言之,所有对于B库的非间接跳转都是合法的。
- B库开启了CFI,它在编译期间会生成一个
__cfi_check的函数,所有对于B库的非间接跳转都将交给它来判断合法性。
既然如此,那么就有一个问题,如何才能知道B库是否开启了CFI,以及如果开启了CFI,如何才能找到它的__cfi_check地址?
CFI开启时,内存中会映射出一块CFI Shadow Region的区域,任何指针都可以通过移位/对齐的操作找到它所对应的shadow value:
- 如果shadow value为Invalid(0),那么这个地址所指向的内存区域并非一个DSO。
- 如果shadow value为Unchecked(1),那么这个地址指向的DSO没有开启CFI检测。
- 如果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);
}
}
因此,上述的三份错误示例分别对应如下的三种情况。
- 当异常的vptr很离谱,看起来根本不是一个指针时,那么转换得到的CFI shadow指针可能落在不可读的区域(CFI Shadow Region并非所有区域都可读)。这时,读取shadow值的动作会直接触发SIGSEGV。
- 当异常的vptr转换得到的CFI shadow值为Invalid时,CFI会主动报错,并抛出SIGTRAP信号。
- 当异常的vptr转换得到一个不相关的DSO的__cfi_check时,接下来发生的事是未知的。但大概率会在一些读写指令时发生SIGSEGV的异常。
所以,实践中我们碰到CFI的检测报错,大概率不能将它当成CFI的错误来处理,而更应该审查原始C++对象是否已经被释放。如果代码逻辑清晰,那么业务方应该很容易定位出来;如果代码逻辑复杂,那么还得依赖HWASan和MTE等内存检测工具。
当然,CFI检测也会暴露一些真正的“安全”问题,譬如函数指针与真实目标之间某个参数类型不一致,访问虚函数时对象类型不匹配,等等。这些错误一眼就能看出,因此不必赘述。但还有一类情况需要特别提一嘴:当某个DSO被卸载后,它所对应的CFI shadow值会被置为Invalid。因此如果A库被卸载,但B库依然保留着指向A库的某个函数的指针时,一旦跳转就会发生CFI检测报错。
好了,CFI的内容说到这里也就差不多了,咱们下期再会!