简介

目前,常见的劫持内核执行流有三种方式:(1)修改函数返回地址;(2)修改函数指针;(3)修改系统调用表/异常向量表。 由于内核普遍开启CC_STACKPROTECTOR_STRONG配置,单一的栈溢出漏洞很难修改函数返回地址。 同时,系统调用表/异常向量表的内存属性为只读,想要修改其中项,需要先修改相应页表属性。 相比之下,修改函数指针比较容易,搭配代码重用攻击(例如ROP),攻击者可以在内核执行自己的代码。

KCFI通过在函数指针调用前进行相应的检查,来防止其被攻击者篡改。下面先通过一个用户态程序例子,对CFI 做一个简单的介绍,然后在此基础上分析Android的KCFI。

基于Clang的CFI分析

首先,用户态的程序实现了以下5个函数


图1:用户态程序定义的函数

从图1中可以看出,这5个函数根据函数签名可以分为两类:


图2:函数分类

Clang在编译该程序时,会根据函数签名生成对应的函数跳转表:


图3:int_arg_fn类跳转表


图4:int_arg1_fn类跳转表

需要注意的是:(1)Clang只会生成函数指针对应的函数签名的跳转表。 (2)源程序中int_argN函数被重命名为int_argN_cfi,而int_argN指向相应的跳转表项。

主程序根据select的值选择要执行的函数:


图5:用户态程序main函数

与case0相对应的反汇编代码如下:


图6:case0对应的反汇编

以case 0为例,在调用int_arg5函数前,编译器插入了相应的检查代码:


图7:检查对应的反汇编

其含义是先查看函数指针跳转的地址是否是8字节对齐(跳转表中每一项占8字节), 然后检查函数指针指向的地址是否在相应的跳转表内。如果检查失败, 调用__ubsan_handle_cfi_check_fail_abort函数。 该函数的主要功能是打印相关错误信息,终止程序运行。 传入该函数的第一个参数Data包含了如下内容:


图8:Data结构

从图8可以看出,Data保存了具体的错误信息:比如错误发生在哪个文件等。

运行该程序:$ ./my_cfi_icall,会得到以下结果:


图9:运行结果

从这个例子可以看出:在开启CFI之后,Clang会根据函数签名生成跳转表。 在函数指针跳转前,检查该指针是否指向跳转表中的项。 如果不在范围内或者没有按照要求对齐,程序会跳转到__ubsan_handle_cfi_check_fail_abort函数, 打出错误信息后,程序退出。

KCFI

KCFI实现了对内核以及模块进行CFI检查。 为了便于理解,本节先分析未开启MODULES配置时,KCFI如何工作, 然后分析KCFI如何对模块进行检查。

未开启MODULES配置时,KCFI实现方式类似于用户态。 以check_flags函数为例,内核编译完成后,会生成相应的跳转表:


图10:check_flags类跳转表

在调用check_flags函数指针前,内核会对其指向的地址进行检查:


图11:函数指针跳转前检查

如果检查失败,内核会调用brk指令。该指令实际是内核BUG()实现:


图12:BUG实现

所以我们可以理解为:如果检查失败,内核会执行BUG()。

如果开启MODULES配置,在模块加载时,会调用cfi_init函数 (load_module() -> post_relocation() -> cfi_init()):


图13:cfi_init函数

该函数会将模块自己实现的__cfi_check函数的地址注册到mod->cfi_check

在模块的函数指针跳转之前,会调用cfi_slowpath_handler函数对指针进行检查:


图14:cfi_slowpath_handler

首先通过find_cfi_check函数找到对应的检查函数的函数指针,如果该指针不为NULL,则调用该检查函数。 如果该指针为NULL,则认为被检查的函数指针非法。 这里有一个问题:check函数指针被调用前,需要被检查吗? 如果检查,会产生无限嵌套:ptr -> cfi_slowpath_handler() -> check -> cfi_slowpath_handler() -> check …… 所以,check函数指针不会被检查。

find_cfi_check函数查找对应的检查函数有两种方式,在没有开启CFI_CLANG_SHADOW时, 该函数通过find_module_cfi_check函数查找:


图15:find_module_cfi_check

从图15可以看出,该函数返回了mod->cfi_check函数指针(cfi_init函数对其初始化)。 这种方式性能消耗比较高,为此,KCFI实现了CFI_CLANG_SHADOW配置(见arch/Kconfig中CFI_CLANG_SHADOW描述)。

SHADOW的核心数据结构是cfi_shadow


图16:cfi_shadow

cfi_shadow分为两部分:头部包含了模块的整体信息,比如module_addr_minmodule_addr_max等。 尾部是shadow数组,该数组用来快速查找对应的检查函数:


图17:根据函数指针查找检查函数

首先,函数指针通过ptr_to_shadow函数计算对应的数组下标:


图18:ptr_to_shadow

对应的数组项中保存了检查函数所在页对应的数组下标。 比如:检查函数的起始地址通过ptr_to_shadow函数计算后得到的下标值为3, 则函数指针对应的数组项中的值为3。如果没有对应的检查函数,则项中的值为0xFFFF。 在实际运行中,通过查询shadow,可以快速找到对应的检查函数:


图19:shadow_to_ptr

这种实现方式有一个限制:检查函数的起始地址必须页对齐。 在ptr_to_shadow函数中右移操作(>>PAGE_SHIFT)丢失了页内偏移。 在恢复地址时,shadow_to_ptr函数只是简单的左移(<<PAGE_SHIFT),并没有恢复页内偏移。 所以为了使内核正常运行,检查函数的起始地址必须页对齐。

在开启CFI_CLANG_SHADOW配置时,cfi_shadow指针以及shadow数组成为潜在的攻击面。 攻击者可以通过修改数组项的值来绕过CFI检查。为了防止修改,KCFI在更新shadow后,将其设置为只读:


图20:shadow只读

攻击者并非无法修改shadow:在开启MODULES配置后,内核会生成所有的函数签名对应的函数跳转表,其中包含了set_memory_rw


图21:包含set_memory_rw的函数跳转表

如果攻击者可以控制一个与其签名一致的函数指针,可以使其指向该函数,通过构造合适的参数,即可将shadow/跳转表的内存属性设置改为可写,为后面的篡改打开大门。

在没有开启CFI_CLANG_SHADOW配置时,mod->cfi_check函数指针是潜在的攻击面。因为该函数指针本身可写,更关键的是,该函数指针不会进行CFI检查。攻击者可以通过修改该指针来绕过CFI检查。

总结

本文主要对KCFI的实现方式以及潜在的攻击面进行了分析。 截止目前,没有手机开启KCFI。 但谷歌称今年(2018)晚些时候会有开启该配置的手机与大家见面,不出意外的话,应该是Pixel 3(XL)。



Published

17 September 2018

Tags