每个人都应该考虑采用的 Linux 内核安全可调

每个人都应该考虑采用的Linux内核安全可调

原文连接:https://blog.cloudflare.com/linux-kernel-hardening/

本文介绍了一些Linux内核的功能,这些功能帮助我们保持生产系统更安全。我们将深入探讨它们的工作原理以及为什么您也应该考虑启用它们。

Linux内核安全调整:每个人都应该考虑采用的方法

Linux内核是许多现代生产系统的核心。它决定了何时允许运行任何代码以及哪些程序/用户可以访问哪些资源。它管理内存,调解对硬件的访问,并在程序运行时的幕后代表程序执行大部分工作。由于内核始终参与任何代码执行,它处于最佳位置来保护系统免受恶意程序的侵害,执行所需的系统安全策略,并为更安全的生产环境提供安全功能。

在本文中,我们将回顾Cloudflare在Linux内核安全配置方面的一些使用方法,以及它们如何帮助阻止或减少潜在的系统妥协。

安全启动

当机器(无论是笔记本还是服务器)启动时,它经历了几个引导阶段:

image3-17

在安全启动架构中,上图中的每个阶段在传递执行权之前都会验证下一个阶段的完整性,从而形成所谓的安全启动链。这样,“可信度”就扩展到引导链中的每个组件,因为如果我们验证了特定阶段的代码完整性,我们可以相信该代码可以验证下一个阶段的完整性。

我们之前已经介绍过Cloudflare在引导过程的初始阶段实现安全启动的方法。在本文中,我们将重点介绍Linux内核。

安全启动是任何操作系统安全机制的基石。Linux内核是操作系统安全配置和策略的主要执行者,因此我们必须确保Linux内核本身没有被篡改。在我们之前关于安全启动的文章中,我们展示了如何使用UEFI Secure Boot来确保Linux内核的完整性。

但接下来会发生什么呢?内核执行后,它可能尝试加载其他驱动程序,或者在Linux世界中称为内核模块。内核模块加载不仅限于引导过程。模块可以在运行时的任何时候加载——当插入新设备并需要驱动程序时,需要对网络堆栈进行一些附加扩展(例如,用于细粒度防火墙规则),或者只是由系统管理员手动加载。

然而,不受控制的内核模块加载可能对系统完整性构成重大风险。与常规程序不同,内核模块是直接注入并在Linux内核地址空间中直接执行的代码片段。在不同的内核模块和核心内核子系统中,代码和数据之间没有分离,因此一切都可以访问一切。这意味着一个恶意的内核模块可以完全使操作系统的可信度失效,并使安全启动变得无效。例如,考虑一个简单的Debian 12(Bookworm安装),但启用了SELinux配置和强制执行:

1
2
3
4
5
6
7
8
9
10
ignat@dev:~$ lsb_release --all
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 12 (bookworm)
Release: 12
Codename: bookworm
ignat@dev:~$ uname -a
Linux dev 6.1.0-18-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.76-1 (2024-02-01) x86_64 GNU/Linux
ignat@dev:~$ sudo getenforce
Enforcing

现在我们需要进行一些研究。首先,我们看到我们正在运行6.1.76 Linux内核。如果我们探索源代码,我们会发现在内核内部,SELinux配置存储在一个单例结构中,该结构定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct selinux_state {
#ifdef CONFIG_SECURITY_SELINUX_DISABLE
bool disabled;
#endif
#ifdef CONFIG_SECURITY_SELINUX_DEVELOP
bool enforcing;
#endif
bool checkreqprot;
bool initialized;
bool policycap[__POLICYDB_CAP_MAX];

struct page *status_page;
struct mutex status_lock;

struct selinux_avc *avc;
struct selinux_policy __rcu *policy;
struct mutex policy_mutex;
} __randomize_layout;

从上面可以看出,如果内核配置启用了CONFIG_SECURITY_SELINUX_DEVELOP,则该结构将具有一个名为enforcing的布尔变量,该变量控制SELinux在运行时的强制执行状态。这正是上面的$ sudo getenforce命令返回的内容。我们可以再次检查Debian内核确实已启用该配置选项:

1
ignat@dev:~$ grep CONFIG_SECURITY_SELINUX_DEVELOP /boot/config-`uname -r` CONFIG_SECURITY_SELINUX_DEVELOP=y

很好!现在我们在内核中有一个变量,负责某种安全执行,我们可以尝试攻击它。但有一个问题是__randomize_layout属性:由于我们的Debian内核实际上没有设置CONFIG_SECURITY_SELINUX_DISABLE,因此enforcing实际上将是结构体的第一个成员。因此,如果我们知道结构体的位置,我们立即就知道了enforcing标志的位置。使用__randomize_layout,在内核编译期间,编译器可能会将成员放置在结构体的任意位置,因此创建通用的漏洞利用变得更加困难。但是,在内核中任意结构体随机化可能会引入性能影响,因此通常被禁用,并且对于Debian内核而言,它是被禁用的:

1
ignat@dev:~$ grep RANDSTRUCT /boot/config-`uname -r` CONFIG_RANDSTRUCT_NONE=y

我们还可以使用pahole工具和内核调试符号(如果可用)或(在现代内核上,如果启用)内核BTF信息来确认enforcing标志的编译位置。我们将使用后者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ignat@dev:~$ pahole -C selinux_state /sys/kernel/btf/vmlinux
struct selinux_state {
bool enforcing; /* 0 1 */
bool checkreqprot; /* 1 1 */
bool initialized; /* 2 1 */
bool policycap[8]; /* 3 8 */

/* XXX 5 bytes hole, try to pack */

struct page * status_page; /* 16 8 */
struct mutex status_lock; /* 24 32 */
struct selinux_avc * avc; /* 56 8 */
/* --- cacheline 1 boundary (64 bytes) --- */
struct selinux_policy * policy; /* 64 8 */
struct mutex policy_mutex; /* 72 32 */

/* size: 104, cachelines: 2, members: 9 */
/* sum members: 99, holes: 1, sum holes: 5 */
/* last cacheline: 40 bytes */
};

因此,enforcing确实位于结构体的开头,我们甚至不需要成为特权用户即可确认这一点。

太棒了!我们只需要内核内的selinux_state变量的运行时地址:
(shell/bash)

1
2
ignat@dev:~$ sudo grep selinux_state /proc/kallsyms
ffffffffbc3bcae0 B selinux_state

有了所有这些信息,我们可以编写一个几乎完全符合教科书的简单内核模块来操纵SELinux状态:

Mymod.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <linux/module.h>

static int __init mod_init(void)
{
bool *selinux_enforce = (bool *)0xffffffffbc3bcae0;
*selinux_enforce = false;
return 0;
}

static void mod_fini(void)
{
}

module_init(mod_init);
module_exit(mod_fini);

MODULE_DESCRIPTION("A somewhat malicious module");
MODULE_AUTHOR("Ignat Korchagin <[email protected]>");
MODULE_LICENSE("GPL");

以及相应的Kbuild文件:

1
obj-m := mymod.o

使用这两个文件,我们可以根据官方内核文档构建一个完整的内核模块:

1
2
3
4
5
6
7
8
9
10
11
12
ignat@dev:~$ cd mymod/
ignat@dev:~/mymod$ ls
Kbuild mymod.c
ignat@dev:~/mymod$ make -C /lib/modules/`uname -r`/build M=$PWD
make: Entering directory '/usr/src/linux-headers-6.1.0-18-cloud-amd64'
CC [M] /home/ignat/mymod/mymod.o
MODPOST /home/ignat/mymod/Module.symvers
CC [M] /home/ignat/mymod/mymod.mod.o
LD [M] /home/ignat/mymod/mymod.ko
BTF [M] /home/ignat/mymod/mymod.ko
Skipping BTF generation for /home/ignat/mymod/mymod.ko due to unavailability of vmlinux
make: Leaving directory '/usr/src/linux-headers-6.1.0-18-cloud-amd64'

如果现在尝试加载此模块,系统可能不允许,因为SELinux策略的原因:

1
ignat@dev:~/mymod$ sudo insmod mymod.ko insmod: ERROR: could not load module mymod.ko: Permission denied

我们可以通过将模块复制到标准模块路径中的某个位置来解决此问题:

1
ignat@dev:~/mymod$ sudo cp mymod.ko /lib/modules/`uname -r`/kernel/crypto/

现在让我们试试:

1
ignat@dev:~/mymod$ sudo getenforce Enforcing ignat@dev:~/mymod$ sudo insmod /lib/modules/`uname -r`/kernel/crypto/mymod.ko ignat@dev:~/mymod$ sudo getenforce Permissive

我们不仅通过恶意内核模块静默地禁用了SELinux保护,而且还完成了这一操作。正常的sudo setenforce 0即使被允许,也会通过官方的selinuxfs接口,并发出审核消息。我们的代码直接操作内核内存,因此没有人会发出警报。这说明为什么不受控制的内核模块加载非常危险,这也是为什么大多数安全标准和商业安全监控产品主张密切监控内核模块加载的原因。

但是,在Cloudflare上,我们不需要监视内核模块。让我们在Cloudflare生产内核上重复这个过程(为简洁起见,跳过模块重新编译):

1
2
3
4
5
ignat@dev:~$ grep MODULE_SIG /boot/config-6.1.0-18-cloud-amd64
CONFIG_MODULE_SIG_FORMAT=y
CONFIG_MODULE_SIG=y
# CONFIG_MODULE_SIG_FORCE is not set

当尝试加载模块时,我们会收到“Key was rejected by service”错误,并且内核日志将显示以下消息:

1
ignat@dev:~/mymod$ sudo dmesg | tail -n 1 [41515.037031] Loading of unsigned module is rejected

这是因为Cloudflare内核要求所有内核模块具有有效的签名,因此我们无需担心恶意模块在某个时间点被加载:

1
ignat@dev:~$ grep MODULE_SIG_FORCE /boot/config-`uname -r` CONFIG_MODULE_SIG_FORCE=y

为了完整起见,值得注意的是,Debian标准内核也支持模块签名,但不强制执行:

1
ignat@dev:~$ grep MODULE_SIG /boot/config-6.1.0-18-cloud-amd64 CONFIG_MODULE_SIG_FORMAT=y CONFIG_MODULE_SIG=y # CONFIG_MODULE_SIG_FORCE is not set …

上述配置意味着,如果可用,内核将验证模块签名。但是如果没有签名-模块将被加载,并发出警告消息,内核将被污染

内核模块签名的密钥管理

签名的内核模块非常好,但是它会带来密钥管理问题:要签名一个模块,我们需要一个由内核信任的签名密钥对。密钥对的公钥通常直接嵌入到内核二进制文件中,因此内核可以轻松使用它来验证模块签名。密钥对的私钥需要受到保护和安全,因为如果泄漏,任何人都可以编译和签名一个潜在恶意的内核模块,而我们的内核将接受它。

但是,如何消除丢失风险呢?首先不要一开始就拥有它!幸运的是,内核构建系统将为模块签名生成一个随机密钥对,如果没有提供密钥。在Cloudflare,我们使用该功能在内核编译阶段对所有内核模块进行签名。但是,在编译和签名完成后,我们不会将私钥存储在安全的位置,而是销毁私钥:

image1-19

因此,根据上述过程:

  1. 内核构建系统为模块签名生成了一个随机密钥对,并编译内核和模块
  2. 公钥嵌入到内核映像中,私钥用于签名所有模块
  3. 私钥被销毁

通过这种方案,我们不仅无需担心模块签名密钥管理问题,而且我们还为每个发布到生产环境的内核使用不同的密钥。因此,即使某个特定的构建过程被劫持并且签名密钥未被销毁并且有可能泄漏,当发布内核更新时,该密钥将不再有效。

然而,值得注意的是,Debian内核的库存版本也支持模块签名,但不强制执行:

1
ignat@dev:~$ grep MODULE_SIG /boot/config-6.1.0-18-cloud-amd64 CONFIG_MODULE_SIG_FORMAT=y CONFIG_MODULE_SIG=y # CONFIG_MODULE_SIG_FORCE is not set …

上述配置意味着,如果可用,内核将验证模块签名。但是如果没有签名-模块将被加载,并发出警告消息,内核将被污染

KEXEC

KEXEC(或kexec_load())是Linux中一个有趣的系统调用,它允许一个内核直接执行(或跳转到)另一个内核。这个想法是在不经过完整的重新启动过程的情况下更快地切换/更新/降级内核,以减少潜在的系统停机时间。然而,它是在很久以前开发的,当时安全启动和系统完整性还不是一个问题。因此,它的原始设计存在安全缺陷,可以绕过安全启动并潜在地破坏系统完整性

我们可以根据系统调用的定义看到这些问题:

1
2
3
4
5
6
7
8
struct kexec_segment {
const void *buf;
size_t bufsz;
const void *mem;
size_t memsz;
};
...
long kexec_load(unsigned long entry, unsigned long nr_segments, struct kexec_segment *segments, unsigned long flags);

因此,内核期望的只是一组要执行的代码缓冲区。在当时,内核内部没有太多的数据解析的愿望,因此,解析要执行的内核映像是在用户空间中完成的,并且仅向内核提供其所需的数据。此外,为了在旧内核关闭时接管并且新内核尚未执行时,我们需要一个中间程序。在kexec世界中,此程序称为purgatory。因此,问题显而易见:我们向内核提供一堆代码,它将愉快地以最高特权级别执行它。但是,与原始内核或purgatory代码相反,我们可以轻松地提供类似于本文中演示的代码,该代码禁用SELinux(或对内核执行其他操作)。

在Cloudflare上,我们已经将kexec_load()禁用了一段时间,仅因为这个原因。使用kexec实现更快的重新启动的优点是,硬件可能未正确初始化,因此即使没有安全问题,也不值得使用它。但是,kexec确实提供了一个有用的功能-它是Linux内核崩溃转储解决方案的基础。简而言之,如果内核在生产中崩溃(由于错误或其他错误),备份内核(使用kexec预先加载)可以接管,收集并保存内存转储以供进一步调查。这使得在生产中更有效地调查内核和其他问题成为可能,因此它是一个强大的工具。

幸运的是,自从指出了kexec的原始问题以来,Linux开发了一种替代的安全的kexec接口:与其使用代码缓冲区,它期望使用包含要执行的内核映像和initrd的文件描述符,并在内核内部进行解析。因此,只能提供有效的内核映像。在此基础上,我们可以配置要求kexec以确保所提供的映像具有正确的签名,因此只有经授权的代码可以在kexec场景中执行。一个安全的kexec配置如下所示:

1
ignat@dev:~$ grep KEXEC /boot/config-`uname -r` CONFIG_KEXEC_CORE=y CONFIG_HAVE_IMA_KEXEC=y # CONFIG_KEXEC is not set CONFIG_KEXEC_FILE=y CONFIG_KEXEC_SIG=y CONFIG_KEXEC_SIG_FORCE=y CONFIG_KEXEC_BZIMAGE_VERIFY_SIG=y …

以上配置确保了禁用了传统的kexec_load()系统调用,通过禁用CONFIG_KEXEC来实现,但仍然可以使用新的kexec_file_load()系统调用通过CONFIG_KEXEC_FILE=y进行Linux内核崩溃转储的配置,并强制执行签名检查(CONFIG_KEXEC_SIG=yCONFIG_KEXEC_SIG_FORCE=y)。

请注意,Debian内核的库存版本启用了传统的kexec_load()系统调用,并且不强制执行kexec_file_load()的签名检查(类似于模块签名检查):

1
ignat@dev:~$ grep KEXEC /boot/config-6.1.0-18-cloud-amd64 CONFIG_KEXEC=y CONFIG_KEXEC_FILE=y CONFIG_ARCH_HAS_KEXEC_PURGATORY=y CONFIG_KEXEC_SIG=y # CONFIG_KEXEC_SIG_FORCE is not set CONFIG_KEXEC_BZIMAGE_VERIFY_SIG=y …

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

即使在库存的Debian内核上,如果您尝试在系统重新启动后重复我们在“安全启动”部分中描述的练习,您可能会发现它无法再禁用SELinux。这是因为我们在恶意内核模块中硬编码了selinux_state的内核地址,但现在地址已更改:

1
ignat@dev:~$ sudo grep selinux_state /proc/kallsyms ffffffffb41bcae0 B selinux_state

内核地址空间布局随机化(或KASLR)是一个简单的概念:它在每次启动时都会轻微且随机地移动内核代码和数据:

Screenshot-2024-03-06-at-13.53.23-2

这是为了对抗基于对内核内部结构和代码位置的了解的有针对性的利用(如本文中的恶意模块)。对于像Debian这样的流行Linux发行版内核尤其有用,因为大多数用户使用相同的二进制文件,任何人都可以下载调试符号和包含所有内核内部地址的System.map文件。只要注意:它不会阻止模块加载和造成伤害,但它可能无法实现禁用SELinux的有针对性效果。相反,它将修改随机的内核内存片段,可能导致内核崩溃。

Cloudflare内核和Debian内核都启用了此功能:

1
ignat@dev:~$ grep RANDOMIZE_BASE /boot/config-`uname -r` CONFIG_RANDOMIZE_BASE=y

受限制的内核指针

虽然KASLR有助于针对性的利用,但很容易绕过,因为每次只是通过单个随机偏移量移动所有内容,如上图所示。因此,如果攻击者知道至少一个运行时内核地址,他们可以通过将运行时地址从内核的System.map文件中的相同符号(函数或数据结构)的编译时地址中减去来恢复此偏移量。一旦他们知道了偏移量,他们就可以通过调整它们来恢复所有其他符号的地址。

因此,现代内核为了至少不将内核地址泄漏给非特权用户而采取了预防措施。其中一个主要的调整选项是kptr_restrict sysctl。将其至少设置为1以禁止普通用户查看内核指针是一个好主意:
(shell/bash)

1
2
3
4
ignat@dev:~$ sudo sysctl -w kernel.kptr_restrict=1
kernel.kptr_restrict = 1
ignat@dev:~$ grep selinux_state /proc/kallsyms
0000000000000000 B selinux_state

特权用户仍然可以查看指针:

1
ignat@dev:~$ sudo grep selinux_state /proc/kallsyms ffffffffb41bcae0 B selinux_state

类似于kptr_restrict sysctl,还有dmesg_restrict,如果设置,将阻止普通用户读取内核日志(通过其消息也可能泄漏内核指针)。虽然您需要在每次启动时显式设置kptr_restrict sysctl的值(或使用一些系统sysctl配置实用程序,如此工具),但您可以通过CONFIG_SECURITY_DMESG_RESTRICT内核配置选项来配置dmesg_restrict的初始值。Cloudflare内核和Debian内核都通过这种方式强制执行dmesg_restrict

1
ignat@dev:~$ grep CONFIG_SECURITY_DMESG_RESTRICT /boot/config-`uname -r` CONFIG_SECURITY_DMESG_RESTRICT=y

值得注意的是,/proc/kallsyms和内核日志不是潜在的内核指针泄漏的唯一来源。Linux内核中有很多遗留问题,而且新的泄漏来源不断被发现和修复。这就是为什么及时跟上最新的内核错误修复版本非常重要。

Lockdown LSM

Linux Security Modules (LSM)是一个基于钩子的框架,用于在Linux内核中实现安全策略和强制访问控制。我们之前[介绍过我们使用的另一个LSM模块,BPF-LSM]。

BPF-LSM是我们内核安全性的一个有用的基础组件,但在本文中,我们要提到我们使用的另一个有用的LSM模块——Lockdown LSM。Lockdown可以处于三种状态(由/sys/kernel/security/lockdown特殊文件控制):

1
ignat@dev:~$ cat /sys/kernel/security/lockdown [none] integrity confidentiality

none是未执行任何操作并且模块实际上被禁用的状态。当Lockdown处于integrity状态时,内核尝试阻止任何可能破坏其完整性的操作。我们在本文中已经涵盖了其中的一些示例:加载未签名的模块和通过KEXEC执行未签名的代码。但是,该LSM还尝试阻止LSM的man页中提到的其他潜在方式confidentiality是最严格的模式,其中Lockdown还尝试阻止内核信息泄漏。在实践中,这对于服务器工作负载来说可能过于严格,因为它会阻止所有运行时调试功能,如perf或eBPF。

让我们看看Lockdown LSM的实际效果。在一个基本的Debian系统上,初始状态是none,意味着没有被锁定:

1
ignat@dev:~$ uname -a Linux dev 6.1.0-18-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.76-1 (2024-02-01) x86_64 GNU/Linux ignat@dev:~$ cat /sys/kernel/security/lockdown [none] integrity confidentiality

我们可以将系统切换到integrity模式:

1
ignat@dev:~$ echo integrity | sudo tee /sys/kernel/security/lockdown integrity ignat@dev:~$ cat /sys/kernel/security/lockdown none [integrity] confidentiality

值得注意的是,我们只能将系统设置为更严格的状态,而不能返回。也就是说,一旦进入integrity模式,我们只能切换到confidentiality模式,而不能切换回none模式:

1
ignat@dev:~$ echo none | sudo tee /sys/kernel/security/lockdown none tee: /sys/kernel/security/lockdown: Operation not permitted

现在,即使在库存的Debian内核上,我们也无法再加载潜在恶意的未签名内核模块:

1
ignat@dev:~$ sudo insmod mymod/mymod.ko insmod: ERROR: could not insert module mymod/mymod.ko: Operation not permitted

而且内核日志会友好地指出这是由于Lockdown LSM引起的:

1
ignat@dev:~$ sudo dmesg | tail -n 1 [21728.820129] Lockdown: insmod: unsigned module loading is restricted; see man kernel_lockdown.7

正如我们所看到的,Lockdown LSM有助于加强内核的安全性,否则可能不会启用其他强制执行位,如库存的Debian内核。

如果您自己编译内核,您可以更进一步,将Lockdown LSM的初始状态设置为比none更严格的状态。这正是我们为Cloudflare生产内核所做的:

1
ignat@dev:~$ grep LOCK_DOWN /boot/config-6.6.17-cloudflare-2024.2.9 # CONFIG_LOCK_DOWN_KERNEL_FORCE_NONE is not set CONFIG_LOCK_DOWN_KERNEL_FORCE_INTEGRITY=y # CONFIG_LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY is not set

结论

在本文中,我们回顾了Cloudflare在Linux内核安全配置方面使用的一些有用方法。这只是一个小的子集,还有许多其他可用的功能,并且Linux内核社区不断开发、审查和改进更多功能。我们希望本文能够为您介绍这些安全功能,并且如果您尚未这样做,您可以考虑在Linux系统中启用它们。