声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由用户承担全部法律及连带责任,文章作者不承担任何法律及连带责任。 |
不想错过任何消息?设置星标↓ ↓ ↓

在本篇博客中,我们将探讨在2025年初于 Linux 内核的 nftables 子系统中发现的一个 Use-After-Free(释放后重用)漏洞。该漏洞已于 2026 年 2 月 5 日发布官方补丁,并分配了编号 CVE-2026-23111。
本文涵盖了该漏洞的技术分析,以及我们如何利用它在 Debian Bookworm、Debian Trixie、Ubuntu 22.04 LTS 和 Ubuntu 24.04 LTS 系统上,从未授权用户提升权限至 root 的过程。
本节主要介绍 nftables 的主要结构体以及“生成掩码”的概念。如果你已熟悉该子系统,可以跳过此部分。
另一个推荐的 nftables 入门资料是这篇博客文章:《How The Tables Have Turned: 对 nf_tables 中两个新 Linux 漏洞的分析》
主要数据结构包括 nft_table、nft_chain、nft_rule、nft_expr、nft_set 和 nft_set_elem。下图简要说明了它们之间的依赖关系。

Netfilter 是 Linux 内核的包过滤框架,通常与 iptables 及其继任者 nftables 相关联。它支持数据包过滤、网络地址和端口转换、数据包日志记录、用户空间数据包排队以及其他数据包修改功能。
Netfilter 钩子是一个位于 Linux 内核内部的框架,允许内核模块在 Linux 网络栈的不同位置注册回调函数。对于流经 Linux 网络栈中相应钩子的每一个数据包,都会调用已注册的回调函数。
iptables 以及现在的 nftables 框架允许定义规则集,它们通过与 Netfilter 框架定义的包过滤钩子进行交互来工作。
在 nftables 中,给定规则集内的顶级容器是表 (struct nft_table)。它们可以容纳链、集合、映射、流表和状态对象。每个表刚好属于一个“族”(family),其中每个族对应不同的网络层级(例如,ip 对应 IPv4,ip6 对应 IPv6,arp 对应 ARP 等)。
链 (struct nft_chain) 是下一级别的顶级容器。它们与表关联,并且可以关联规则。链允许在特定的处理步骤中处理数据包。为此,需要创建所需类型的基础链(例如 filter、route 或 NAT),然后将其附加到合适的 Netfilter 钩子(例如 ingress、prerouting、input、forward、output 和 postrouting)。
规则 (struct nft_rule) 是用于根据数据包是否匹配指定条件来决定对其采取何种操作的元素。每条规则由零个或多个表达式后跟一个或多个语句组成。每个表达式测试数据包是否匹配特定的载荷字段或数据包/流元数据。多个表达式从左到右线性求值;如果第一个表达式匹配,则求值下一个表达式,依此类推。如果给定数据包匹配了规则中的所有表达式,则执行该规则的语句。语句定义了要执行的操作,例如计数、记录日志、接受或丢弃数据包。
一个链中的规则通过一个双向链表相互连接。
nftables 中的判定 (struct nft_verdict) 是在链内评估数据包规则的结果。当数据包匹配规则时,判定会决定后续的操作:继续在当前链内评估、重定向到另一个链,或者终止处理并接受或拒绝数据包。
一些常见的判定有:
NFT_CONTINUE: 继续对当前规则进行评估。NFT_BREAK: 终止对当前规则进行评估。NFT_JUMP: 将当前链压入跳转栈并跳转到另一个链。NFT_GOTO: 在不将当前链压入跳转栈的情况下跳转到另一个链。NFT_RETURN: 返回到跳转栈最顶层的链。NF_DROP: 丢弃数据包。不再进行进一步评估。NF_ACCEPT: 接受数据包。表达式 (struct nft_expr) 是按顺序依次求值以形成规则的一系列操作。它们用于表示在规则集评估期间从数据包收集的数据,或像网络地址和端口号这样的常数值。表达式可以通过二进制、逻辑、关系以及其他类型的表达式合并,以形成复杂或复合表达式。它们也用作某些操作(如 NAT 和数据包标记)的参数。
此类表达式的示例有:
nft_immediate: 将即时值加载到寄存器中。nft_cmp: 将给定数据与给定寄存器中的数据进行比较。nft_meta: 设置/获取数据包元信息,例如相关接口、时间戳等。nft_payload: 从数据包头中设置/获取任意数据。以下是一条规则示例,它将发往 IP 地址 192.168.0.10 的流量复制到 ens192 接口:
table netdev filter { chain input {type filter hook ingress device ens192 priority 0; ip daddr 192.168.0.10 dup to ens192; }}nftables 的内置基础设施允许使用带有任何支持的选择器的集合 (struct nft_set)。通用的集合基础设施也用在映射和判定映射的实现中。集合的元素在内部使用哈希表和红黑树等数据结构来表示。
存在两种类型的集合:
nftables 中存在多种类型的集合。创建集合时,会根据集合属性和标志选择一种实现。集合类型可以在 /net/netfilter/nft_set_*.c 中找到。一些类型包括:
hashrbtreepipapo类型名称 pipapo 代表 Pile Packet Policies(堆叠数据包策略)。其他集合类型允许匹配带有区间表达式的条目(rbtree),例如 192.0.2.1-192.0.2.4,并指定字段拼接(hash、rhash),例如 192.0.2.1:22,但不能两者同时进行。类型为 pipapo 的集合可以同时匹配多个字段的范围表达式。
集合也可以作为判定映射来运行,其中每个元素将一个键映射到一个判定,例如 accept、drop 或 goto。任何集合类型都可以额外包含一个catchall 元素,它充当通配符默认值——如果查找不匹配集合中的任何其他元素,则使用 catchall 元素。Catchall 元素不存储在集合的后端数据结构中,而是单独维护在一个通用的列表 (set->catchall_list) 中。
Linux 内核中的 nftables 子系统利用一种代际机制来管理对象的生命周期。该机制由一个称为“生成游标”(gencursor) 的概念控制,它定义了两个关键的生成:
每个对象都有一个 2 位比特掩码(genmask),用于指示其在这两个生成中的活动状态:
这种机制支持对规则集进行原子性、事务性的更新。改动在下一生成中暂存,不影响当前活动的规则集,然后通过翻转生成游标一次性应用。对象在此系统中的生命周期过程如下:
当添加新对象时,它被标记为在当前生成中非活动,在下一生成中活动。这确保了新对象被暂存以便激活,而不会干扰正在进行的数据包处理。 当规则集被提交时,比特掩码被完全清零,意味着该对象在所有生成中都变为活动状态。 相反,当一个对象被移除时,它被标记为在下一生成中非活动。在规则集提交后,该对象被完全移除。
在删除一个 nftables 判定映射之前,其元素会先被“停用”。这涉及将元素从映射中解除链接,并移除它们对其他对象的任何引用,例如链。每个 nftables 链维护一个引用计数器,只有当此计数器为零时才能被删除。
当一个具有引用某个链的 catchall 元素的 nftables 判定映射被删除时,catchall 元素被停用,并且该链的引用计数器递减。 如果在删除 nftables 判定映射集之后的同一批事务中出现错误,则会调用中止进程,必须撤销该集合的删除。特别是,被停用的 catchall 元素必须被重新激活,并且该链的引用计数器必须递增。为了完成此操作,会执行 nft_map_catchall_activate() 函数。但是,nft_map_catchall_activate() 函数会错误地跳过已停用的 catchall 元素,并去激活那些已经处于活动状态的元素。
因此,在 nftables 判定映射删除过程中被停用的 catchall 元素,在中止进程完成后仍错误地保持非活动状态。此外,链的引用计数器也错误地保持为零。
如果另一个对象持有对该链的有效引用,那么即使链的有效引用仍然存在,其引用计数器也可能为零。由于链的引用计数器为零,该链可以被删除。因此,删除该链会导致一个 Use-After-Free(释放后重用)漏洞。
接下来,让我们考虑一个包含两个事务的批次,并跟踪相关的代码路径:第一个事务成功删除一个基于 pipapo 的判定映射,第二个失败并触发了中止进程。
在这个例子中,pipapo 集合有一个 catchall 元素,其判定数据引用了一个链。我们假设这是对该链的唯一引用,因此链的引用计数器为一。
当内核接收到 netlink 批次时,它会通过调用相应的 netlink 回调函数逐个处理每条消息。对于 NFT_MSG_DELSET,它会调用 nf_tables_delset(),该函数继而调用 nft_delset() 函数。
接下来,首先展示 nft_delset() 函数如何导致 catchall 元素的停用。然后,解释中止进程如何错误地未能重新激活 catchall 元素。
以下代码清单展示了 nft_delset() 函数。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_api.c#L801static int nft_delset(const struct nft_ctx *ctx, struct nft_set *set){ int err; err = nft_trans_set_add(ctx, NFT_MSG_DELSET, set); if (err < 0) return err;[1] if (set->flags & (NFT_SET_MAP | NFT_SET_OBJECT)) nft_map_deactivate(ctx, set); nft_deactivate_next(ctx->net, set); nft_use_dec(&ctx->table->use); return err;}由于 pipapo 集合在创建时带有 NFT_SET_MAP 标志,因此会调用位于 [1] 处的 nft_map_deactivate() 函数。它会进一步调用 nft_map_catchall_deactivate() 函数。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_api.c#L769staticvoid nft_map_catchall_deactivate(const struct nft_ctx *ctx, struct nft_set *set){ u8 genmask = nft_genmask_next(ctx->net); struct nft_set_elem_catchall *catchall; struct nft_set_ext *ext;[2] list_for_each_entry(catchall, &set->catchall_list, list) { ext = nft_set_elem_ext(set, catchall->elem); if (!nft_set_elem_active(ext, genmask)) continue;[3] nft_set_elem_change_active(ctx->net, set, ext); nft_setelem_data_deactivate(ctx->net, set, catchall->elem); break; }}nft_map_catchall_deactivate() 函数遍历 pipapo 集合的 catchall_list 成员给出的所有 catchall 元素。如果一个 catchall 元素相对于下一生成掩码是非活动的,则处理下一个 catchall 元素 [2]。在当前示例中,catchall 元素相对于下一生成掩码是活动的。因此,通过调用位于 [3] 的 nft_set_elem_change_active() 函数,其活动状态从活动变为非活动。之后,会调用 nft_setelem_data_deactivate() 函数,然后循环终止。
下一个清单显示了 nft_setelem_data_deactivate() 函数。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_api.c#L7560void nft_setelem_data_deactivate(const struct net *net,const struct nft_set *set, struct nft_elem_priv *elem_priv){const struct nft_set_ext *ext = nft_set_elem_ext(set, elem_priv); if (nft_set_ext_exists(ext, NFT_SET_EXT_DATA))[4] nft_data_release(nft_set_ext_data(ext), set->dtype); if (nft_set_ext_exists(ext, NFT_SET_EXT_OBJREF)) nft_use_dec(&(*nft_set_ext_obj(ext))->use);}在当前示例中,catchall 元素具有引用一个链的判定数据。因此,调用位于 [4] 的 nft_data_release() 函数,该函数再进一步调用 nft_verdict_uninit() 函数。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_api.c#L11530staticvoid nft_verdict_uninit(const struct nft_data *data){ struct nft_chain *chain;switch (data->verdict.code) {case NFT_JUMP:case NFT_GOTO:[5] chain = data->verdict.chain; nft_use_dec(&chain->use);break; }}由于 catchall 元素的判定数据引用了一个链,因此通过调用位于 [5] 的 nft_use_dec() 函数,链的引用计数器 chain->use 从一递减到零。
综上所述,删除 pipapo 集合导致了 catchall 元素的停用以及链的引用计数器的递减。
如果在删除 pipapo 集合之后的同一批事务中出现错误,则会调用中止进程,并且必须撤销对 pipapo 集合的删除。特别是,已被停用的 catchall 元素必须被重新激活。重新激活是通过调用 nft_map_catchall_activate() 函数来完成的。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_api.c#L5713staticvoid nft_map_catchall_activate(const struct nft_ctx *ctx, struct nft_set *set){ u8 genmask = nft_genmask_next(ctx->net); struct nft_set_elem_catchall *catchall; struct nft_set_ext *ext; list_for_each_entry(catchall, &set->catchall_list, list) { ext = nft_set_elem_ext(set, catchall->elem);[6] if (!nft_set_elem_active(ext, genmask)) continue;[7] nft_clear(ctx->net, ext); nft_setelem_data_activate(ctx->net, set, catchall->elem); break; }}nft_map_catchall_activate() 函数遍历 pipapo 集合的 catchall_list 中的元素。在 [6] 处,非活动的catchall 元素被错误地不再进一步处理(注意条件中的 ! 操作符)。相反,只有活动的catchall 元素才会在 [7] 处被激活。然而,本应在 [7] 处激活那些非活动的 catchall 元素。
位于 [7] 的 nft_clear() 函数调用,本应该会将停用的 catchall 元素相对于下一生成掩码激活。因此,在当前示例中,catchall 元素相对于下一生成掩码仍然错误地保持非活动状态。
此外,位于 [7] 的 nft_setelem_data_activate() 函数的调用,本应该导致一系列执行,最终调用 nft_use_inc_restore() 函数。nft_use_inc_restore() 函数本应该会将链的引用计数器从零递增到一。
总之,当中止进程完成时,pipapo 集合的 catchall_list 包含一个 catchall 元素,它相对于下一生成是非活动的,并且链的引用计数器为零。
下一步,可以发送一个有效的事务批次,使得下一生成掩码被切换,而 catchall 元素的生成掩码保持不变。在下一生成掩码切换之后,catchall 元素相对于下一生成变为活动状态。由于 catchall 元素相对于下一生成是活动的,它可以在下一批事务中被删除。这会导致内核尝试将受害链的引用计数器从零递减为负一。
如果另一个对象持有对该链的有效引用,此漏洞可能导致链的引用计数器为零,尽管对该链的有效引用仍然存在。因此,删除这样的链会导致一个 Use-After-Free(释放后重用)漏洞。
注意: 有趣的是,上述代码清单中位于 [3] 和 [7] 的 break 指令引入了另一个 Bug (CVE-2026-23278, 补丁提交)。不过,该 Bug 不在本篇博客的讨论范围之内。
本博客介绍的利用过程包含以下步骤:
以下各节将详细阐述这些步骤。
要触发此漏洞,需要创建一个新的网络命名空间,因为低权限用户无法在默认命名空间上执行命令。
与 Debian 和 Ubuntu 22.04 不同,Ubuntu 24.04 存在限制,阻止低权限用户创建命名空间。但是,这些限制可以被绕过,例如,通过执行 aa-exec -p trinity -- unshare -Urmin /bin/sh 命令。 更多细节请参考绕过Ubuntu非特权用户命名空间限制。
接下来,创建多个 nftables 对象:
pipapo 集合。pipapo 集合中创建具有判定数据的 catchall 元素,其具有 NFT_GOTO 判定代码并引用常规链。下图展示了这个基本设置。特别注意受害链的引用计数器为二。

为了触发漏洞并将常规链的引用计数器设为零(尽管基础链有一个规则引用该常规链),需要发送以下提交批次:
批次 1:
pipapo 集合。之后,在同一批中触发一个错误以引发中止过程。批次 2:
批次 3:
pipapo 集合。批次 4:
下面展示这些批次如何触发漏洞。为了更清晰地了解每个批次中发生的情况,下表显示了在每个批次处理之前,当前生成掩码、下一生成掩码以及 catchall 元素的生成掩码的值如何变化。

调用批次 1 时,状态如下:
0b01catchall 生成掩码:0b00catchall 相对于下一生成掩码是活动的删除 pipapo 集合导致 catchall 元素被停用,常规链的引用计数器从 2 递减为 1。catchall 元素的生成掩码被设为 0b01,使其相对于下一生成掩码 0b01 变为非活动状态。 由于 nft_map_catchall_activate() 函数中的 Bug,catchall 元素没有被重新激活,常规链的引用计数器也没有从 1 递增到 2。 因此,catchall 元素的生成掩码保持为 0b01。
调用有效的批次 2 时,状态如下:
0b01catchall 生成掩码:0b01catchall 元素相对于下一生成掩码是非活动的调用批次 3 时,状态如下:
0b10(由于有效的批次 2,它已被切换)catchall 生成掩码:0b01catchall 元素相对于下一生成掩码是活动的!因此,它可以被删除。此批次删除了 pipapo 集合,之后没有触发中止过程。在删除 pipapo 集合期间,调用 nft_map_catchall_deactivate() 以停用 catchall 元素。由于 catchall 元素具有引用常规链的判定数据,链的引用计数器从 1 递减到 0。由于没有调用中止过程,链的引用计数器保持为 0。
由于常规链的引用计数器为 0,该链可以被成功删除。然而,由于基础链仍然有一个规则引用该常规链,因此发生了 Use-After-Free(释放后重用)漏洞。
为了泄露内核基地址,我们针对一个名称长度为 30 的常规链触发了 UAF 漏洞。当该链被创建时,一个位于 kmalloc-cg-32 区域的对象被分配用于存储其名称。
当常规链被删除时,包含链名称的 kmalloc-cg-32 对象被释放。之后,用户态利用程序执行 open("/proc/self/stat", 0);,这会调用内核函数 single_open()。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/fs/seq_file.c#L572int single_open(struct file *file, int (*show)(struct seq_file *, void *), void *data){[1] struct seq_operations *op = kmalloc(sizeof(*op), GFP_KERNEL_ACCOUNT); int res = -ENOMEM;if (op) {[2] op->start = single_start; op->next = single_next; op->stop = single_stop; op->show = show; res = seq_open(file, op);if (!res) ((struct seq_file *)file->private_data)->private = data;else kfree(op); }return res;}在 [1] 处,一个类型为 struct seq_operations 的对象被分配在堆上。seq_operations 结构体如下所示。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/include/linux/seq_file.h#L31struct seq_operations { void * (*start) (struct seq_file *m, loff_t *pos); void (*stop) (struct seq_file *m, void *v); void * (*next) (struct seq_file *m, void *v, loff_t *pos); int (*show) (struct seq_file *m, void *v);};该结构体包含四个在 [2] 处设置好的函数指针。由于其大小为 32 字节,该对象被分配在 kmalloc-cg-32 区域,并占据了先前存储常规链名称的已释放对象。
由于 UAF 漏洞,基础链仍然有一条包含即时表达式的规则,该表达式的判定数据引用了已被删除的常规链。发送一个针对此规则的 NFT_MSG_GETRULE 请求,会导致 nft_verdict_dump() 函数的执行。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_api.c#L11543int nft_verdict_dump(struct sk_buff *skb, int type, const struct nft_verdict *v){ struct nlattr *nest; nest = nla_nest_start_noflag(skb, type);if (!nest) goto nla_put_failure;if (nla_put_be32(skb, NFTA_VERDICT_CODE, htonl(v->code))) goto nla_put_failure;switch (v->code) {case NFT_JUMP:case NFT_GOTO:[3]if (nla_put_string(skb, NFTA_VERDICT_CHAIN, v->chain->name)) goto nla_put_failure; } nla_nest_end(skb, nest);return0;nla_put_failure:return-1;}在 [3] 处,已删除的常规链的名称被导出(dump)。由于一个 seq_operations 结构体被放置在了链名称所在的内存块中,通过它可以泄露在 [2] 处设置的函数指针,这些指针指向内核地址空间。可以利用这个地址泄露来计算内核基地址并攻破 KASLR。
如果没有 seq_operations 结构体被放置在链名称所在的内存块中,则重复此步骤。
如果 seq_operations 结构体的 start 成员包含一个空字节,可能无法通过此方法泄露内核基地址。由于在 [3] 处导出的链名称被解释为字符串,对覆写了链名称的数据的泄露会在遇到空字节时停止。
为了泄露堆地址,我们再次触发漏洞,这次使用新的表、新的 pipapo 集合和新的链。具体来说,这次常规链名称长度为 140 字节,这样名称就会在 kmalloc-cg-192 区域分配。触发漏洞后,常规链被删除,基础链有一条包含即时表达式的规则,其判定数据引用了已删除的常规链。
现在,我们创建 struct nft_rule 类型的规则,如下所示。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/include/net/netfilter/nf_tables.h#L995struct nft_rule { struct list_head list; u64 handle:42, genmask:2, dlen:12, udata:1; unsigned char data[] __attribute__((aligned(__alignof__(struct nft_expr))));};通过提供具有可控大小的 data 成员,首先创建一个大小为 96 的规则,随后创建多个大小为 192 的规则。这些规则通过它们具有 struct list_head 类型的 list 成员相互链接,该结构体如下所示。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/include/linux/types.h#L194struct list_head { struct list_head *next, *prev;};一个大小为 192 的规则占据了之前存储常规链名称的已释放的 kmalloc-cg-192 对象。此规则的 prev 和 next 成员分别指向放置在大小为 96 和 192 的内存块中的规则。因此,类似于内核基地址泄露,发送针对基础链中指向已删除链的规则的 NFT_MSG_GETRULE 请求,会导致 nft_verdict_dump() 函数的执行。在 [3] 处,可以泄露 prev 和 next 指针,它们分别指向大小为 96 和 192 的内存块。
如果 prev 或 next 指针包含空字节,由于在 [3] 处 nft_verdict_dump() 函数将链名称解释为字符串,因此必须重复此步骤。
下一步是改变控制流。我们再次触发漏洞,使用新的表、新的 pipapo 集合和新的链。传入的网络数据包由 nft_do_chain() 函数根据链的规则集进行评估,该函数如下所示。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_core.c#L252unsigned intnft_do_chain(struct nft_pktinfo *pkt, void *priv){[此处有内容省略]do_chain:if (genbit) blob = rcu_dereference(chain->blob_gen_1);else[4] blob = rcu_dereference(chain->blob_gen_0); rule = (struct nft_rule_dp *)blob->data;next_rule: regs.verdict.code = NFT_CONTINUE;for (; !rule->is_last ; rule = nft_rule_next(rule)) { nft_rule_dp_for_each_expr(expr, last, rule) {if (expr->ops == &nft_cmp_fast_ops) nft_cmp_fast_eval(expr, ®s);elseif (expr->ops == &nft_cmp16_fast_ops) nft_cmp16_fast_eval(expr, ®s);elseif (expr->ops == &nft_bitwise_fast_ops) nft_bitwise_fast_eval(expr, ®s);elseif (expr->ops != &nft_payload_fast_ops || !nft_payload_fast_eval(expr, ®s, pkt))[5] expr_call_ops_eval(expr, ®s, pkt);if (regs.verdict.code != NFT_CONTINUE)break; }[此处有内容省略] }[此处有内容省略] switch (regs.verdict.code) {case NFT_JUMP:if (WARN_ON_ONCE(stackptr >= NFT_JUMP_STACK_SIZE))return NF_DROP; jumpstack[stackptr].rule = nft_rule_next(rule); stackptr++; fallthrough;case NFT_GOTO:[6] chain = regs.verdict.chain; goto do_chain;[此处有内容省略]}基础链有一条包含即时表达式的规则,其判定数据具有 NFT_GOTO 代码并指向已删除的常规链。因此,在 [6] 处,已删除的常规链被赋值给 chain 变量,函数跳转到 do_chain 标签。然后,在 [4] 处,对已删除常规链的 chain->blob_gen_0 成员(其类型为 struct nft_rule_blob *)进行解引用。随后,在 [5] 处,expr_call_ops_eval() 函数根据由常规链的 chain->blob_gen_0 成员引用的表达式对数据包进行评估。expr_call_ops_eval() 函数如下所示。
// 来源: https://elixir.bootlin.com/linux/v6.13-rc7/source/net/netfilter/nf_tables_core.c#L206staticvoid expr_call_ops_eval(const struct nft_expr *expr, struct nft_regs *regs, struct nft_pktinfo *pkt){#ifdef CONFIG_MITIGATION_RETPOLINE unsigned long e;if (nf_skip_indirect_calls()) goto indirect_call; e = (unsigned long)expr->ops->eval;#define X(e, fun) \do { if ((e) == (unsigned long)(fun)) \return fun(expr, regs, pkt); } while (0) X(e, nft_payload_eval); X(e, nft_cmp_eval); X(e, nft_counter_eval); X(e, nft_meta_get_eval); X(e, nft_lookup_eval);#if IS_ENABLED(CONFIG_NFT_CT) X(e, nft_ct_get_fast_eval);#endif X(e, nft_range_eval); X(e, nft_immediate_eval); X(e, nft_byteorder_eval); X(e, nft_dynset_eval); X(e, nft_rt_get_eval); X(e, nft_bitwise_eval); X(e, nft_objref_eval); X(e, nft_objref_map_eval);#undef Xindirect_call:#endif /* CONFIG_MITIGATION_RETPOLINE */[7] expr->ops->eval(expr, regs, pkt);}在 [7] 处,调用了表达式的 ops->eval 函数指针成员。这个调用被用于劫持控制流。
在 Debian Bookworm/Trixie 和 Ubuntu 22.04/24.04 上,劫持控制流存在一些差异,将在下文讨论。
在 Debian 6.12.8-1 内核中,当 [7] 处调用 expr->ops->eval 时,汇编代码看起来如下:
mov rax,QWORD PTR [rax]mov rdx,rbpmov rsi,r12mov rdi,rbxcall 0xffffffffad2f7bc0因此,rbx 寄存器包含一个指向存储在 blob_gen_0 对象中的表达式的指针。更具体地说,如果 unsigned long blob_gen_0_data[] 表示 blob_gen_0 内存块的数据,那么 rbx = &blob_gen_0_data[2]。因此,blob_gen_0_data[2] 包含指向伪造的 expr->ops 对象的指针。
接下来,详细解释如何劫持控制流以将权限提升至 root。
首先,泄露大小为 96 和 192 的堆内存块的地址。泄露的大小为 192 的内存块的地址在下文中称为 heap_addr_192_first。具体来说,以下数据被放置在地址为 heap_addr_192_first 的大小为 192 的规则中。
int off = 0;// 前两个条目用于伪装一个假的 nft_expr_ops 结构体。// rule_data_fake_expr_ops[0] 是 ops->eval 函数指针[8]rule_data_fake_expr_ops[off++] = push_rbx_pop_rsp_pop_rbp; // push rbx ; sbb byte ptr [rbx + 0x41], bl ; pop rsp ; pop rbp ; retrule_data_fake_expr_ops[off++] = 0x1111111111111111; // 哑数据,会被 rule_data_fake_blob[x]=mov_rsp_rbp_pop_rbp 弹出到 rbp 寄存器// ROP 链的最终部分[9]// rule_data_fake_expr_ops[off++] = ...;接下来,泄露第二对大小为 96 和 192 的堆内存块的地址。泄露的大小为 192 的内存块的地址在下文中称为 heap_addr_192_second。放置在地址为 heap_addr_192_second、大小为 192 的规则中的数据如下所示。
int off = 0;// 前三个条目用于伪装 chain->blob_gen_0 的假对象[10]rule_data_fake_blob[off++] = 0x100;rule_data_fake_blob[off++] = 0xffffffffffffff00; // 设置 blob_gen_0->data->is_last = 0rule_data_fake_blob[off++] = heap_addr_192_first + 4*8; // 指向 expr->ops 的指针 => rule_data_fake_blob_ops[0] 是 expr-ops->eval// ROP 链的开始[11]// rule_data_fake_blob[off++] = ...;// 将栈转移到位于 heap_addr_192_first + 6*8 的 ROP 链最终部分[12]rule_data_fake_blob[off++] = pop_rbp; // pop rbp ; retrule_data_fake_blob[off++] = heap_addr_192_first + 5*8; rule_data_fake_blob[off++] = mov_rsp_rbp_pop_rbp; // mov rsp, rbp ; pop rbp ; ret如前文所述,为了劫持控制流,我们再次触发漏洞。当漏洞被触发时,包含常规链的、位于 kmalloc-cg-128 区域的对象被释放。已删除的常规链的 blob_gen_0 成员在 [4] 处被引用,它包含一个指向 struct nft_expr_ops 对象的指针,该对象的 eval 函数指针在 [7] 处被调用。为了操纵已删除常规链的 blob_gen_0 成员,我们分配了“表”,其用户数据缓冲区会占据已删除的常规链并覆写其 blob_gen_0 成员。大小为 128 的表的用户数据如下所示。
[13]int off = 0;data_128[off++] = heap_addr_192_second + 4*8; // 常规链的 blob_gen_0 成员data_128[off++] = heap_addr_192_second + 4*8; // 常规链的 blob_gen_1 成员当在 [7] 处调用 expr->ops->eval() 时,触发以下一系列执行。由于常规链的 blob_gen_0 成员已被覆写为 heap_addr_192_second + 4*8 [13],因此 [7] 处的 expr->ops 函数指针是 heap_addr_192_first + 4*8。地址 heap_addr_192_first + 4*8 处的内存如 [10] 所示。伪造的 expr->ops->eval 函数指针是 [8] 处的 push_rbx_pop_rsp_pop_rbp gadget,当在 [7] 处调用 expr->ops->eval() 时会被执行。rbx 寄存器的值为 &rule_data_fake_blob[2] [10]。
这将栈转移到了 &rule_data_fake_blob[2]。随后的 pop rbp 和 ret 指令使栈前进,导致位于 rule_data_fake_blob[4] 的第一个 ROP gadget 在 [11] 处被执行。由于 rule_data_fake_blob 中的可用内存不足以容纳完整的 ROP 链,因此需要在 [12] 处将栈再次转移,以便 ROP 链的最终部分在 heap_addr_192_second 的数据内继续执行 [9]。
该 ROP 链通过调用 commit_creds(&init_cred) 来授予 root 凭据以提升权限,然后调用 __rcu_read_unlock() 来退出 RCU 读端临界区,最后在进程 ID 为 1 的任务上调用带有 init_nsproxy() 参数的 switch_task_namespaces(),以便在返回到用户模式之前跳出容器的命名空间隔离。
总的来说,Ubuntu 的利用机制与 Debian 类似。主要区别在于无法在 Ubuntu 内核中找到 push_rbx_pop_rsp_pop_rbp gadget [8]。由于 rdi 和 rbx 寄存器都包含指向存储在 blob_gen_0 对象中的表达式的指针,因此改用以下栈转移 gadget。
int off = 0;// 前两个条目用于伪装一个假的 nft_expr_ops 结构体// rule_data_fake_expr_ops[0] 是 ops->eval 函数指针rule_data_fake_expr_ops[off++] = push_rdi_pop_rsp_pop_r13_pop_rbp; // push rdi ; adc byte ptr [rbx + 0x41], bl ; pop rsp ; pop r13 ; pop rbp ; xor edx, edx ; xor esi, esi ; xor edi, edi ; retrule_data_fake_expr_ops[off++] = 0x1111111111111111; // 哑数据,会被 rule_data_fake_blob[x]=mov_rsp_rbp_pop_rbp 弹出到 rbp 寄存器在 Debian 和 Ubuntu 中,prepare_kernel_cred(&init_task) 函数的返回值都存储在 rax 寄存器中。在 Debian 中,该返回值也存储在 rdi 寄存器中,而在 Ubuntu 中并非如此。然而,当调用 commit_creds() 函数时,prepare_kernel_cred(&init_task) 的返回值必须存储在 rdi 寄存器中,以便执行 commit_creds(prepare_kernel_cred(&init_task))。
因此,与 Debian 不同,在 Ubuntu 中,调用 prepare_kernel_cred(&init_task) 之后,必须在调用 commit_creds() 之前,将值从 rax 寄存器移动到 rdi 寄存器。
在这篇博客中,我们看到了一个错误使用的感叹号如何导致一个 Use-After-Free(释放后重用)漏洞,该漏洞可被 Debian 和 Ubuntu 上的非特权用户利用来将权限提升至 root。
尽管该利用多次触发 UAF 漏洞以泄露内核基地址、泄露堆地址并劫持控制流,但在空闲系统上进行的稳定性测试结果显示其稳定性超过 99%。
为了对利用进行压力测试,我们运行了来自 Phoronix Test Suite 的 Apache benchmark,该基准测试会对内核堆施加很大压力。我们在 Playing for K(H)eaps: 理解和改进 Linux 内核利用可靠性[这篇论文中进行了类似的测试。在运行此基准测试时,利用的稳定性下降到了 80%。
该利用的实现使用了诸如上下文保存(来自上述 K(H)eaps 论文)等技术,从而获得了观察到的稳定性结果。
原文:https://blog.exodusintel.com/2026/06/08/off-by-exploiting-a-use-after-free-in-the-linux-kernel/
- END -感谢阅读,如果觉得还不错的话,动动手指给个三连吧~