内核自我保护¶
内核自我保护是指在 Linux 内核中设计和实现系统及结构,以防止内核自身出现安全漏洞。这涵盖了广泛的问题,包括消除整类错误、阻止安全漏洞利用方法以及主动检测攻击尝试。本文档并未探讨所有主题,但它应该可以作为一个合理的起点,并回答任何常见问题。(当然,欢迎提交补丁!)
在最坏的情况下,我们假设一个非特权的本地攻击者可以任意读取和写入内核的内存。在许多情况下,被利用的错误不会提供这种级别的访问权限,但是通过部署能够防御最坏情况的系统,我们也可以覆盖更有限的情况。一个更高的标准,也应该牢记在心的是,保护内核免受_特权_本地攻击者的侵害,因为 root 用户可以访问非常广泛的攻击面。(尤其是在他们有能力加载任意内核模块的情况下。)
成功的自我保护系统的目标是有效、默认启用、不需要开发人员选择启用、没有性能影响、不阻碍内核调试并具有测试。不太可能满足所有这些目标,但值得明确提及它们,因为这些方面需要探索、处理和/或接受。
减少攻击面¶
针对安全漏洞利用的最基本防御是减少可用于重定向执行的内核区域。这包括限制暴露给用户空间的 API、使内核 API 难以错误使用、最大限度地减少可写内核内存的区域等。
严格的内核内存权限¶
当所有内核内存都可写时,攻击者可以很容易地重定向执行流程。为了减少这些目标的可用性,内核需要使用一组严格的权限来保护其内存。
可执行代码和只读数据不得可写¶
内核中任何具有可执行内存的区域都不得可写。虽然这显然包括内核文本本身,但我们还必须考虑所有其他地方:内核模块、JIT 内存等。(此规则存在临时例外,以支持诸如指令替换、断点、kprobes 等功能。如果这些必须存在于内核中,则它们的实现方式是在更新期间临时使内存可写,然后恢复到原始权限。)
为了支持这一点,存在 CONFIG_STRICT_KERNEL_RWX
和 CONFIG_STRICT_MODULE_RWX
,它们旨在确保代码不可写、数据不可执行,并且只读数据既不可写也不可执行。
大多数架构默认启用这些选项,并且用户不可选择。对于某些希望这些选项可选择的架构(如 arm),架构 Kconfig 可以选择 ARCH_OPTIONAL_KERNEL_RWX 以启用 Kconfig 提示。CONFIG_ARCH_OPTIONAL_KERNEL_RWX_DEFAULT
确定启用 ARCH_OPTIONAL_KERNEL_RWX 时的默认设置。
函数指针和敏感变量不得可写¶
内核内存的很大一部分包含函数指针,内核会查找这些指针并使用它们来继续执行(例如,描述符/向量表、文件/网络/等操作结构等)。这些变量的数量必须减少到绝对最小值。
可以通过将许多此类变量设置为“const”来使其成为只读,以便它们位于内核的 .rodata 部分而不是 .data 部分,从而获得上述内核严格内存权限的保护。
对于在 __init
时初始化一次的变量,可以使用 __ro_after_init
属性进行标记。
剩下的是很少更新的变量(例如 GDT)。这些变量将需要另一个基础设施(类似于上面提到的对内核代码的临时例外),以允许它们在其余生命周期中保持只读。(例如,在更新时,只有执行更新的 CPU 线程才会被赋予对内存的不可中断的写入权限。)
将内核内存与用户空间内存隔离¶
内核绝不能执行用户空间内存。内核也绝不能在没有明确期望的情况下访问用户空间内存。这些规则可以通过基于硬件的限制(x86 的 SMEP/SMAP、ARM 的 PXN/PAN)或通过模拟(ARM 的内存域)来强制执行。通过以这种方式阻止用户空间内存,执行和数据解析不能传递给可以轻易控制的用户空间内存,从而迫使攻击完全在内核内存中进行。
减少对系统调用的访问¶
消除 64 位系统的许多系统调用的一种简单方法是在构建时不使用 CONFIG_COMPAT
。但是,这种情况很少可行。
“seccomp” 系统为用户空间提供了一个选择加入的功能,该功能提供了一种减少运行进程可用的内核入口点数量的方法。这限制了可以访问的内核代码的范围,从而可能减少特定错误对攻击的可用性。
一个需要改进的方面是创建可行的方法来限制对诸如 compat、用户命名空间、BPF 创建和 perf 等内容的访问,仅限于受信任的进程。这将使内核入口点的范围限制为通常可供非特权用户使用的更常规的集合。
限制对内核模块的访问¶
内核绝不应允许非特权用户加载特定的内核模块,因为这将提供一种意外扩展可用攻击面的工具。(通过预定义的子系统按需加载模块,例如 MODULE_ALIAS_*,在此处被认为是“预期”的,尽管即使对这些也应给予额外的考虑。)例如,通过非特权套接字 API 加载文件系统模块是毫无意义的:只有 root 用户或物理本地用户才能触发文件系统模块的加载。(即使在某些情况下,这也值得商榷。)
为了防止特权用户,系统可能需要完全禁用模块加载(例如,单片内核构建或 modules_disabled sysctl),或者提供签名的模块(例如,CONFIG_MODULE_SIG_FORCE
,或带有 LoadPin 的 dm-crypt),以防止 root 用户通过模块加载器接口加载任意内核代码。
内存完整性¶
内核中有许多内存结构经常被滥用,以便在攻击期间获得执行控制权。到目前为止,最广为人知的是堆栈缓冲区溢出,其中堆栈上存储的返回地址被覆盖。存在许多其他类型的此类攻击,并且存在防御这些攻击的保护措施。
堆栈缓冲区溢出¶
经典的堆栈缓冲区溢出涉及写入超出堆栈上存储的变量的预期末尾,最终将受控值写入堆栈帧的存储返回地址。最广泛使用的防御措施是在堆栈变量和返回地址之间存在一个堆栈金丝雀 (CONFIG_STACKPROTECTOR
),该金丝雀在函数返回之前进行验证。其他防御措施包括诸如影子堆栈之类的东西。
堆栈深度溢出¶
一个不太为人所知的攻击是利用一个错误,该错误会触发内核通过深度函数调用或大型堆栈分配来消耗堆栈内存。通过这种攻击,可以将写入内容超出内核预分配的堆栈空间末尾,并写入敏感结构。为了获得更好的保护,需要进行两个重要的更改:将敏感的 thread_info 结构移动到其他位置,并在堆栈底部添加一个导致故障的内存空洞来捕获这些溢出。
堆内存完整性¶
在分配和释放期间,可以对用于跟踪堆空闲列表的结构进行健全性检查,以确保它们没有被用来操纵其他内存区域。
计数器完整性¶
内核中的许多地方使用原子计数器来跟踪对象引用或执行类似的生命周期管理。当这些计数器可以被强制环绕(向上或向下)时,通常会暴露一个使用后释放漏洞。通过捕获原子环绕,此类错误就会消失。
大小计算溢出检测¶
与计数器溢出类似,整数溢出(通常是大小计算)需要在运行时进行检测,以消除此类错误,因为传统上此类错误会导致写入内核缓冲区末尾之外的内存。
概率性防御¶
虽然许多保护措施可以被认为是确定性的(例如,只读内存无法写入),但一些保护措施仅提供统计防御,即攻击必须收集足够关于运行系统的信息才能克服防御。虽然并非完美,但这些确实提供了有意义的防御。
金丝雀、盲化和其他秘密¶
应该注意的是,像之前讨论的堆栈金丝雀这样的东西在技术上是统计防御,因为它们依赖于一个秘密值,而这些值可能通过信息泄露漏洞被发现。
对于像JIT这样的东西,需要对字面值进行盲化处理,因为可执行内容可能部分受用户空间控制,这也需要类似的秘密值。
为了最大限度地提高成功率,使用的秘密值必须是独立的(例如,每个堆栈使用不同的金丝雀)并且具有高熵(例如,RNG是否真的在工作?)。
内核地址空间布局随机化 (KASLR)¶
由于内核内存的位置几乎总是成功发起攻击的关键因素,使位置不确定性会增加利用的难度。(请注意,这反过来会使信息泄露的价值更高,因为它们可能被用来发现所需的内存位置。)
代码段和模块基址¶
通过在启动时重定位内核的物理和虚拟基址(CONFIG_RANDOMIZE_BASE
),需要内核代码的攻击将被挫败。此外,偏移模块加载基址意味着即使在每次启动时以相同顺序加载相同模块集的系统也不会与内核代码的其余部分共享公共基址。
堆栈基址¶
如果内核堆栈的基址在进程之间不相同,甚至在系统调用之间也不相同,那么定位堆栈上或超出堆栈的目标将变得更加困难。
动态内存基址¶
由于早期启动初始化的顺序,内核的大部分动态内存(例如,kmalloc、vmalloc 等)最终布局相对确定。如果这些区域的基址在启动之间不相同,那么定位它们就会受挫,需要特定于该区域的信息泄露。
结构布局¶
通过对敏感结构的布局执行每次构建随机化,攻击必须要么针对已知的内核构建进行调整,要么暴露足够的内核内存以在操作它们之前确定结构布局。
防止信息泄露¶
由于敏感结构的位置是攻击的主要目标,因此必须防止泄露内核内存地址和内核内存内容(因为它们可能包含内核地址或其他敏感信息,如金丝雀值)。
内核地址¶
将内核地址打印到用户空间会泄露关于内核内存布局的敏感信息。当使用任何打印原始地址的 printk 说明符时,应谨慎使用,目前是 %px, %p[ad] (以及在某些情况下的 %p[sSb] [*])。任何使用这些说明符写入的文件都应该只能由特权进程读取。
4.14 及更旧的内核使用 %p 打印原始地址。从 4.15-rc1 开始,使用说明符 %p 打印的地址在打印前会被哈希处理。
[*] 如果启用 KALLSYMS 并且符号查找失败,则会打印原始地址。如果未启用 KALLSYMS,则会打印原始地址。
唯一标识符¶
内核内存地址绝不能用作暴露给用户空间的标识符。 相反,请使用原子计数器、idr 或类似的唯一标识符。
内存初始化¶
复制到用户空间的内存必须始终完全初始化。如果不是显式地 memset()
,这将需要更改编译器以确保清除结构漏洞。
内存中毒¶
释放内存时,最好对内容进行中毒,以避免依赖于旧内存内容的重用攻击。例如,在系统调用返回时清除堆栈 ( CONFIG_GCC_PLUGIN_STACKLEAK
),释放时擦除堆内存。这会挫败许多未初始化的变量攻击、堆栈内容泄露、堆内容泄露和释放后使用攻击。
目标跟踪¶
为了帮助消除导致内核地址被写入用户空间的一类错误,需要跟踪写入的目标。如果缓冲区的目标是用户空间(例如,由 seq_file 支持的 /proc
文件),它应该自动审查敏感值。