Linux内核自防护项目的初始文档

原文:Linux内核自防护项目

作者:Kees Cook

译者:citypw( Shawn C)

Shawn: 2015年11月,华盛顿邮报发布了一篇关于Linux内核安全性的报道,厂商们多年以来极力掩饰的Linux内核安全状况第一次呈现在了公众面前,虽然在黑客圈Linux内核自身安全的脆弱性早已经不是秘密,而多年以来自由软件社区和商业用户也用各种各样的方式来规避Linux内核本身的安全性问题,由于Mobile和IoT的趋势,越来越多的人开始关注这个基础架构中的基础架构和担心Linux的安全性会影响到未来重度依赖自由软件的IoT体系,由Kees Cook主导的Kernel Self-Protection Project( KSPP)应运而生,目标是为了让Linux内核本身具有对漏洞利用的防御能力,主要工作是参考PaX/Grsecurity的实现来移植或者重新实现类似的功能然后推进到Linux内核主线。随着Linux 4.6的发布,第一个加固patch合并到了主线,而Kees Cook也编写了这篇KSPP介绍的文档以让更多的人能了解和参与到KSPP中。

内核自防护

内核自防护是针对Linux内核对抗自身的安全缺陷的设计与实现。这个领域涉足广泛的问题,包括干掉一整个类型的bug,阻止漏洞利用的方法和积极的检测攻击行为。这篇文档不讨论所有的议题,但这篇文档是整个内核自防护项目的一个起点和回答一些常见的问题。(欢迎提交补丁)

在最糟糕的场景下,我们假设一名没有权限的本地攻击者拥有对内核内存任意读写访问的能力。在很多情况下,被利用的bug不会提供这个层面的访问能力,但在防御最糟糕的情况的同时我们也会讨论更多限制的攻击场景。root用户大大的增加了攻击面,而我们应该注意防御具有权限的本地攻击者则是更高的门槛(特别是当攻击者具有加载任意内核模块的场景)。

自防护系统的目标应该是让安全防护成为默认配置,不影响性能和调试以及经过严格的测试。要达到所有的目标可能不太容易,但值得我们都把这些问题罗列出来,再去解决或者接受。

降低攻击面

针对安全漏洞利用的最基础的防御是降低内核可被重定向执行的区域。这包含限制暴露给用户空间的API,让内核自身的API更难以不正确的使用,可写内核内存区域的最小化,etc。

限制内核内存权限

当所有的内核内存都是可写,重定向执行流变得容易。降低这些内核攻击的可用性需要针对内核内存更严格的权限。

可执行的代码和只读数据必须不可写

所有内核里可执行的内存区域都必须不可写。这很明显包含内核代码自身,但我们必须考虑所有的区域:内核模块,JIT内存,etc。(有一些临时的例外以支持一些特性比如指令替换指令,断点,kprobes,etc。如果这些特性必须存在于内核,他们的实现应该在更新时的内存变得临时可写,然后恢复到原有的权限。)

要支持这写特性,CONFIG_DEBUG_RODATA和CONFIG_DEBUG_SET_MODULE_RONX(名字启的太烂)保证代码不是可写,数据不是可执行以及只读数据即不能写也不能执行。

函数指针和敏感变量必须不可写

大量的内存区域都有函数指针用于内核查找和继续执行(e.g. 描述符/向量表,文件/网络/etc操作结构,etc)。这些变量都必须降低到最小。

很多这种变量可以通过设置”const”而变成只读,所以他们可以存在于.rodata区域而不是.data区域来获得内核限制内存权限而带来的保护。

对于那些在__init时初始化的变量可以标记为(新的和正在开发的)__ro_after_init属性。

剩下的需要更新的变量就比较少见了(比如GDT)。这些需要基础架构(类似上面提到的暂时性例外)的支持用于实现在例外更新以后的时间里为只读(比如被更新时,只有CPU线程执行更新操作会被赋予对内存的不可中断的写。

从用户空间内存分离出内核内存

内核必须永远不能执行用户空间的内存。内核也必须永远不能在没有显式预期的情况下访问用户空间内存。这些规则可以被基于硬件的限制(x86的SMEP/SMAP,ARM的PXN/PAN)或者通过模拟(ARM的内存域)。这种方式阻断了执行和数据不能传递到被控制的用户空间内存里,只能强制攻击在内核内存中进行。

Shawn: SMEP是2012年在Intel Ivybridge中加入的特性,SMAP是在2014年的Broadwell中加入,ARM的PXN是在armv7中加入,PAN会在armv8.1中加入。遗憾的是,PaX/Grsecurity的KERNEXEC和UDEREF均领先厂商数年。Anyway,最终SMEP/SMAP把兵工厂逼上了Kernel ROP的道路;-)

减少对系统调用的访问

一个简单的为64位系统消除很多系统调用的方式是编译时不带CONFIG_COMPAT。当然,这是一种很罕见的场景。

“seccomp”系统为用户空间提供了减小针对运行的进程对内核入口的可选的功能。这限制了内核codepath的宽度从而降低了特定bug的攻击。

构建一些可行的方式只允许信任的进程访问像compat,用户空间,BPF创建和perf这类资源。This would keep the scope of kernel entry points restricted to the more regular set of normally available to unprivileged userspace.

Shawn: 在operations的过程中,使用一些基于seccomp的实现的sandbox是一个比较好的选择,可参考这篇。注:对于有风险的binary file建议人工review相关的syscall规则。

限制访问内核模块

内核不应该让非特权用户有能力加载内核模块,因为这会增加攻击面。(通过自定义子系统的按需加载模块,比如在这里MODULE_ALIAS_*是可接受的。)比如,通过一个非特权socket API加载一个文件系统的模块是不应该的: 只有root或者物理的本地用户才能够触发文件系统的模块加载。(甚至这在一些场景下也是值得商榷的。)

去对抗特权用户,系统可能需要完全关闭模块加载(比如宏内核编译或者modules_disabled sysctl),或者提供带签名的模块(比如CONFIG_MODULE_SIG_FORCE或者dm-crypt的LoadPin)来保证root无法通过模块加载器接口加载任意内核代码。

内存完整性

有很多内核的数据结构在攻击中是可以被滥用于获得执行控制的,至今最广为人知的是存储在栈上的返回地址被修改的栈缓冲区溢出。其他这类攻击的例子存在,相关用于对抗此类攻击的保护机制也存在。

栈缓冲区溢出

经典的栈缓冲区溢出是越界的写一个存储在栈上的变量,最终写一个可控制的值到栈帧的存储返回地址。常用的防御方案是在栈和返回地址之间放stack canary( CONFIG_CC_STACKPROTECTOR),以在函数返回前验证。其他防御方案包括shadow stacks。

Shawn: 需要注意的是kernel stack canary被触发后系统通常会直接panic,对于生产环境里性能和安全风险的trade-off由各位自己把握。

栈深度写出

这是一种少见的攻击方式,用一个bug触发内核通过深度函数调用或者大量栈分配消耗掉栈内存。这种攻击可能覆盖内核预分配栈空间的末端和敏感结构体。为了更好的防护,有两个重要的改变需要做:把敏感的结构体thread_info移到别处,和增加一个内存错误机制在栈底用于捕捉这些溢出。

Shawn: 常见的漏洞利用会根据rsp算出thread_info的地址从而去修改addr_limit,而PaX/Grsecurity的x86实现早在2011年以前就已经把thread_info从kernel stack相邻的位置移走了;-)

堆内存完整性

用于跟踪堆的链表的数据结构可以在分配和释放时做sanity-check以保证他们没有被用于篡改其他内存区域。

计数器完整性

内核很多地方使用了原子计数器用于跟踪对象引用或者执行类似生命周期管理的操作。当这些计数器被wrap时(unsigned int的32位会在2^32 - 1后出现)会暴露user-after-free的漏洞。通过捕获原子wrapping可以解决掉这类bug。

Shawn: 来自以色列的PERCEPTION POINT公布的针对CVE-2016-0728的PoC算是第一个针对此种类型bug的公开漏洞利用,PAX_REFCOUNT可以防御。

大小计算溢出检测

类似计数器溢出,整数溢出(通常是大小计算)需要在运行时被检测到来解决掉这类通常会导致内核缓冲区越界写的bug。

统计性防御

有很多防御是可以被认为是确定性的(比如只读内存不能被写入),一些防护在一些必须搜集足够信息的场景只提供统计性防御。虽然不完美,但也提供了有意义的防御。

Canaries, blinding和其他秘密

应该注意像之前讨论的stack canary是技术性的统计性防御,因为他们依赖于(可泄漏)的秘密值。

致盲像在被用户空间控制的内容,类似JIT的逐字逐句的值,也需要一个类似的秘密值。

关键是秘密值必须分离(比如每个stack不同的canary)和高熵(比如,RNG真的工作吗?)。

内核地址空间布局随机化(KASLR)

内核内存的地址几乎是成功攻击的重要工具,让地址变得不确定性会增加漏洞利用的难度。(注意,这反过来会让泄漏值更高从而可能发现所需的内存地址。)

代码段和模块基地址

通过在启动时( CONFIG_RANDOMIZE_BASE)对内核的物理和虚拟地址的基地址进行重定位,需要内核代码的攻击会受挫。另外,offsetting模块的加载基地址意味着系统每次启动以相同的顺序加载相同的模块不会共享同样的基地址。

栈基

如果内核栈的基地址对于不同的进程甚至系统调用都不一样,攻击将变得很困难。

动态内存基

根据早期启动的初始化,太多的内核动态内存(比如kmalloc, vmalloc, etc)都有相对确定性的内存布局。如果这些区域的基地址在不同的启动是不同的,攻击会受挫,从而需要特定区域的信息泄漏。

结构布局

通过对敏感的结构体在每次编译时进行随机化,攻击者在攻击前必须知道内核的build信息或者足够多的内核内存信息才能确定结构布局。

阻止信息泄漏

敏感结构体的地址是攻击的首要目标,很重要的是去防御对内核内存地址和内核内存内容(他们包含内核地址或者其他敏感信息比如carnary值)的泄漏。

Shawn: 对于敏感结构体最好的方式是code diversification,但这对生产环境的取证工作会带来一些麻烦-_-

唯一的标示符

内核内存地址不能作为标示符暴露给用户空间。相反,应该使用一个原子计数器,一个idr或者类似唯一的标示符。

内存初始化

内存拷贝到用户空间必须总是完全初始化的。如果没有显式的memset(),这需要修改编译器确保这些结构体是清空的。

内存污染

当释放内存是,最好去污染内容(在syscall返回是清空栈,在释放后清空堆),以防止依赖于旧内存内容重用的攻击。这会受挫很多未初始化变量的攻击,栈泄漏,堆信息泄漏和UAF攻击。

目的跟踪

为了解决掉内核地址被写到用户空间的bug,写的目的地址需要被跟踪。如果缓冲区是用户空间(比如seq_file的/proc文件),就应该检查敏感值。