Linux 内核自防护项目 KSPP

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

内核自防护

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

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

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

降低攻击平面

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

限制内核内存权限

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

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

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

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

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

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

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

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

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

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

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

Shawn: SMEP 是 2011 年在 Intel Sandybridge 中加入的特性,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 这类资源。这将内核入口点的范围限制到正常可用于非特权用户空间的更规则的集合中。

Shawn: 在 operations 的过程中,使用一些基于 seccomp 的实现的 sandbox 是一个比较好的选择,可参考这篇。注:对于有风险的 binary file 建议人工审查相关的 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)都有相对确定性的内存布局。如果这些区域的基地址在不同的启动是不同的,攻击会受挫,从而需要特定区域的信息泄漏。

组织泄漏

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

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

唯一的标示符

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

内存初始化

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

内存污染

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

目的跟踪

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

Linux 内核自防护项目 KSPP

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/41783.html

(0)
上一篇 2021年8月5日
下一篇 2021年8月5日

相关推荐

发表回复

登录后才能评论