关注了就能看到更多这么棒的文章哦~
By Jake Edge
January 22, 2020
原文来自:https://lwn.net/Articles/810077/
主译:DeepL
控制流完整性(CFI, Control-flow integrity)是一种阻止黑客改变程序原有执行流程的防护技术。Clang编译器有一些可以帮助维护控制流完整性的功能,这些功能已经应用到了Android内核中。Kees Cook在最近结束的澳大利亚黄金海岸(Gold Coast)举办的linux.conf.au大会上发表了关于Linux内核CFI的演讲。
库克认为CFI是减少内核的攻击面,或者说是利用面(exploit surface)的一种方式。内核的大多数漏洞都与攻击者获得执行控制权有关,通常是利用某种write漏洞来改变系统内存。这些write漏洞有很多种类型,一般都有一些限制(例如只能写一个零或只能写一组固定的字节值),但在最坏情况下,它们可以是 "在任何时间任何地方写任何东西 "的漏洞。所幸的是,后者比较少见。
Background
历史上的一种攻击模式是简单地写到运行代码的区域,"不仅此前的rootkits会用,那时候的反病毒软件也会用这种技术,它们与rootkits没什么两样"。要使这种攻击起作用,目标区域必须同时拥有可执行和可写这两种内存权限。用户空间可以在其地址空间的任何地方写入和执行,但它不能写到预留给内核空间的那部分。不过,内核中write漏洞事实上会允许用户空间写入内核内存。
[Kees Cook]
这导致了开始使用no-execute(NX)位来禁止在内核地址空间的数据区域执行代码;但内核text区域仍然可以执行代码。这就导致了一个想法,即把这些部分变成只读(RO),这样就没有区域同时拥有writeable和executable的权限,从而阻止这类攻击。
但所有的用户空间还是没有保护,可以通过内核exploit来写入这部分区域并执行。这就导致英特尔使用了supervisor mode execution prevention(SMEP)功能、ARM使用了priviledged execute never (PXN)功能来限制内核执行用户空间内存的代码(在内核模式下)。这关闭了所有既可写又可执行的位置。他说,所有这些保护构成了 "上个世纪的防御"。
那么,从攻击者的角度来思考的话,在这些限制之下可以做什么呢?一种可能是使用存储在内存中的地址;通过覆盖这些地址,攻击者可以控制哪些代码被执行。最常见的地方就是通过操纵堆栈上的返回地址,这就是return-oriented programming(ROP)攻击所做的事情。
函数指针用于间接函数调用(indirect function call),它与直接函数调用(direct function cal)不同,因为目标函数不在(non-writable)kernel context区域中。相反,只是从内存中获取目标函数的地址,放在寄存器中,并直接跳转到这个位置去。如果攻击者能够改变内存,他们就可以控制代码流程的实际去向。这就是间接调用的发起方向(forward edge),而栈上的返回地址是调用跳转回来的方向(backword edge)。两者都可以被攻击者用来重定向代码流程。
上述对内存的其余部分访问严格管理的措施,这种writable的函数指针只存在于内核的heap和stack中。函数指针可以存储在heap中,也可以存储在stack上。库克笑着说,事实证明,如果把stack改成只读,那会 "让它变得非常难用"。如果攻击者可以覆盖这两个跳转方向之一,他们可以调用内核中的任意可执行代码,"这是一个巨大的exploitation surface"。
Enter CFI
CFI的目标是尽量保证间接调用都能跳转到预期的地址,并保证返回地址不被改变。对于forward edge,间接函数指针需要在调用之前进行验证。函数指针可以根据其原型和返回值,将函数指针分为 "class";这样做可以将间接调用限制在原来的class之内的函数中。他说,一些基于硬件的CFI保护功能,如Arm的branch target identification(BTI)等,它的保护粒度很差,因为它们只能确保间接调用到的位置是个函数起始点,这个保护很不够。
Clang 编译器可以确保只对同一类中的函数进行调用,这就 "使保护粒度变细了不少"。不过为了实现这个功能,Clang需要使用链接时间优化(LTO,link-time optimization)来完全理解整个kernel code base。同一个类中的所有函数被收集到跳转表中,在每次进行间接调用的时候都会先检查跳转表。Cook的幻灯片中有详细例子进行介绍。
他说,实测下来会有一点性能损失,但 "并不可怕"。Clang的方法很好用,但还有一些其他实现思路。PaX团队的Reuse Attack Protector (RAP)技术是一个 "聪明的想法",它对kernel text中各个函数进入和跳出位置算出哈希值,然后通过从kernel text中读取这些值来进行检查。它不兼容execute-only内存属性(即不能被读取,只是执行的内存),这个方案即将成为现实。
另一种机制是(尚未发布的)kCFI;它进行二次检查来确认哪些函数可以从调用点合法到达。这样做的目的是为了进一步减少多个函数都在同一个类中的exploitability surface。例如,有很多内核函数都有类似"void foo(void) "的原型。Android 内核团队查看了间接调用可以到达的目标数,发现55%的只有5个或更少的目标函数--但有7%的函数可以调用的目标超过100个。
Backward edge
对于backward edge,需要某种trusted stack(如shadow call stack)来放置返回地址。看起来最好是用硬件来完成(比如Intel的Control-Flow Enforcement Technology(CET)和Arm的pointer authentication功能),但硬件支持目前还没好,所以需要软件方案。
Clang中x86版本的shadow stack速度很慢,而且有一些race condition,所以被移除了,不过Arm的情况比较好。为Arm处理器中预留了一个单独的寄存器用于shadow-stack operation;为了避免暴露shadow stack的位置,这个寄存器值不会存储在普通堆栈上。shadow stack上只放置各个返回地址,但同时返回地址也仍然会被推送到普通堆栈上,这样call-stack unwinder工具才能正常工作。在函数返回之前,会从shadow stack取出返回地址用来跳转。
由于需要对shadow stack的位置进行保密,这就使得这种解决方案不那么完美;硬件的解决方案会好很多。对于Intel CET,虽然shadow stack需要由开发语言的runtime代码来预先设置好,但是生成的汇编代码就不需要改变了。调用和返回时将自动使用这个只读堆栈。对于Arm pointer authentication,需要对地址使用-msign-return-address选项进行签名(对于Clang和GCC来说都是如此),并且在输入和返回指令之前需要调用两个新的指令(paciasp和autiasp)。
Pixel 3及以后的安卓手机使用了CFI;2018年第三季度增加了forward-edge保护,2019年第三季度增加了backward-edge保护。这部分代码也在安卓common kernel中。Android compatibility definition强烈推荐使用这些功能;所谓的强烈推荐,通常在下一个Android版本中就会变成强制要求。
在安卓中实现的过程里,存在一些 "麻烦事"。首先,使用LTO需要大量的内存和CPU,这导致了 "程序链接阶段非常耗时"。Clang有一个ThinLTO模式,其实它做的分析较少,不过在修复了一些bug之后就可以用于CFI了。"现在的链接时间算不上特别特别长了,只能算是有点慢"。Clang只会为C函数建立跳转表,但kernel有很多汇编语言函数(如密码算法),所以Clang需要扩展到一下为所有extern函数建立跳转表条目。
除此之外,kernel进行user-space访问时使用的kernel exception table中,会用到从实际函数地址中计算出的相对地址。这种相对地址因为不在跳转表中出现,从而导致调用失败。然而,exception table是写死的(hardcoding),所以在跳转到这些相对地址时,CFI检查只能被临时禁用了。ftrace使用了linker aliases(链接器别名)的方式,也会让CFI检查无法识别,不过这个可以可以通过添加不同的linker aliases来解决。此外,为了处理Meltdown而添加的内核表隔离(kernel-page-table isolation,简称KPTI),可以确保只为用户空间程序映射了一个小的kernel entry stub,但这个entry stub也需要跳转表,所以必须在KPTI下映射的区域中添加跳转表。
Upstreaming
他谨慎乐观地认为(finger crossed),Clang中构建Linux所需要的所有变化都将出现在即将发布的LLVM 10版本中。虽然与CFI无关,但为了构建x86内核,Clang中也需要增加 "asm goto "功能;他希望CFI能适用于尽可能多的Linux target(编译目标平台),"事实证明,x86的装机量相当大"。
在内核方面,Clang的shadow-call-stack支持是可与其他功能轻松分离开的,预计将在5.6合并窗口中加入。为了支持forward-edge CFI,针对所有间接调用点,需要对函数原型进行一下修改。在内核中有很多地方没有达到这个要求。目前,Arm已经把这些都解决了,但还有一个补丁需要修正一个x86的函数原形定义。
添加LTO支持,其实主要是对构建脚本的修改,这一切都在进行中,但还需要推到upstream上。有很多小细节需要解决和商定;希望所有这些问题都已经解决了,这样LTO就可以合入了。而要真正支持forward-edge CFI功能,肯定需要LTO;他希望CFI这块 "能毫无争议地合入,可惜从来就没有这么好的事情"。无论如何,他对纳入5.7或5.8内核持乐观态度。
在库克的幻灯片末尾附近有一些 "do it yourself "的步骤和链接。所有大概50个补丁都在GitHub仓库中。有一点需要注意的是,即使是只想configure一下kernel,构建脚本也需要知道使用的是什么编译器和链接器,所以必须在make命令行中指定Clang和LLVM链接器("CC=clang LD=ld.ld")。
CFI有两种模式;CONFIG_CFI_PERMISSIVE实际上是CFI的调试版本,它只会对CFI问题报出warning,并继续运行。如果没有设置为permissive模式,则根据CFI故障的类型以及内部配置,来决定是触发kernel panic还是把此线程kill掉。但是,这只是针对forward edge的CFI故障,backward-edge的故障目前不会被报告出来,而是直接忽略那些被篡改过的返回地址,只从shadow stack中取出的正确值。他其实希望在未来的版本中能改成提供一些warning。
在回答某个问题时,Cook提到,forward-edge CFI保护可以跟retpolines(这是一个Spectre的补救措施)很好地配合,但他没有在他举的例子中包括这个部分,,因为这只会使情况弄得太复杂。基于硬件的forward-edge CFI可能会导致retpolines完全不可用,但他希望在一些新的CFI方案出现之前,retpolines能先消亡。他说,虽然ROP攻击的目标是函数入口以外的地方,但大多数其他攻击都是在 "正常 "地调用函数,所以现在的硬件CFI提供的限制并是很有用,他说。
有兴趣的读者也可以观看讲座的YouTube视频。
【感谢LWN的旅行赞助商--Linux基金会为linux.conf.au提供了前往黄金海岸的旅费援助。】
全文完
LWN文章遵循CC BY-SA 4.0许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注LWN深度文章以及开源社区的各种新近言论~