-
-
[原创]Linux Netfilter 匿名集合 UAF 漏洞 (CVE-2023-32233) 复现与利用分析
-
发表于: 2天前 565
-
CVE-2023-32233 揭示了 Linux 内核 Netfilter (nf_tables) 子系统中一个关键的 Use-After-Free (UAF) 逻辑缺陷。该漏洞的核心在于内核在处理“匿名集合”(Anonymous Sets)的生命周期时存在设计疏忽。通过构造特定的批处理事务并利用内核处理大量数据时的延迟,攻击者可以在匿名集合仍被规则引用的情况下,诱导内核将其提前释放。利用这一不稳定的内存状态,攻击者可以实现稳定的堆喷射与类型混淆,进而绕过 KASLR 防护并劫持内核控制流。
在 Linux Netfilter 的 nf_tables 框架中,集合(Set)用于存储数据以供规则高效匹配。匿名集合(带有 NFT_SET_ANONYMOUS 标志)通常作为规则中 lookup 等表达式的附属品自动生成。其生命周期理论上应与规则严格绑定:规则创建时增加引用计数,规则删除时减少引用。
漏洞根源于 nf_tables_deactivate_set 函数在事务准备阶段(NFT_TRANS_PREPARE)仅递减了引用计数 set->use,却未执行解绑(unbind)操作。
这导致一个计数已归零、逻辑上应销毁的对象依然残留在全局链表中。通过在同一个 Batch 事务中精心编排“删除规则 -> 显式删除集合”的操作序列,可以诱导内核对同一个物理对象执行两次销毁流程,从而将 UAF 演变为 Double Free。
CVE-2023-32233 的复现是一场精密的对象生命周期操纵艺术。在深入细节之前,下图展示了整个利用逻辑的核心路径,包括对象释放与抢占的顺序。

我们通过 exploit.c 将这一艺术转化为稳定的提权原语。
利用的第一步是在内核中构建一个受控的 Netfilter 环境并进行精密的堆内存布局。CVE-2023-32233 的核心矛盾在于:当一个匿名集合(Anonymous Set)被规则引用时,虽然其生命周期应与规则绑定,但攻击者可以通过特定的批处理序列,在规则被标记删除后、集合正式解绑前,利用逻辑缺陷显式地触发集合的删除流程。
为了实现这一目标,我们首先通过 pwn_prepare 函数建立 nftables 的逻辑框架。这包括创建一个独立的主表 testfirewall,并在其中定义两个关键链:pwn_lookup_chain (OUTPUT) 用于存放最初引用受害者集合的规则,而 pwn_log_chain (INPUT) 则预留给后续的堆喷射操作。这种分层隔离不仅条理清晰,更确保了漏洞触发过程不会干扰系统的正常网络功能,为后续的内存操作提供了一个稳定的“手术台”。
在实际调试过程中,为了验证堆排布的效果并精确定位受害者集合 s_a 的内存地址,我们可以在内核源码的 net/netfilter/nf_tables_api.c:6145 处打入断点。这里的目的是为了观察 elem.priv,它指向了我们目标集合对应的 nft_rhash_elem 内容。通过 GDB 的输出可以看到,此时处理的正是我们预设的目标集合 "s_a",这证实了基础环境构建的准确性。

紧随其后的是至关重要的堆整理(Heap Grooming)阶段。为了确保受害者对象落在可预测的内存位置并具备特定的溢出属性,我们执行 pwn_uaf_spray 函数。该函数通过循环喷射大量匿名集合来清理 SLUB 分配器的空闲列表(Freelist),迫使内核向伙伴系统申请全新的 Slab 页面。在这一喷射序列的中段,我们分配受害者集合 s_a(即代码中的 pwn_lookup_set)。此处的一个关键技巧是通过 userdata 将该对象的大小调整为 0x100 字节,使其精确落入 kmalloc-256 缓存池。
为了深入观察这个匿名集合在后续 Double Free 过程中的状态变化,我们需要利用硬件断点进行精细监控。通过命令 p &set->ops 获取 ops 指针格在内存中的绝对地址(例如 0xffff888102bf74c0),随后设置硬件监视点:watch *0xffff888102bf74c0。这样做是因为在触发漏洞时,受害者内存块会被释放并可能被另一个“竞争集合”(Race Set)占领。监控 ops 成员的变化,能够让我们在复杂的事务提交流程中,精准地捕捉到对象被重新占用或二次释放的瞬间。

环境准备的最后一步是通过 pwn_create_lookup_rule 创建一条引用 s_a 的 lookup 规则。此时,受害者集合 s_a 的引用计数(use)变为 1,它在逻辑上已正式成为规则的一部分。这一步是整个利用链条的“挂钩”点:只有建立了这种引用关系,我们才能在后续的事务处理中,利用 nf_tables_deactivate_set 函数的逻辑缺陷,强行触发对该集合的非法释放,从而打开通往 Use-After-Free 的大门。
单纯的并发竞争往往难以跨越微秒级的内核指令间隙,因此我们需要一种机制来人为地“拉伸”内核的执行时间。在攻击准备阶段,我们通过 pwn_delay_spray_set_elem 函数向一个名为 set_delay 的辅助集合中预先填充了海量元素(数量高达 0x300 * 0x800)。这个庞大的集合就像是一个蓄势待发的“时间锚”。
当我们在后续的攻击事务中请求删除这个集合时,内核被迫陷入漫长的内存释放循环中,必须逐个清理这些元素。这个精心构造的“停顿”操作,就像是插入齿轮中的一根撬棍,强行将内核的处理流程卡滞在特定的事务提交阶段。这产生了长达数十毫秒的执行延迟,为用户态的后续操作赢得了宝贵的“子弹时间”,使得原本稍纵即逝的竞争条件变得稳定可控,让我们有足够的时间从容地进行堆内存的抢占与布局。
万事俱备,利用流程进入了最关键的阶段。在正式触发 Double Free 之前,通过 GDB 调试观察 nft_rhash_elem 的内容,可以进一步确认受害者集合 s_a 的内存排布。如下图所示,elem.priv 准确指向了我们的目标匿名集合。

接下来的核心挑战在于如何在两次释放的微小间隙中完成内存抢占。我们的策略是:在 s_a 被第一次释放后,立即通过另一个事务创建一个“竞争集合”(Race Set)。这里有一个极其精妙的设计:我们将 race_set 的名称(name)构造为一个大小属于 kmalloc-cg-256 的字符串。由于 s_a 刚刚被释放,内核分配器极大概率会将 race_set->name 分配到 s_a 原有的内存槽位上。通过在 nf_tables_newset 函数处设置断点,我们可以精准地捕捉到这个覆盖瞬间。

此时原本存放 s_a 结构体的空间已经被 race_set 的名称字符串所覆盖。为了进一步监控这块内存的变化,我们获取 name 的地址(例如 0xffff888102af4f00)并设置硬件监视点:watch *0xffff888102af4f00。当利用程序继续执行 add chain 操作时,这个监视点会被断下,此时正好处于 memcpy 用户数据(udata)到内核空间的时刻。

执行 finish 结束 memcpy 后,观察内存状态可以清晰地看到原本的 name 字段已经被我们完全可控的 chain->udata 所填充。

至此,受害者集合 race_set 的 name 指针已经变成了一个指向“幽灵内存”的悬垂指针,且该内存中的数据已被我们占位。

随后,漏洞逻辑触发了对 s_a 的第二次释放。由于之前的重叠,内核实际上释放的是 race_set->name 的内存。这就在 kmalloc-cg-256 缓存池中制造了一个空闲槽位。为了引入更具“价值”的对象——nft_rule,我们在这里执行了一个关键的步骤:主动释放 race_set。通过调用 pwn_uaf_del_set,内核会显式释放 race_set->name。由于物理地址重叠,这实际上将 chain->udata 所在的内存块再次放回了空闲列表。

接下来的目标是让 nft_rule 对象占领这个空位。由于 nft_rule 及其表达式(如 counter)同样使用该缓存池,我们可以通过喷射规则来触发占位。当内核执行到规则插入 RCU 链表的关键步骤时,新的规则对象已经精准地坐落在之前被释放的内存块上。
