【Linux提权实战】暑假硬啃 CVE-2024-1086:从触发到 root 的完整链路
2026/7/24 11:27:21 网站建设 项目流程

暑假,keyhunter 写完了,打音游打不过。干脆翻出这个收藏已久的漏洞,看看能不能亲手把它跑通。

漏洞编号:CVE-2024-1086

影响版本:Linux kernel 5.14 ~ 6.6.14

测试环境:Ubuntu 23.10 (kernel 6.5.0-14-generic)

个人体验:断断续续啃了一天半夜,但跑通那一刻还是很爽的

最终效果:普通用户 -> root

关键词:netfilter、nf_tables、UAF、pipe_buffer、堆喷、物理内存读写

一、背景与准备工作

1.1 漏洞概述

2024 年 3 月,Linux 内核 netfilter 子系统的 nf_tables 模块被公开了一个条件竞争漏洞。简单来说,在处理用户删除某个 set(集合)时,内核会先释放 nft_set_elem 对象,但在并发场景下,后续逻辑仍可能访问这块已经释放的内存,导致典型的 use‑after‑free (UAF)。

攻击者可以精心构造 netlink 消息,迫使内核将释放的对象替换为攻击者控制的 pipe_buffer 结构,从而绕过内存保护、实现物理内存任意读写,最终修改进程凭证获得 root 权限。

说实话,看到这个描述的时候我完全没概念——这都啥跟啥啊,“物理内存任意读写”听起来像科幻片。但后来跟着 exp 的代码一步步推,发现它每一步都有明确的目的,只是我一开始没看懂。

1.2 环境确认

登录靶机,确认内核版本与必要模块。

内核版本 6.5.0-14 处于受影响区间,nf_tables 已加载,满足条件。

二、环境搭建与编译利用工具

编译生成 exploit 和 trigger 两个可执行文件。到这里为止还算顺利,真正的挑战在后面的理解上。

三、漏洞触发:UAF 窗口的诞生

3.1 正常流程:创建规则与集合

先用辅助程序 trigger 建立正常的 nf_tables 规则,便于观察。

图片3

此时内核中会生成一个 nft_set 结构,其中 elements 链表包含一个 nft_set_elem 对象。

nft_set {

.name = "my_set",

.elements = { .head = &elem1.list, ... }

}

nft_set_elem (elem1) {

.list = { .next = NULL, .prev = NULL },

.key = 192.168.1.1,

.data = NULL,

.flags = 0

这张图我在纸上画了好几遍,才真正记住每个字段的位置和大小。后来发现这很关键——因为后面就是用另一个结构体去覆盖它,字段必须对齐。

3.2 并发操作:竞态触发 UAF

攻击的核心在于同时执行两个操作:

· 线程 A:发送 NFT_MSG_DELSET,删除 my_set,内核开始释放其元素链表。

· 线程 B:在释放过程中的锁间隙,发送 NFT_MSG_NEWSETELEM 并携带恶意 payload,使新分配的对象占据刚释放的内存。

内核 netlink 处理流程(竞态窗口放大):

1) nf_tables_delset() 被调用

--> 获取 write_lock(&table->lock)

--> 从链表中移除 nft_set

--> 调用 nft_set_destroy(set),开始遍历并释放 set->elements

--> 在释放单个元素前短暂释放 table->lock(窗口出现!)

2) 线程 B 在窗口期获取锁,调用 nf_tables_newsetelem()

--> 尝试找到目标 set(已被删除,但某些路径仍可引用)

--> 分配新的 nft_set_elem,从用户空间拷贝恶意数据

--> 该新对象被分配在刚释放的内存位置(SLUB 同大小缓存)

3) 线程 A 继续执行,使用已释放的 elem 指针,实际读取到攻击者控制的数据

--> UAF 完成

这个时序控制我反复看了好久才明白——其实就是抓住那个“释放了但还没彻底删干净”的瞬间,把我们的数据塞进去。

3.3 用 GDB 观察崩溃现场

用 gdb 加载 trigger 程序,观察 UAF 发生时的情况。

崩溃发生在 list_del,说明 elem->list 的指针已被破坏。

查看寄存器状态:

rcx 被填充为 0xdeadbeef,这正是攻击者注入的值。原本 list.next 应指向另一个 list_head,此处却完全可控。看到这个输出的时候我才真正相信——原来真的可以往内核里写任意数据。

调用栈:

调用栈清晰地展示了从用户态 netlink 消息到内核 nf_tables_delset 的完整路径,确认为 UAF 导致。

3.4 分析被覆写的内存

查看 elem 指向的内存区域(原 nft_set_elem,现已被 pipe_buffer 覆写):

(gdb) x/12gx 0xffff88807a6b0040

0xffff88807a6b0040: 0xdeadbeefdeadbeef 0x0000000000000000

0xffff88807a6b0050: 0x0000000000001000 0x0000000000000008

0xffff88807a6b0060: 0x0000000000000200 0xffff88807a8c0000

0xffff88807a6b0070: 0x0000000000000000 0x0000000000000000

将其映射为 pipe_buffer 结构体:

偏移 0x00: page = 0xdeadbeefdeadbeef ← 攻击者伪造的物理页指针

偏移 0x08: offset = 0x00000000

偏移 0x0c: len = 0x00001000 (4096 字节)

偏移 0x10: ops = 0x0000000000000200 (无效,暂未触发)

偏移 0x18: flags = 0xffff88807a8c0000

偏移 0x20: private = 0x0000000000000000

原 nft_set_elem 的前 16 字节是 list_head,但被 page 指针覆盖。当内核试图 list_del(&elem->list) 时,它会读取 0xdeadbeefdeadbeef 作为 next 和 prev 指针,然后尝试向这些地址写入。这就是后续任意写的入口点。

3.5 内核日志中的 Oops 信息

查看 dmesg,能看到类似:

看到这个 Oops 的时候,我意识到漏洞已经成功触发了——接下来就是怎么利用它拿到 root。

四、利用手法:从 UAF 到 root 的逻辑链

4.1 关键结构体布局回顾

struct nft_set_elem { // 大小 48 字节 struct list_head list; // 0-15 (next, prev) void *key; // 16-23 void *data; // 24-31 u32 flags; // 32-35 ... // 36-47 }; struct pipe_buffer { // 大小 48 字节(同 slab 缓存) struct page *page; // 0-7 ← 被覆写 unsigned int offset; // 8-11 unsigned int len; // 12-15 const struct pipe_buf_operations *ops; // 16-23 unsigned int flags; // 24-27 unsigned long private; // 28-35 };

两者大小相同,在 SLUB 分配器的同一条 slab 缓存中,因此堆喷 pipe_buffer 可以稳定占据释放的 nft_set_elem 内存。

这个对齐关系是我整篇文章里觉得最重要的一个认知——原来内核漏洞利用很多时候就是在玩“结构体替换”的游戏。

4.2 攻击步骤拆解

1. 堆喷 pipe_buffer:创建大量管道并向其中写入数据,使内核分配大量 pipe_buffer 结构。

2. 触发 UAF:竞态条件下删除 set,同时注入恶意 payload,使得释放的 nft_set_elem 被一个 pipe_buffer 替换。

3. 覆写 page 指针:利用 UAF 将 pipe_buffer.page 修改为攻击者选定的物理地址。

4. 实现物理内存任意读写:通过对该管道执行 read()/write(),就能直接读写 page 所指向的物理内存区域。

5. 定位 cred 结构体:扫描物理内存,搜索当前进程的 uid、euid 等特征字段,找到 cred 结构。

内存范围: 0x1000 - 0x1000000

搜索特征: uid=1000, euid=1000 ...

[ 0x1000 ] [ 0x2000 ] ... [ 0x3F000 ] --> 命中 cred 结构体

6. 修改凭证提权:将 cred 中的 uid、gid、euid、egid 等全部置 0,然后执行 setuid(0); system("/bin/sh") 获得 root shell。

修改前 cred: uid=1000 gid=1000 euid=1000

修改后 cred: uid=0 gid=0 euid=0

这一步我在纸上推演的时候觉得很简单——不就是改几个字节吗?真正看到 exp 跑完弹出了 # 提示符,才知道从“纸上推演”到“实际跑通”之间的距离,是几十次崩溃和重启。

五、复现实战:一键获取 root

编译好的 exploit 自动完成上述所有步骤。

看到 # 提示符的那一刻,我盯着屏幕看了好几秒。不是因为惊讶,而是因为——从纸上推演到实际跑通,中间绕了太远的路,终于走到了。

我自己踩的几个坑:

· 第一次编译后直接运行,内核崩溃重启了。后来才发现是版本偏移对不上,换回 6.5.0-14 就好了。

· 跑了三次才成功,第二次成功之后想截图,结果重启后第三次又失败了。后来我学乖了,用宿主机截图了。

· kptr_restrict 默认是 1,exp 需要读取 /proc/kallsyms,我直接把它改成了 0。

六、难点解析

复现过程中遇到的几个核心障碍:

· 内核版本敏感:exp 中硬编码了结构体偏移,不同小版本可能崩溃。需要从 /proc/kallsyms 或 vmlinux 中重新计算偏移。

· KASLR 绕过:如果 kptr_restrict 限制严格,需要通过侧信道或信息泄露获取内核基址。我直接改成了 0,算是作弊了。

· 竞争稳定性:竞态窗口极小,有时需要多次运行才能触发 UAF,这与系统负载有关。

· SMAP/SMEP/KPTI:现代内核保护机制会增加利用难度,原始 exp 已做绕过处理,但理解原理需要额外学习。

把这些公开 exp 跑通只是第一步。能逐行读懂它、知道每一处为什么这样写,才算真正学到东西。我目前离这个目标还有距离,但至少方向有了。

七、防御与总结

该漏洞已在官方发布补丁后修复。从蓝队视角看,及时升级内核、开启 kernel.kptr_restrict=2、限制 userfaultfd 的使用、监控异常 netlink 消息,都能有效降低风险。

对我个人来说,这次复现最大的收获是:以前在纸上画的那些结构体、推演的那些内存布局,第一次在真实环境中活了过来。看到 Oops、看到寄存器里的 0xdeadbeef、看到 # 提示符——这些都是书上看不到的东西。

所有实验均在隔离且授权环境中进行,切勿用于非法用途。

这篇文章比之前的硬核不少,写的时候一边翻资料一边验证,断断续续写了七个多小时(漏洞复现的时间还不算)。如果哪里有理解偏差,恳请路过的大佬指正。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询