关注了就能看到更多这么棒的文章哦~
Control-flow integrity in 5.13
By Jonathan Corbet
May 21, 2021
DeepL assisted translation
https://lwn.net/Articles/856514/
在为 5.13 内核合并的许多改动中,有一项是增加了 LLVM control-flow integrity(CFI)机制的支持。CFI 通过检查确保间接函数调用(indirect function call)未被攻击者修改过,从而堵住系统漏洞。为了让这个功能在内核中正常用起来,需要做大量的工作。但最终结果看起来已经可以用在生产环境中了,能够用来保护 Linux 系统免受这一类的攻击。
Protecting indirect function calls
内核在很大程度上非常依赖间接函数调用,也就是那些在编译时尚未知道目标地址的函数调用。各种各样的设备驱动、文件系统和其他内核子系统跟那些通用的 core 代码对接的时候,都是通过提供可供调用的函数并在其中完成具体的工作。例如,当需要打开一个文件(也可能是一个跟设备对应的特殊文件节点)时,core kernel 的代码将采用间接调用的方式来跳转到最合适的那个 open()函数中去,该函数是定义在相关的文件中的 file_operations structure 里面。这些间接函数调用就帮助在通用代码和底层代码之间进行了清晰的隔离。
这种机制很灵活,一直工作得很好。不过,也使得那些间接调用成为一个吸引攻击者的攻击目标。如果某个间接调用可以被重定向来跳转到攻击者所指定的位置,那么就完全无法控制他干什么坏事了。多年来的许多改动已经使得攻击者很难将自己的代码注入内核,但如果他们可以强制指定一个地址去执行代码,那么就完全不受约束了。请注意,利用漏洞的时候并不是一定要跳转到另一个函数的开头位置,其实完全可以跳到内核代码中的任何位置。如果能攻破一处间接函数调用的位置的话,内核里有无数有吸引力的代码可供攻击者选择。
CFI 就是希望通过限制间接调用,让它们只能跳转到合理的目标位置,从而阻止这类攻击行为。在这种情况下所说的 "合理的" 位置意味着跳转到某个函数的开始地址,并且目标函数的原型(prototype)要跟调用者预期的 prototype 一致。这个判断条件并不完美,毕竟可能有一些具有相同原型的函数可能会对攻击者也是有用的。但这个措施已经能大大减少攻击者可利用的目标函数,通常这已经就足够了。
这种检查通常被称为 "forward-edge CFI",因为它保护了对函数的调用。相应的 "backward-edge" 类型的保护则确保了位于 stack 中的返回地址没有被篡改。在 5.13 合并的 patch 主要关注的是 forward-edge 类型的问题。
LLVM CFI in Linux
具体来说,这个 CFI 功能的实现是通过在链接的时候(link time)对整个 kernel image 进行检查来实现的。也就是说,必须要打开 link-time optimization 功能。LLVM 在每发现一个函数地址位置的时候,就会记下该函数及其原型。然后,它会将 "跳转表" 塞到最终构建好的内核中,其中针对每一类函数原型都有一项。举例来说,上面提到的 open()函数被定义为:
int (*open) (struct inode *inode, struct file *file);
内核中有许多符合此原型的函数,它们的地址都被放到某个 file_operations 结构中以供使用。LLVM 将把它们全部收集到一个跳转表中,这个跳转表基本上就是这些函数的地址列表。
下一步就是修改所有用到该函数地址的地方,并将这些内容修改为跳转表中的相应位置。所以像下面这样的赋值操作:
func_ptr = my_open_function;
将会将跳转表中的一个地址分配给 func_ptr。
最后,每当一个间接函数被调用时,就会去执行一个叫做 __cfi_check() 的特殊函数。这个函数收到的参数除了目标地址之外,还会有与被调用函数的原型相匹配的跳转表地址。它会检查目标地址是不是符合目标跳转表中的一项地址,是的话就从 list 中提取真正的函数地址然后跳转过去。如果目标地址不在跳转表内,那么缺省的行为就是将其视作攻击行为,马上让系统进入 panic。在 config 阶段可以选择一种另一种 permissive mode,也就是仅仅将这次错误记录下来。
Kernel-specific quirks
上面这种极端反应很可能是合理选择,但是如果 kernel 里面真有这样的情况,也就是内核对一个函数的 indirect call 跟正在使用的函数指针的原型不是完全匹配的,这肯定会是很烦人的一件事。其实,内核里面真有这样的情况。在 5.13 之前的内核中,list_sort() 的声明都是:
void list_sort(void *priv, struct list_head *head,
int (*cmp)(void *priv, struct list_head *a, struct list_head *b))
这里的 cmp 是一个比较函数,由调用者传入进来,并通过 indirect call 方式用来比较 list 中的每一项。在 list_sort() 里面,我们看到这一行:
a = merge(priv, (cmp_func)cmp, b, a);
这里的 cmp_func 指向的类型看起来几乎和 cmp()的原型一样,但是还是有差别的,就是两个 list_head 指针带有 const 标志了。这就导致函数的原型并不相同,于是在运行时就会导致 CFI 检查失败。这里所采用的 fix 方法是将 const 属性传递给 list_sort()的调用者,这样就不需要对函数指针进行类型转换了。然而,这需要在整个内核源代码库中改变 40 个独立文件中的调用它的代码。
还有一个有意思的特殊情况,这来自于跳转表是在 link time 建立的这个特点。这对于单一内核(monolithic)来说是可行的,可是 loadable module 都是单独链接的。这些 loadable module 中的 CFI 也是可以工作的,但是每个 module 都会有自己的跳转表。请记住,函数指针被替换成了指向跳转表的指针,因此,由于各个 module 都有各自独立的跳转表,因此它们也会得到不同的指针。换句话说,如果有两个指向同一个函数的指针,如果其中之一位于 loadable module 中的话,那么这两个指针的值可能会是不相同的。
大多数情况下都还是能正常使用的,毕竟对这两个不同指针的调用最终其实都会跳到同一个地方去。但是 __queue_delayed_work() 中的这一行就不行了:
WARN_ON_ONCE(timer->function != delayed_work_timer_fn);
这一行检测代码是在 2012 年添加到 3.7 内核中的,用来 "检测是否有一些使用了 delayed_work 的地方会对内部 timer 定时器做手脚"。差不多 9 年过去了,人们认为这类问题应该都已经被找出来了,但这个检测条件仍然存在。但是,如果 CFI 被打开的话,那么从 loadable module 看到的 delayed_work_timer_fn()的地址就跟从 core kernel 看到的地址不一样,于是检测出错。内核中有几个地方都有类似这样的代码。现在它们已经被 "fix" 了,具体的 fix 方法就是在打开 CFI 时直接将这种代码禁用掉。
还有其他一些情况也需要 fix,比如有一些情况下必须要使用一个直接指向函数的指针,而不能使用这种指向跳转表的指针。内核中的 CFI 在 5.13 版本中只有在 arm64 架构上可用,对于 x86 架构的支持正在进行中,但还没有准备好,暂时无法启用。关于这个功能对性能有多大影响,似乎还没有多少评估,不过在介绍 CFI 的 LLVM 文档中据说它的额外开销 "低于 1%"。
CFI 看起来是一个可能会有一些负面影响或者边边角角没有考虑周全的新功能。值得注意的是,Kees Cook 在发送这个请求合并这些由 Sami Tolvanen 撰写的 patch 的 pull request 时说,CFI "已经在 Android 内核中应用了将近 3 年了"。换句话说,这个功能已经在现实世界中广泛部署了,因此也许不会有多少意外,不过,对于攻击者就不一样了,他们会发现他们以前使用的许多漏洞不再有效。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~