USER 命名空间为我们最喜欢的工具(例如 docker 和 podman)驱动各种功能。 我们在去年 6 月份写过关于 Linux 命名空间的文章并这样解释:
许多命名空间都不存在争议的,例如 UTS 命名空间,其允许主机系统隐藏其主机名和时间。其他一些命名空间复杂但直接明了,例如,NET 和 NS (mount) 命名空间令人难以理解。最后,还有一个非常特殊、很不寻常的 USER 命名空间。USER 名称空间之所以特殊,是因为它允许通常没有特权的所有者在其中作为根运行。这样我们才能拥有非作为真正根运行的工具,例如 Docker,以及无根容器之类的东西。
由于其性质,允许非特权用户访问 USER 命名空间总是存在巨大的安全风险。在它的帮助下,非特权用户事实上可以运行通常需要根权限的代码。这种代码往往未经充分测试和存在 bug。今天我们将研究一个此类案例,即通过 USER 命名空间来利用一个内核缺陷,从而导致非特权的拒绝服务攻击。
Linux 流量控制队列规则
2019 年,我们正在探索利用 Linux 流量控制的 队列规则 (qdisc),使用 Hierarchy Token Bucket (HTB) classful qdisc 策略为我们的服务之一调度数据包。Linux 流量控制是一个用户配置的系统,用于调度和过滤网络数据包。队列规则是调度数据包的策略。具体而言,我们想从一个接口过滤和调度某些数据包,并将其他数据包丢入 noqueue qdisc。
noqueue 是 qdisc 的一个特例,调度到其中的数据包应该被丢弃。实践中情况并非如此。Linux 对 noqueue 处理方式导致数据包被通过而不是被丢弃(大部分情况下)。 文档也很能说明问题。它还指出,“不可能将 noqueue 排队规则分配给物理设备或类”。那么,当我们把 noqueue 分配给一个类时会发生什么呢?
让我们写一些 shell 命令来显示这个问题的实际情况。
首先我们需要以根身份登录,这样我们将获得 CAP_NET_ADMIN 权限,从而能够配置流量控制。
然后我们将一个网络接口分配给一个变量。这些可以通过
ip a
找到。虚拟接口可以通过调用ls /sys/devices/virtual/net
定位。这些将匹配ip a
的输出。我们的接口目前被分配给 pfifo_fast qdisc,所以我们用 HTB classful qdisc 来代替它,并把它分配给
1:
的句柄。我们可以把它看作是树中的根节点。“默认 1”配置的结果是,未分类的流量被路由直接通过这个 qdisc,后者会回退到 pfifo_fast 排队。(下文将进一步说明)接下来我们给我们的根 qdisc 添加一个类
1:
,把它分配给根 1: 的第一个叶节点1:1
,并给它一些合理的配置默认值。最后,我们将 noqueue qdisc 添加到层级结构中的第一个叶节点:
1:1
。这实际意味着,在这里路由的流量将被调度到 noqueue。
1. $ sudo -i
2. # dev=enp0s5
3. # tc qdisc replace dev $dev root handle 1: htb default 1
4. # tc class add dev $dev parent 1: classid 1:1 htb rate 10mbit
5. # tc qdisc add dev $dev parent 1:1 handle 10: noqueue
假设我们的设置顺利执行,我们将收到类似于这个内核错误的:
我们知道该根用户负责在接口设置 qdisc,如果根用户可以导致内核崩溃,那怎么办呢?我们不要将 HTB qdisc 应用到一个 HTB qdisc 的 noqueue qdisc 就是了。
BUG: kernel NULL pointer dereference, address: 0000000000000000
#PF: supervisor instruction fetch in kernel mode
...
Call Trace:
<TASK>
htb_enqueue+0x1c8/0x370
dev_qdisc_enqueue+0x15/0x90
__dev_queue_xmit+0x798/0xd00
...
</TASK>
在这里,我们利用 HTB 的默认情况,其中分配一个类 id 1:2 以被限速(A),并隐含地没有将 qdisc 设置另一个类(例如 id 1:1) (B)。排队到 (A) 的数据包将被过滤到 HTB_DIRECT ,排队到 (B) 的数据包将被过滤到 pfifo_fast。
# dev=enp0s5
# tc qdisc replace dev $dev root handle 1: htb default 1
# tc class add dev $dev parent 1: classid 1:2 htb rate 10mbit // A
// B is missing, so anything not filtered into 1:2 will be pfifio_fast
因为我们不熟悉代码库的这一部分,我们通知了邮件列表并创建了一个工单。当时这个 bug 对我们来说似乎并不那么重要。
快进到 2022 年,我们正在推进 USER 命名空间创建强化。我们用一个新的 LSM 钩子扩展了 Linux LSM 框架: userns_create ,以利用 eBPF LSM 提供保护,并鼓励其他人也这样做。最近,在梳理我们的积压工单时,我们重新考虑了这个 bug。我们自问:"我们能不能利用 USER 命名空间来触发这个 bug ?” 简短的答案是肯定的!
演示这个 bug
这个漏洞可以用任何一个假设 struct Qdisc.enqueue 函数不为空的 classful qdisc 来执行(后面会详细介绍),但在本例中,我们只用 HTB 来演示。
我们用 “lo” 接口来证明这个 bug 可以用虚拟接口触发。这对容器来说很重要,因为它们在大多数时候都是被提供虚拟接口,而不是物理接口。正因为如此,我们可以使用一个容器以非特权用户的身份使主机崩溃,从而执行拒绝服务攻击。
$ unshare -rU –net
$ dev=lo
$ tc qdisc replace dev $dev root handle 1: htb default 1
$ tc class add dev $dev parent 1: classid 1:1 htb rate 10mbit
$ tc qdisc add dev $dev parent 1:1 handle 10: noqueue
$ ping -I $dev -w 1 -c 1 1.1.1.1
为什么有那样的结果?
为了更好地理解这个问题,我们需要回顾一下最初的补丁系列,但特别是引入了这个 bug 的提交 。这一系列之前,在接口上实现 noqueue 依赖于一种 hack:如果设备有 tx_queue_len = 0,则将设备 qdisc 设为 noqueue。提交 d66d6c3152e8("net: sched: register noqueue qdisc")通过显式允许用 tc 命令添加 noqueue 来解决这个问题,无需绕过以上限制。
内核检查我们是否处于 noqueue 情况的方式,就是简单地检查 qdisc 是否有 NULL enqueue() 函数。记得前面说过,noqueue 在实践中不一定会丢弃数据包?在以上检查失败后,下面的逻辑处理 noqueue 的功能。为了不通过检查,作者不得不以欺骗方式将 noop_enqueue() 重新赋值为 NULL,方式是在 init 中使 enqueue = NULL,后者将在运行时在register_qdisc()后调用。
这就是 classful qdiscs发挥作用的地方了。对入队函数的检查不再为 NULL。在此调用路径中,它现在设置为 HTB(在我们的示例中),因此允许通过调用 htb_enqueue() 函数,将 struct skb 排入队列。进入那里后,HTB 会进行查找以提取分配给叶节点的 qdisc,并最终尝试将 struct skb 排入选定的 qdisc,最终到达这个函数:
include/net/sch_generic.h
我们可以看到,排队过程对物理/虚拟接口是相当无关的。权限和验证检查是在向接口添加队列时进行的,这就是为什么 classful qdics 假定队列不是 NULL。了解这一点使我们找到了一些可以考虑的解决方案。
static inline int qdisc_enqueue(struct sk_buff *skb, struct Qdisc *sch,
struct sk_buff **to_free)
{
qdisc_calculate_pkt_len(skb, sch);
return sch->enqueue(skb, sch, to_free); // sch->enqueue == NULL
}
解决方案
我们有几个解决方案,介于从我们认为最好到最坏的。
遵循 tc-noqueue 文档,不允许将 noqueue 分配给一个 classful qdisc
不检查 NULL,而是检查 struct noqueue_qdisc_ops,并将 noqueue 重设为 noop_enqueue
对于每个 classful qdisc,检查是否有 NULL 和回退
我们最终选择了第一个选项: “disallow noqueue for qdisc classes(不允许对 qdisc 类的 noqueue) ”,而第三个选项在代码中造成了大量的混乱,且没有彻底解决问题。未来的 qdiscs 实现可能会忘记这个重要的检查以及维护者。然而,不采用第二个选项的原因更为有意思。
我们之所以没有采用这种方法,是因为我们需要首先回答这些问题:
为什么不允许将 noqueue 分配给 classful qdisc?
这背离了文档。文档确实有一些在实践中不被完全遵循的先例,但我们需要对其更新,以反映目前的状况。这样做很好,但是除了删除 NULL 解引用错误之外,并不能解决行为变化问题。
如果我们允许将 noqueue 分配给 qdisc,会发生什么行为变化?
这个问题更难回答,因为我们需要确定这种行为应该是什么。目前,当 noqueue 被作为根 qdisc 为一个接口应用时,其路径基本上是允许数据包被处理。声明了回退的类则是另一回事。它们可能每个都有自己的回退,我们如何知道什么是正确的回退?在 HTB中,有时回退是通过 HTB_DIRECT,有时是 pfifo_fast。其他类又如何呢?也许我们应该回到默认的 noqueue 行为,就像对根 qdiscos 那样?
我们觉得走这条路只会给排队增加混乱和额外的复杂性。我们还可以提出一个观点,即这样的更改可以被视为特性的添加,而不一定是 bug 的修复。可以说,对于目前防止漏洞而言,遵守当前文档似乎是更有吸引力的方法,其他事情可以日后再解决。
要点
首先,也是最重要的,尽快应用这个补丁 。同时,考虑通过设置 setting sysctl -w
kernel.unprivileged_userns_clone
=0
,只允许根在 Debian 内核中创建 USER 命名空间, sysctl -w
user.max_user_namespaces
=[number]
用于进程层级,或者考虑回退到这两个补丁:[security_create_user_ns()](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=7cd4c5c2101cb092db00f61f69d24380cf7a0ee8)
和 SELinux 实现 (现在为 Linux 6.1.x),允许您用 eBPF 或者 SELinux 保护自己的系统。如果确定没有使用 USER命名空间和处于极端情况下,您可以考虑用 CONFIG_USERNS=n
关闭该功能。这只是利用命名空间进行攻击的众多例子之一,未来肯定会有更多严重程度不同的例子出现。
特别感谢 Ignat Korchagin 和 Jakub Sitnicki 的代码审查工作,并帮助在实践中演示这个 bug。