Subscribe to receive notifications of new posts:

CVE-2022-47929:流量控制 noqueue 没有问题?

01/31/2023

10 min read
CVE-2022-47929: traffic control noqueue no problem?

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 命令来显示这个问题的实际情况。

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
  1. 首先我们需要以根身份登录,这样我们将获得 CAP_NET_ADMIN 权限,从而能够配置流量控制。
  2. 然后我们将一个网络接口分配给一个变量。这些可以通过 ip a 找到。虚拟接口可以通过调用 ls /sys/devices/virtual/net 定位。这些将匹配 ip a 的输出。
  3. 我们的接口目前被分配给 pfifo_fast qdisc,所以我们用 HTB classful qdisc 来代替它,并把它分配给 1: 的句柄。我们可以把它看作是树中的根节点。“默认 1”配置的结果是,未分类的流量被路由直接通过这个 qdisc,后者会回退到 pfifo_fast 排队。(下文将进一步说明)
  4. 接下来我们给我们的根 qdisc 添加一个类 1:,把它分配给根 1: 的第一个叶节点 1:1,并给它一些合理的配置默认值。
  5. 最后,我们将 noqueue qdisc 添加到层级结构中的第一个叶节点: 1:1。这实际意味着,在这里路由的流量将被调度到 noqueue。

假设我们的设置顺利执行,我们将收到类似于这个内核错误的:

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>

我们知道该根用户负责在接口设置 qdisc,如果根用户可以导致内核崩溃,那怎么办呢?我们不要将 HTB qdisc 应用到一个 HTB qdisc 的 noqueue qdisc 就是了。

# 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

在这里,我们利用 HTB 的默认情况,其中分配一个类 id 1:2 以被限速(A),并隐含地没有将 qdisc 设置另一个类(例如 id 1:1) (B)。排队到 (A) 的数据包将被过滤到 HTB_DIRECT ,排队到 (B) 的数据包将被过滤到 pfifo_fast。

因为我们不熟悉代码库的这一部分,我们通知了邮件列表并创建了一个工单。当时这个 bug 对我们来说似乎并不那么重要。

快进到 2022 年,我们正在推进 USER 命名空间创建强化。我们用一个新的 LSM 钩子扩展了 Linux LSM 框架: userns_create ,以利用 eBPF LSM 提供保护,并鼓励其他人也这样做。最近,在梳理我们的积压工单时,我们重新考虑了这个 bug。我们自问:"我们能不能利用 USER 命名空间来触发这个 bug ?” 简短的答案是肯定的!

演示这个 bug

这个漏洞可以用任何一个假设 struct Qdisc.enqueue 函数不为空的 classful qdisc 来执行(后面会详细介绍),但在本例中,我们只用 HTB 来演示。

$ 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

我们用 “lo” 接口来证明这个 bug 可以用虚拟接口触发。这对容器来说很重要,因为它们在大多数时候都是被提供虚拟接口,而不是物理接口。正因为如此,我们可以使用一个容器以非特权用户的身份使主机崩溃,从而执行拒绝服务攻击。

为什么有那样的结果?

为了更好地理解这个问题,我们需要回顾一下最初的补丁系列,但特别是引入了这个 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

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
}

我们可以看到,排队过程对物理/虚拟接口是相当无关的。权限和验证检查是在向接口添加队列时进行的,这就是为什么 classful qdics 假定队列不是 NULL。了解这一点使我们找到了一些可以考虑的解决方案。

解决方案

我们有几个解决方案,介于从我们认为最好到最坏的。

  1. 遵循 tc-noqueue 文档,不允许将 noqueue 分配给一个 classful qdisc
  2. 不检查 NULL,而是检查 struct noqueue_qdisc_ops,并将 noqueue 重设为 noop_enqueue
  3. 对于每个 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() SELinux 实现 (现在为 Linux 6.1.x),允许您用 eBPF 或者 SELinux 保护自己的系统。如果确定没有使用 USER命名空间和处于极端情况下,您可以考虑用 CONFIG_USERNS=n 关闭该功能。这只是利用命名空间进行攻击的众多例子之一,未来肯定会有更多严重程度不同的例子出现。

特别感谢 Ignat Korchagin 和 Jakub Sitnicki 的代码审查工作,并帮助在实践中演示这个 bug。

We protect entire corporate networks, help customers build Internet-scale applications efficiently, accelerate any website or Internet application, ward off DDoS attacks, keep hackers at bay, and can help you on your journey to Zero Trust.

Visit 1.1.1.1 from any device to get started with our free app that makes your Internet faster and safer.

To learn more about our mission to help build a better Internet, start here. If you're looking for a new career direction, check out our open positions.
Linux (CN)Security (CN)Vulnerabilities (CN)CVE (CN)简体中文

Follow on X

Cloudflare|@cloudflare

Related posts