内核自保护

内核自保护是指在 Linux 内核中设计和实现系统与结构,以抵御内核自身存在的安全漏洞。这涵盖了广泛的问题,包括消除整类错误、阻止安全漏洞利用方法以及主动检测攻击尝试。本文档并未探讨所有主题,但它应该是一个合理的起点,并能解答常见的疑问。(当然,欢迎提交补丁!)

在最坏的情况下,我们假设一个非特权本地攻击者可以对内核内存进行任意读写。在许多情况下,被利用的漏洞并不会提供这种级别的访问权限,但通过部署能够防御最坏情况的系统,我们也能覆盖更有限的情况。一个更高的标准(也应牢记在心)是保护内核免受_特权_本地攻击者的侵害,因为 root 用户拥有大幅增加的攻击面。(特别是当他们能够加载任意内核模块时。)

成功的自保护系统的目标是:有效、默认开启、无需开发者选择启用、无性能影响、不阻碍内核调试,并有相应的测试。很少能完全满足所有这些目标,但明确提及它们是值得的,因为这些方面需要被探索、处理和/或接受。

减小攻击面

抵御安全漏洞最基本的防御措施是减少内核中可用于重定向执行的区域。这包括限制暴露给用户空间的 API、使内核内 API 难以被错误使用、最小化可写内核内存区域等。

严格的内核内存权限

当所有内核内存都可写时,攻击者重定向执行流就变得轻而易举。为了减少这些攻击目标的可用性,内核需要用一套严格的权限来保护其内存。

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

内核中任何具有可执行内存的区域都必须不可写。显然,这包括内核文本本身,但我们也必须考虑所有其他地方:内核模块、JIT 内存等。(此规则存在临时例外,以支持指令替代、断点、kprobes 等。如果这些必须存在于内核中,则其实现方式是:在更新期间内存被临时设置为可写,然后恢复为原始权限。)

为此,有 CONFIG_STRICT_KERNEL_RWXCONFIG_STRICT_MODULE_RWX,它们旨在确保代码不可写,数据不可执行,只读数据既不可写也不可执行。

大多数架构默认启用这些选项,并且用户不可选择。对于一些希望这些选项可选的架构(例如 ARM),架构的 Kconfig 可以选择 ARCH_OPTIONAL_KERNEL_RWX 来启用 Kconfig 提示。当 ARCH_OPTIONAL_KERNEL_RWX 启用时,CONFIG_ARCH_OPTIONAL_KERNEL_RWX_DEFAULT 决定了默认设置。

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

大量内核内存区域包含函数指针,这些指针由内核查找并用于继续执行(例如,描述符/向量表、文件/网络/等操作结构等)。必须将这些变量的数量减少到绝对最少。

许多此类变量可以通过将其设置为“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 结构移到别处,并在栈底部添加一个故障内存空洞以捕获这些溢出。

堆内存完整性

用于跟踪堆空闲列表的结构可以在分配和释放期间进行健全性检查,以确保它们不会被用于操作其他内存区域。

计数器完整性

内核中许多地方使用原子计数器来跟踪对象引用或执行类似的生命周期管理。当这些计数器可以被强制回绕(向上或向下溢出)时,传统上会暴露一个使用后释放(use-after-free)漏洞。通过捕获原子回绕,这类错误就会消失。

大小计算溢出检测

类似于计数器溢出,整数溢出(通常是大小计算)需要在运行时被检测到,以消除这类错误,这类错误传统上会导致能够写入超出内核缓冲区末端。

概率防御

虽然许多保护措施可以被认为是确定性的(例如,只读内存不可写入),但有些保护措施仅提供统计性防御,因为攻击者必须收集足够的关于运行系统的信息才能克服防御。尽管不完美,但这些防御确实提供了有意义的保护。

金丝雀、致盲及其他秘密

值得注意的是,前面讨论的栈金丝雀等技术在技术上属于统计性防御,因为它们依赖于一个秘密值,而这些值可能会通过信息泄露漏洞被发现。

对于像 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 文件),它应该自动审查敏感值。