-
-
[原创]Linux Netfilter 提权漏洞 (CVE-2023-4015) 复现与利用分析
-
发表于: 1小时前 43
-
综合描述
CVE-2023-4015 揭示了 Linux 内核 Netfilter (nf_tables) 子系统中一个极为精妙的逻辑设计缺陷。
该漏洞的核心在于 nf_tables 在处理复杂的 Netlink 批处理事务时,未能正确维护对象在异常回滚过程中的引用状态。具体而言,当攻击者构造一个包含特定 immediate 表达式(用于实现跳转逻辑)的规则,并故意在同一事务中触发后续错误的路径时,内核会因为逻辑重叠而对同一个 nft_chain 对象执行两次去激活(Deactivate)操作。这种“双重去激活”直接导致了对象的引用计数(use count)异常归零,从而触发提前释放。
由于内核在回滚(Abort)阶段的清理逻辑不够严谨,虽然对象在内存中已被释放回 SLUB 分配器,但先前建立的规则索引中依然保留着指向该内存地址的悬垂指针。这意味着攻击者可以通过后续的堆喷射(Heap Spraying)技术,将恶意构造的数据填入该内存坑位,从而实现类型混淆。在拥有 CAP_NET_ADMIN 权限的前提下(非特权用户可通过用户命名空间轻松获得此权限),该漏洞能够绕过 KASLR 等现代防御机制,最终实现从普通用户到 Root 权限的本地提权。这个漏洞不仅展示了逻辑漏洞在复杂事务模型中的隐蔽性,也再次证明了引用计数管理在内核安全中的关键地位。
背景知识介绍
Netfilter 核心架构
Netfilter 的 nf_tables 模块构建了一个高度层级化的对象管理体系。其核心对象包括 Table(表)、Chain(链)、Rule(规则)以及 Set(集合)和 Element(元素)。Table 作为最顶层的容器,包含了所有的链;Chain 则是规则的集合,通常与内核协议栈的钩子(Hook)点绑定;Rule 包含了具体的匹配表达式(Expression),负责实际的数据包处理逻辑。Set 和 Element 则提供了一种高效的键值匹配机制,允许规则在不线性遍历的情况下处理大量的 IP 地址或端口。这些对象之间通过引用计数相互链接,例如一个规则引用了一个跳转链,那么该链的引用计数就会增加,只有当所有引用都消失时,对象才能被安全释放。

RCU 无锁机制与宽限期检测
在 Linux 内核网络子系统中,为了兼顾高并发下的读取性能与数据一致性,广泛采用了 RCU(Read-Copy-Update)机制。RCU 的核心哲学在于“读者优先”,它允许多个读者在没有任何锁开销的情况下并发访问数据,而写者则通过“复制-修改-更新”的方式进行操作。当写者完成更新并切换全局指针后,旧的数据副本并不能立即释放,因为可能仍有读者正在引用它。此时,旧副本进入所谓的“宽限期”(Grace Period)。
内核判断 RCU 读操作结束的标准是基于“静止状态”的检测。通过 rcu_read_lock() 和 rcu_read_unlock() 标记的临界区内禁止发生进程调度。因此,当每一个 CPU 都至少经历了一次上下文切换(Context Switch)或进入了 CPU 空闲状态(Idle),即意味着所有在宽限期开始前进入临界区的读者都已退出了读操作。此时,系统认为该 CPU 度过了一个“静止状态”(Quiescent State)。当全系统所有 CPU 都经历了静止状态,内核便可安全地物理释放旧内存。在 CVE-2023-4015 的利用中,理解这种逻辑释放(引用计数归零)与物理释放(宽限期结束)之间的时间差至关重要。

nf_tables 事务处理模型
为了保证规则更新的原子性和一致性,nf_tables 引入了基于 Netlink 的批处理事务处理逻辑(nft_trans)。这一机制模仿了数据库的“两阶段提交”:在 Preparation Phase(准备阶段),内核会解析用户发送的 Batch 消息,分配资源并验证参数,此时所有的更改都被记录在事务链表中而未真正生效;如果该阶段通过,则进入 Commit Phase(提交阶段)将更改原子性地应用到全局状态。
如果中间任何一步出错,则会触发 Abort Phase(中止阶段),内核将遍历事务链表,按照相反的顺序撤销所有已记录的操作。这种事务处理在内核中是序列化的,通常在进程的上下文中执行,但对象的物理释放往往会延迟到 RCU 回调或特定的系统工作队列(workqueue)中异步完成。这种同步更新状态与异步释放内存的设计,正是 CVE-2023-4015 能够通过并发竞争和逻辑交织制造出 UAF 场景的架构基础。

漏洞原理分析
漏洞的深层诱因源于 nf_tables_newrule 函数在处理新规则创建失败时的错误清理路径,与后续事务中止(Abort)路径之间发生的逻辑碰撞。在内核解析 Netlink 消息创建规则的过程中,如果遇到参数非法或内存不足等错误,系统会立即跳转至错误处理标签 err_release_rule。在此处,内核会显式调用 nft_rule_expr_deactivate 并携带 NFT_TRANS_PREPARE_ERROR 标志,旨在撤销当前规则中已经部分解析的表达式对其他对象的引用。对于 immediate 类型的表达式,这不仅意味着要递减目标跳转链的引用计数,还会通过 nf_tables_unbind_chain 解除该链与当前规则的绑定关系。
1 2 3 4 | err_release_rule: // 第一次去激活:由 nf_tables_newrule 直接触发 nft_rule_expr_deactivate(&ctx, rule, NFT_TRANS_PREPARE_ERROR); nf_tables_rule_destroy(&ctx, rule); |
然而,由于该事务已经标记为失败,内核在完成局部的错误处理后,仍会不可避免地触发全局的 __nf_tables_abort。在该函数遍历事务链表处理 NFT_MSG_NEWRULE 类型的操作时,逻辑出现了致命偏差。由于目标链在之前的错误处理路径中已经执行了 unbind 操作,导致 nft_trans_rule_bound 的状态检查失效。于是,回滚逻辑会再一次、也是第二次对该规则调用 nft_rule_expr_deactivate,此时的阶段标志被设为 NFT_TRANS_ABORT。
1 2 3 4 5 6 7 | case NFT_MSG_NEWRULE: if (nft_trans_rule_bound(trans)) { // 状态位已在 Preparation 错误路径中被污染,导致检查跳过 nft_trans_destroy(trans); break; } // 第二次去激活:导致目标 Chain 的引用计数产生 Underflow nft_rule_expr_deactivate(&trans->ctx, nft_trans_rule(trans), NFT_TRANS_ABORT); |
这一系列动作导致目标跳转链(Victim Chain)的引用计数经历了两次连续的递减。如果我们构造一个初始引用计数为 1 的链,并在一次恶意事务中先通过规则引用它再人为制造错误,该链的引用计数会先降至 1(Preparation 报错),随后在 Abort 阶段降至 0。此时内核错误地认为该链已无人使用,从而将其物理释放。然而,我们的 Base 链中依然保留着指向这个已释放地址的规则索引,这就形成了一个稳定的 Use-After-Free 漏洞点。
漏洞利用过程详解
UAF触发
漏洞利用的第一步,也是最关键的一步,是精确控制目标对象的引用计数,使其在内核逻辑认为“已释放”的同时,我们手中仍持有指向它的有效引用。在 trigger_uaf 函数中,我们通过一系列精心编排的 Netlink 批处理事务(Batch Transactions)来实现这一目标。
首先,我们需要在内核中建立三个关键的链:c_free(即 Victim,受害者链)、c_primitive(持有悬垂指针的 Base 链)以及 c_spray(用于后续堆喷射的占位链)。在代码中,我们特别将 c_free 的名称设置为全 'A' 的长字符串,这是为了让它在后续被释放时能够落入特定的 kmalloc-cg-192 缓存池,方便我们进行堆喷射占位。通过第一个 Batch,我们完成了这些对象的创建,并添加了一条规则 r_primitive,让其通过 immediate 表达式引用 c_free。此时,如果我们通过调试器在 nf_tables_addchain 处下断点并观察,可以看到名为 'AAAA...' 的 c_free 链已被成功创建。执行 finish 跳出函数后,可以清晰地观察到该受害链的内存结构信息,包括其地址以及当前的引用计数状态。下图中0xffff888102b3e300地址代表的是对应刚才通过ctx->chain获得的对应chain的地址,而0xffff888102b05300则是他的name指向的slab的内存区域也就是kmalloc-cg-192里面的地址。

接下来的操作是漏洞触发的核心。
我们构造了一个包含两条新规则的恶意 Batch。第一条规则 r_effect 被添加到 effect 链中,它再次引用了 c_free。这一步在事务的 Preparation 阶段会将 c_free 的引用计数从 1 增加到 2。紧接着,我们在 attack 链中创建第二条规则 r_attack,它包含两个表达式:第一个引用合法的 effect 链,而第二个表达式则故意引用一个不存在的链 some_invalid_chain。这个不存在的链是整个逻辑陷阱的扳机,它将直接导致当前事务进入错误处理流程。
当内核处理这个构造精巧的恶意 Batch 时,逻辑陷阱被成功触发。在处理 r_attack 规则时,由于引用的目标链不存在,内核立即抛出错误并进入 NFT_TRANS_PREPARE_ERROR 阶段。随后,内核开始回滚该 Batch 中已处理的规则,调用 nft_immediate_deactivate 来清理 r_effect。由于我们预先为 c_effect 链设置了 NFT_CHAIN_BINDING 标志,这一操作会触发递归解绑,导致受害者链 c_free 的引用计数从 2 递减至 1。然而,真正的致命一击发生在整个事务最终中止(Abort)的时刻。内核在执行 __nf_tables_abort 逻辑时,会再次遍历事务链表处理 NFT_MSG_NEWRULE 操作。由于先前的解绑逻辑未能在事务状态中留下完整标记,导致状态检查失效,回滚流程对 r_effect 发起了第二次去激活操作。至此,c_free 的引用计数再次递减,彻底归零。
为了直观观察这一破坏过程,我们可以利用 GDB 设置条件断点:b nf_tables_chain_destroy if ctx->chain == 0xffff888102b3e300(地址需根据实际情况调整)。当我们在后续 Batch 中发送删除受害者链的指令时,断点会被触发。通过调试输出可以看到,在 nf_tables_chain_destroy 执行完毕后,原先存储 c_free 对象的内存区域已被破坏,原本清晰的 'AAAA...' 名称指针位置现在充斥着内存回收后的脏数据,这标志着该内存块已正式返回 SLUB 分配器,成为了一个可被喷射占位的 UAF 坑位。


现在,c_free 的引用计数已经归零,内核认为它不再被任何规则引用。于是,我们在下一个 Batch 中发送 NFT_MSG_DELCHAIN 命令来删除 c_free。内核执行删除操作后,那个全 'A' 名称的 c_free 链所占用的内存被释放回 kmalloc-cg-192 缓存池。然而,我们在第一阶段创建的 r_primitive 规则依然静静地躺在内核中,它的 immediate 表达式仍然指向那个已经被释放的内存地址。至此,一个完美的 Use-After-Free 漏洞场景构建完成,我们通过 r_primitive 获得了一个通向内核堆内存深处的“后门”。
堆地址泄露 (Heap Leak)
在成功制造了 UAF 之后,首要任务是泄露内核堆地址,为后续的利用铺平道路。leak_heap 函数的核心思想是利用堆喷射(Heap Spraying)技术,将一种我们可控的内核对象填入刚刚被释放的 c_free 内存坑位中。这里我们选择了 nft_rule 对象,因为它的大小可以通过附加表达式灵活调整,使其正好落入 kmalloc-cg-192 缓存池。
占位与类型混淆
我们构造了一个包含 notrack 表达式的 nft_rule,并通过大量发送 NFT_MSG_NEWRULE 请求进行喷射。当其中一个 nft_rule 恰好占据了原 c_free 的内存位置时,就发生了关键的类型混淆(Type Confusion):原 nft_chain 结构体中的 name 指针字段,在内存偏移上恰好与新 nft_rule 结构体中的 list.next 指针重叠。
1 2 3 4 | // 构造 payload 使 nft_rule 大小适配 kmalloc-cg-192char data[191 - sizeof(struct nft_expr) - sizeof(struct nft_rule)] = {0}; rule r = make_rule(current_table_name, "spray", &e, 1, data, sizeof(data), 0);// ... 循环发送 batch_new_rule 进行喷射 ... |
信息泄露路径
为了读取这个指针,我们向内核发送 NFT_MSG_GETRULE 命令,请求获取那个一直被我们持有的悬垂指针 r_primitive。内核在处理这个请求时,会调用 nft_verdict_dump 来导出规则中的 immediate 表达式信息。
1 2 | // 触发信息泄露nlmsghdr hdr = dump_rule(r_primitive, buf); |
在 nft_verdict_dump 中,内核会试图读取并发送 verdict.chain->name。由于此时 chain 指针指向的内存已经被我们的 nft_rule 覆盖,内核实际上读取的是 nft_rule->list.next 的值。
为了验证堆喷射是否成功占位,我们可以通过 GDB 在 nft_immediate_dump 函数处设置断点。当执行到泄露逻辑并触发该断点时,观察受害者链的 name 字段可以发现,它已经不再指向原本的名称字符串,而是指向了一块典型的 slab 内存区域。
通过进一步解析这块内存,我们可以确认其内部结构完全符合我们喷射的 nft_rule 对象(大小为 192 字节)。利用 GDB 的结构体查看功能,我们可以清晰地看到 nft_rule 的元数据,并且通过对其 extensions 区域的进一步探测,能够识别出我们预埋的 nft_expr 表达式类型。这种内存层面的吻合完美证实了受害者链的 name 指针确实已被我们的恶意规则对象所覆盖,类型混淆已成定局。


解析泄露数据
在用户态的回调函数 dump_expr_leak_heap 中,我们接收并解析这段数据。由于 list.next 指向的是双向链表中的下一个规则(或链表头),我们得到的是一个内核堆地址。
1 2 3 4 5 6 7 | static int dump_expr_leak_heap(expr e, void *_unused) { const char *data = nftnl_expr_get_str(e, NFTNL_EXPR_IMM_CHAIN); // ... struct nft_rule *r = (struct nft_rule *)data; heap = (uint64_t)r->list.next; // ...} |
通过这种方式,我们成功将一个内核内部的链表指针作为字符串“偷”了出来。一旦获得这个堆地址,我们就能计算出我们喷射对象的具体位置,从而精准地控制后续的内存布局。这里的list的next也就是上述说的0xffff888102b050c0地址。
绕过 KASLR (Leak Kernel Base)
在获取了堆地址后,下一步就是绕过 KASLR(内核地址空间布局随机化)。我们继续利用 c_free 的 UAF 坑位,但这次我们需要更精细的控制。leak_vmlinux 函数展示了如何通过 nft_table 的 udata(用户自定义数据)功能来实施这一攻击。
伪造 nft_chain 结构体
我们不再喷射 nft_rule,而是喷射带有 udata 的 nft_table。udata 的内容完全由用户态控制,我们将一段伪造的 nft_chain 结构体数据填入其中。当这个 udata 恰好覆盖了原 c_free 的位置时,内核再次访问 c_free 时实际上访问的是我们伪造的数据。
构造任意地址读
攻击的关键在于伪造链的 name 指针。我们将 fake_chain->name 指向了堆上的一个特定偏移位置:heap + sizeof(struct nft_rule)。回顾之前的堆喷射,这个位置正好存放着 nft_rule 内部的 nft_expr(表达式)结构体。
1 2 | // 将 name 指针指向 nft_rule 之后,即 nft_expr 的起始位置fake_chain->name = (char *)(heap + sizeof(struct nft_rule)); |
而 nft_expr 结构体的第一个成员正是 ops 指针(指向 nft_expr_ops,如 nft_notrack_ops)。这个 ops 指针位于内核代码段(.text),是一个固定的内核符号地址。通过调试器观察,我们可以清晰地看到伪造的 chain 结构体已经成功占据了内存,且其 name 指针准确地指向了 nft_expr 的起始地址。
泄露 ops 指针

当我们再次触发 NFT_MSG_GETRULE 进行 dump 时,内核会顺着这个伪造的 name 指针去读取字符串。结果,它把位于该地址的 nft_notrack_ops 内核函数地址当作字符串读取并发送给了我们。
1 2 3 4 5 6 7 8 9 10 | static int dump_expr_leak_vmlinux(expr e, void *dat) { const char *data = nftnl_expr_get_str(e, NFTNL_EXPR_IMM_CHAIN); // 直接读取前8字节作为内核地址 vmlinux = *(uint64_t *)data; // 计算内核基址 if (vmlinux >= 0xffffffff00000000) { vmlinux -= NFT_NOTRACK_OPS; } // ...} |
通过计算泄露出的 ops 地址与已知的 NFT_NOTRACK_OPS 偏移,我们即可精准计算出内核基址 vmlinux_base,从而彻底击败 KASLR 保护。
劫持 RIP:udata 喷射的“偷梁换柱”
在之前的堆地址泄露阶段,我们使用了 nft_rule 对象来占位,那是为了利用其内部合法的链表指针。然而,在最终的劫持阶段,我们需要对内存内容拥有字节级的完全控制权,特别是要精确伪造 ops 指针。合法的 nft_rule 结构受内核逻辑限制,许多字段无法被用户态随意修改。因此,我们在这里采用了一种更为底层的喷射技术——nft_table 的 udata(用户定义数据)喷射。
udata 的核心特性在于其“透传”性:内核会将用户提供的数据原封不动地 memcpy 到堆内存中。这就给了我们实施“偷梁换柱”的绝佳机会。我们不再依赖内核去构造对象,而是在用户态直接构造一个包含恶意 ops 指针的“伪造数据包”,然后利用 udata 机制将其注入到内核堆中。

用户态构造 payload
我们在用户态定义一个与 UAF 坑位大小匹配的数组(如 192 字节),并将其强制转换为 nft_rule 和 nft_expr 的结构体指针。注意,此时这些操作完全在用户态内存中进行。我们计算出 ops 指针在结构体中的偏移,并将计算好的恶意地址(指向我们的 JOP Gadget)直接写入该位置。
1 2 3 4 5 6 7 | // 1. 在本地准备伪造数据char data[192] = {0};struct nft_rule *fake_rule = (struct nft_rule *)data;struct nft_expr *fake_expr = (struct nft_expr *)(data + sizeof(struct nft_rule));// 2. 将恶意的 ops 指针写入本地数组fake_expr->ops = (struct nft_expr_ops *)(heap + JOP_OFFSET - DEACTIVATE_OFFSET); |
内核态堆喷射
随后,我们调用 make_table 并将这个精心构造的 data 数组作为 udata 传入。内核在处理 NFT_MSG_NEWTABLE 请求时,会申请一块与 data 大小相同的内存。由于其大小恰好也是 192 字节,SLUB 分配器极大概率会将刚刚释放的 UAF 坑位分配给它。紧接着,内核执行内存拷贝,将包含恶意 ops 指针的 data 数据完整地写入这块内核内存。
最终触发
当内核随后尝试通过悬垂指针访问这个对象并调用 deactivate 时,它会读取到我们通过 udata 注入的恶意 ops 指针,从而跳转执行 JOP Gadget,开启 ROP 之旅。
ROP 载荷构造
由于单个 udata 或规则数据区的空间限制,我们将 ROP 链拆分为两部分。第一段载荷利用 PUSH RSI; JMP QWORD PTR [RSI + 0xF] 指令配合栈迁移 gadget,将 RSP 指向我们的堆区。随后的 ROP 链执行标准的提权流程:
- 提权:调用
commit_creds(prepare_kernel_cred(0))将当前进程凭证提升为 Root。 - 逃逸:调用
find_task_by_vpid(1)获取 init 进程,并通过switch_task_namespaces切换命名空间以逃逸容器。 - 返回:利用 KPTI 跳板(trampoline)安全返回用户态,执行
system("/bin/bash")。
1 2 3 4 5 | // commit_creds(&init_cred)*rop++ = vmlinux + POP_RDI_RET;*rop++ = vmlinux + INIT_CRED;*rop++ = vmlinux + COMMIT_CREDS;// ... 命名空间切换与返回用户态 ... |
总结
CVE-2023-4015 的复现过程展示了内核利用的艺术:一个微小的事务回滚逻辑缺陷,通过精密的堆布局控制与类型混淆,一步步转化为任意读写,最终实现完全的权限提升。这不仅是对 Netfilter 复杂性的警示,也是对内核开发者在处理原子操作与对象生命周期时必须保持极度严谨的有力证明。