CVE-2021-22555 利用过程详解
原文:Andy Nguyen (theflow@, Google),CVE-2021-22555: Turning
\x00\x00into 10000$
https://google.github.io/security-research/pocs/linux/cve-2021-22555/writeup.html
PoC:https://github.com/google/security-research/tree/master/pocs/linux/cve-2021-22555
阅读路线:〇 速览 → 一 漏洞本体 → 二 选型 → 三 地图 → 四~九 十二步主线 → 十~十三 复盘 / 防御 / 面试。
| 章 | 内容 | 什么时候看 |
|---|---|---|
| 〇 一页速览 | 档案 + 一句话 + 十二步总表 | 先花 1 分钟建立全局 |
| 一 漏洞本体 | 触发路径、越界从哪来、原语能力 | 想懂"漏洞本身" |
二 选型:为什么是 msg_msg | 分配模型、两种链、三个"恰好" | 想懂"为什么这么打" |
| 三 全链地图 | 参与对象、尺寸接力、全链总览、读图约定 | 读十二步之前先有坐标 |
| 四 Stage 0 | 初始化 | — |
| 五 Stage 1(①–⑥) | 建链 → 挖洞 → 越界写 → 定位 → 造 UAF #1 | 主线 |
| 六 Stage 2(⑦–⑩) | 伪造 msg_msg → 两次越界读 → 升级 UAF #2 | 主线,最难 |
| 七 Stage 3(⑪) | pipe_buffer 占位 → 泄露 kbase | 主线 |
| 八 Stage 4(⑫) | 伪 ops → pivot → ROP | 主线 |
| 九 Stage 5 | setns + execve 逃逸 | 主线 |
| 十 三次 UAF 对比与关键数字 | 复盘、查数 | 面试前 |
| 十一 整链时序与 PoC 输出 | 时序图 + 实跑输出 | 对照验证 |
| 十二 修复、检测与防御 | 补丁 / 检测 / 加固 | — |
| 十三 面试复述要点 | 5 句背版 | 面试前 |
〇 一页速览
0.1 档案
| 项 | 内容 |
|---|---|
| 编号 | CVE-2021-22555 |
| 组件 | Linux Netfilter x_tables(net/netfilter/x_tables.c) |
| 类型 | 堆越界写(写 0),漏洞年龄 15 年 |
| 触发 | 32 位兼容模式下 setsockopt(IPT_SO_SET_REPLACE / IP6T_SO_SET_REPLACE),需 CAP_NET_ADMIN(可在 user+net namespace 里拿到) |
| 原语 | 在 slab 对象尾部越界写最多 0x4C 字节的 0x00,偏移可控 |
| 影响 | 本地提权 + 逃逸容器/K8s Pod(在 kCTF 拿 10000 , G o o g l e 配捐翻倍到 20000 ,Google 配捐翻倍到 20000 ,Google配捐翻倍到20000) |
| 绕过 | SMEP + SMAP + KASLR + CONFIG_SLAB_FREELIST_HARDENED |
| 修复 | v5.12-rc 起在分配时把对齐填充算进去 |
| 时间线 | 2021-04-06 报告 → 04-13 上游合入补丁 → 07-07 公开 |
0.2 一句话
内核在把 32 位兼容结构转成 64 位时,memset() 补的对齐字节没有算进 kmalloc 的分配长度,于是在相邻 slab 对象上留下"几个 0x00";作者用 msg_msg 把这 2 个 0x00 变成指针重定向 → UAF → 泄露堆地址 → 泄露内核基址 → 劫持 pipe_buffer->ops 做栈迁移执行 ROP,从容器里拿到 root shell。
0.3 十二步总表
| 步 | Stage | 动作 | 对象 / 缓存 | 留下的悬空指针 | 收获 |
|---|---|---|---|---|---|
| ① | 1 | msgsnd 4096B × 4096 | primary / kmalloc-4096 | — | 铺满 kmalloc-4096 |
| ② | 1 | msgsnd 1024B × 4096 | secondary / kmalloc-1024 | — | 每条队列成环 P_i ⇄ S_i |
| ③ | 1 | msgrcv 掉每第 1024 条 primary | primary / 4096 | — | 4 个 kmalloc-4096 空洞 |
| ④ | 1 | setsockopt 触发越界写 | xt_table_info / 4096 | — | P_4.next:&S_4 → &S_3 |
| ⑤ | 1 | msgrcv(MSG_COPY) 比对 tag | — | — | fake_idx / real_idx |
| ⑥ | 1 | msgrcv 从真队列取走 S_3 | secondary / 1024 | P_4.m_list.next | UAF #1 |
| ⑦ | 2 | unix socket 喷 1024B 占位 | sk_buff 数据区 / 1024 | — | 一块内容全控的"假 msg_msg" |
| ⑧ | 2 | 放大 m_ts 后越界读 | — | — | 邻居 S_x.next = &P_x |
| ⑨ | 2 | 段链 next 改成 &P_x 再读 | — | — | fake 自身地址(堆地址已知) |
| ⑩ | 2 | m_list 自环后 msgrcv 释放 | fake / 1024 | sk_buff 数据指针 | UAF #2(可释任意 kmalloc-1024 对象) |
| ⑪ | 3 | 喷 pipe + read(socket) | pipe_buffer[] / 1024 | pipe->bufs | anon_pipe_buf_ops ⇒ kbase |
| ⑫ | 4 | 喷伪造 pipe_buffer + close(pipe) | 伪造 pipe_buffer / 1024 | — | ops->release → ROP → root |
全链只做了一件事:把"改错一个指针"(④)换来的"一次 free 机会"(⑥),借
sk_buff升级成"能 free 任意对象"(⑩),再把受害者换成带函数指针的pipe_buffer(⑪⑫)。
一 漏洞本体
1.1 触发路径
用户态 32 位(compat)程序
└─ setsockopt(fd, SOL_IP, IPT_SO_SET_REPLACE, &repl, size)
└─ do_ipt_set_ctl() net/ipv4/netfilter/ip_tables.c
└─ translate_compat_table()
├─ xt_compat_match_from_user()
├─ xt_compat_target_from_user() ← 漏洞在这里
└─ xt_alloc_table_info() ← 分配(少算了 pad)
1.2 错在哪
// net/netfilter/x_tables.c
void xt_compat_target_from_user(struct xt_entry_target *t, void **dstptr,
unsigned int *size)
{
const struct xt_target *target = t->u.kernel.target;
struct compat_xt_entry_target *ct = (struct compat_xt_entry_target *)t;
int pad, off = xt_compat_target_offset(target);
u_int16_t tsize = ct->u.user.target_size;
...
t = *dstptr;
memcpy(t, ct, sizeof(*ct));
if (target->compat_from_user)
target->compat_from_user(t->data, ct->data);
else
memcpy(t->data, ct->data, tsize - sizeof(*ct));
pad = XT_ALIGN(target->targetsize) - target->targetsize; // ← 补到 8 字节对齐
if (pad > 0)
memset(t->data + target->targetsize, 0, pad); // ← 越界写 0
tsize += off;
t->u.user.target_size = tsize;
...
*size += off;
*dstptr += tsize;
}
要点:
xt_table_info的分配长度是按 32 位结构长度 +off(xt_compat_target_offset) 算的,pad没有计入。- 但写入时按 64 位结构的真实
targetsize定位,再往后补pad字节 0 —— 这pad字节就落到下一个 slab 对象上。 targetsize用户不可直接改,但可以按名字换 target(TCPMSS/TTL/NFQUEUE/NFLOG…),从而改变pad的宽度;再通过在xt_entry_match与xt_entry_target之间加 padding,可以把越界偏移往后再推。- 作者找到的最大可用组合是
NFLOG:struct xt_nflog_info里有char prefix[64],可把越界偏移推到 0x4C。
struct xt_nflog_info {
__u32 len; __u16 group; __u16 threshold;
__u16 flags; __u16 pad;
char prefix[64]; // ← 长度可被用户拉长,用来调偏移
};
1.3 分配端:为什么是 kmalloc-512 ~ 8192
struct xt_table_info *xt_alloc_table_info(unsigned int size)
{
size_t sz = sizeof(*info) + size;
if (sz < sizeof(*info) || sz >= XT_MAX_TABLE_SIZE) return NULL;
info = kvmalloc(sz, GFP_KERNEL_ACCOUNT); // ← 注意:ACCOUNT
memset(info, 0, sizeof(*info));
info->size = size;
return info;
}
- 最小
sz > 0x100⇒ 最小落在 kmalloc-512,最大可用到 kmalloc-8192。 - 用
GFP_KERNEL_ACCOUNT分配 ⇒ 攻击对象也必须是GFP_KERNEL_ACCOUNT(5.9 之前 memcg 用的是独立 slab 缓存,混用就永远撞不上)。
1.4 原语评估:为什么"写 0"很难打,作者怎么选的
| 常见打法 | 是否可行 | 原因 |
|---|---|---|
| 打引用计数 | ✗ | 前 0x4C 字节内找不到合适的、带 refcount 的对象 |
| 打 free list 指针 | ✗ | 内核已开 CONFIG_SLAB_FREELIST_HARDENED(CVE-2016-6187 那套失效) |
| 打结构体里的指针 | ✓ 但要巧 | 一次写 4 字节 0 太狠:0xffff91a49cb7f000 → 0xffff91a400000000(废)。若只写块首 2 字节 0,则 0xffff91a49cb7f000 → 0xffff91a49cb70000(仍在同一 64 KB 区域内,大概率仍有效) |
结论:把原语降级成"相邻对象起始处的 2 字节清零"。 于是问题变成:什么对象的第 0~1 字节是个指针、且清 2 字节后还能指向"合法且可控"的东西?
二 选型:为什么是 msg_msg
2.1 分配模型(ipc/msgutil.c: alloc_msg())
alen = min(len, DATALEN_MSG); // DATALEN_MSG = 4096 - 48 = 4048
msg = kmalloc(sizeof(*msg) + alen, GFP_KERNEL_ACCOUNT); // 头段
while (len > 0) { // 超出部分切成段链
alen = min(len, DATALEN_SEG); // DATALEN_SEG = 4096 - 8 = 4088
seg = kmalloc(sizeof(*seg) + alen, GFP_KERNEL_ACCOUNT);
*pseg = seg; seg->next = NULL; pseg = &seg->next; len -= alen;
}
struct msg_msg { // 头部 48 字节
struct list_head m_list; // 0x00: next, prev —— 指向"队列里下一条消息"
long m_type; // 0x10
size_t m_ts; // 0x18: 消息长度,决定 msgrcv 拷多少给用户
struct msg_msgseg *next; // 0x20: 指向下一段(不是队列里的下一条!)
void *security; // 0x28
/* 数据紧随其后 */
};
struct msg_msgseg { struct msg_msgseg *next; };
2.2 先分清两种"链"
头里有两个都叫 next 的东西,全篇的混淆几乎都来自这里:
| 链 | 结构 | 谁维护 | 何时存在 | 在本漏洞里的角色 |
|---|---|---|---|---|
① m_list 队列环形链 | struct list_head(双向环形) | ipc/msg.c 的消息队列 | 每条消息都在链上,单条消息是"自环" | 被破坏的目标:2 字节清零打在 m_list.next(offset 0) |
② next 段链 | msg_msg.next → msg_msgseg.next → … | alloc_msg() 超长分段 | 仅当 payload > DATALEN_MSG(4048) 才存在 | 本漏洞两条消息都 ≤ 4048 ⇒ 段链天然为 NULL,正好当成"可控 next 指针"来伪造 |
破坏
m_list.next不需要"链是好的":msgrcv只在真正删除消息时才 unlink,MSG_COPY偷看只顺着m_list.next读 —— 所以指针指错时用"偷看"就能察觉,且不会立刻崩。连同
sk_buff内部那个指针,全篇一共出现 三个next,对照表见 §6.3。
2.3 三个"恰好"
m_list.next在 offset 0 —— 正好是 2 字节清零能打到的地方,而且它是"队列内下一条消息"的指针,改它不会破坏对象自身的合法性。m_ts可被改成更大的值 ——msgrcv按m_ts决定拷贝长度 ⇒ 天然的信息泄露原语。- 用
GFP_KERNEL_ACCOUNT—— 和xt_table_info同一套 kmalloc 缓存,能相邻。
三 全链地图
读十二步之前,先建立三样坐标:谁参与、凭什么能互相顶替、图怎么画。
3.1 参与对象与"尺寸接力"
| 对象 | 大小 | 缓存 | 属性 | 出现在哪几步 |
|---|---|---|---|---|
primary msg_msg | 48 + 4048 = 4096 | kmalloc-4096 | GFP_KERNEL_ACCOUNT | ①③④⑤ |
secondary msg_msg | 48 + 976 = 1024 | kmalloc-1024 | GFP_KERNEL_ACCOUNT | ②③⑥⑦⑧⑨⑩ |
xt_table_info | sizeof + size,最小 > 0x100 | kmalloc-512 ~ 8192(本例 4096) | GFP_KERNEL_ACCOUNT | ④ |
sk_buff 数据区 | 1024 | kmalloc-1024 | GFP_KERNEL_ACCOUNT | ⑦⑧⑨⑩⑪ |
pipe_buffer[] | 16 × 40 = 640 | kmalloc-1024 | GFP_KERNEL_ACCOUNT | ⑪⑫ |
全链能接上的唯一原因:尺寸卡死在 1024。 secondary、sk_buff 数据区、pipe_buffer[] 三者必须落进同一个 kmalloc-1024 缓存,才能互相顶替同一个槽位 —— 这就是从 ② 一路传到 ⑫ 的"接力棒"。另一个隐含前提是所有对象都用 GFP_KERNEL_ACCOUNT(5.9 之前 memcg 用独立 slab 缓存,混用就永远撞不上)。
3.2 全链总览
3.3 读图约定
下面五张图都按同一套约定画:
- 每个竖块 = 一块内核对象;块顶的蓝色横条 =
msg_msg头里的m_list.next(offset0x00);箭头 = 这个指针指向谁。 - 左列 = kmalloc-4096(primary),右列 = kmalloc-1024(secondary);两列是不同 slab,同一列上下相邻 = 地址相邻。
- 虚线块 = 已
kfree/ 悬空;红框 = 已被篡改或已失效。 - 图源
img/cve22555_chains.drawio(可编辑)。
四 Stage 0:准备
unshare(CLONE_NEWUSER|CLONE_NEWNET) → 在新 netns 里拿到 CAP_NET_ADMIN
socketpair(AF_UNIX) × N → 后续 spray sk_buff
msgget() × 4096 → 4096 个消息队列(队列号 = 后续的 tag)
五 Stage 1:制造 UAF(步 ①–⑥)
目标:把一条 primary 的 m_list.next 指到"别人家的 secondary"上,再 free 掉那条 secondary —— primary 侧就留下一个自己不知道的悬空指针(UAF #1)。
5.1 ① 喷 A、② 喷 B:把两条链建起来
- 4096 条 primary 落在 kmalloc-4096、4096 条 secondary 落在 kmalloc-1024,两块缓存互不干扰。
- ① 每个队列只放 1 条 primary ⇒
next = prev = 自己(自环);4096 条把 kmalloc-4096 铺满,彼此相邻。 - ② 再给每个队列发 1 条 secondary ⇒ 队列成环
P_i ⇄ S_i。P_i.next就是第 ④ 步要清零的那个指针。

5.2 ③ 挖洞、④ 越界写:把 next 改到邻居
- ③ 每第 1024 条 primary 被
msgrcv掉 ⇒ kmalloc-4096 空出 4 个槽位。 - ④
setsockopt让xt_table_info(同缓存)落进洞里,它紧邻的下一条 primary 就是受害者P_4;xt_table_info尾部溢出的 2 字节0x00正好打在P_4.next上。

为什么只写 2 字节,而且还能"指对":
P_4.next原本指向S_4(kmalloc-1024 的段)。清零低 2 字节 = 把指针"向下对齐到 64 KB 边界"。- 这一段 64 KB 里全是 message 对象 ⇒ 大概率仍指向另一条 secondary(比如
S_3)。 - 若写满 4 字节,
0xffff91a49cb7f000会变成0xffff91a400000000(区域性作废);只写 2 字节则落到0xffff91a49cb70000,仍在同一 64 KB 区域内。 - 成功率:只有"低 2 字节原本就是 0"才失败 ⇒ 1 − 1024 65536 ≈ 98.4 % 1 - \frac{1024}{65536} \approx 98.4\% 1−655361024≈98.4%。
5.3 ⑤ 打 tag:怎么知道撞上了哪两条
每条消息(primary 与 secondary 的数据区)都写入自己队列的下标 [0, 4096):
用 msgrcv(MSG_COPY) 遍历所有队列,比较 primary 数据里的 tag 与 secondary 数据里的 tag:
tag 一致 → 正常
tag 不一致 → 找到了!
primary 的 tag = fake_idx(指向了"别人家的 secondary"的假队列)
secondary 的 tag = real_idx(这条 secondary 真正的队列)
5.4 ⑥ 制造 UAF #1
msgrcv(real_idx 队列, 取下 secondary) → free(S_3),kfree 掉 kmalloc-1024 那块
但 fake_idx 队列里的 P_4.next 仍指向它 → 悬空引用(UAF #1)

- 左半:
P_4.next已成悬空、S_3已被kfree;队列 3 恢复自环(整条链重新自洽,所以不会立刻崩)。 - 右半:第 ⑦ 步用 unix socket 的
sk_buff数据区(同样 1024B)把这个槽位抢回来,内容由我们逐字节决定 —— 见 §6.1。
六 Stage 2:绕过 SMAP(步 ⑦–⑩)
SMAP 禁止内核直接读写用户地址,所以不能把 next 指向用户态伪造的结构;必须"用内核堆里的东西伪造内核堆里的东西"。
6.1 ⑦ 用 unix socket 精确占位
sendmsg() 到 socketpair 时,sk_buff 的数据缓冲区同样走 GFP_KERNEL_ACCOUNT,kmalloc(1024) 的 payload 里可以逐字节构造内容。喷一堆 1024 字节,把刚才 free 掉的槽位抢回来,内容伪装成 struct msg_msg:
| 字段 | 伪造值 | 目的 |
|---|---|---|
m_list.next/prev | 0x41414141 | 故意给"非法指针"⇒ 之后无法 unlink/再释放(这是想要的,避免二次 free 提前触发检测) |
m_type | 任意 | — |
m_ts | 4048(DATALEN_MSG) | 放大长度,让 msgrcv 越过原 1024 边界读 |
next | NULL | 单段 |
6.2 第 ⑧ 步到底在读什么(关键一步)
copy_msg() 的拷贝只由一个长度参数驱动,而且从头部之后开始拷:
// ipc/msgutil.c
len = src->m_ts; // ← 伪造时改成 4048
alen = min(len, DATALEN_MSG); // DATALEN_MSG = 4096 - 48 = 4048
memcpy(dst + 1, src + 1, alen); // src+1 = offset 0x30,拷 4048 字节
src 是 fake 对象——它是 kmalloc-1024 里的一个槽,总共只有 1024 字节。所以内核实际读的范围是:
| 内核读的区间 | 内容 | 归属 |
|---|---|---|
[fake+0x00, fake+0x30) | 48B 头(m_list / m_type / m_ts / next / security) | 自己 |
[fake+0x30, fake+0x400) | 976B 数据区 | 自己 |
[fake+0x400, fake+0x1000) | 3072B 越界区 | 别人 |
越界区的开头 [fake+0x400, fake+0x430) 正好是同 slab 里紧挨着的下一条 secondary S_x 的 48 字节头。换算到用户缓冲区(偏移要减去跳过的 0x30):
| 用户缓冲区偏移 | 拿到的是 |
|---|---|
| 0 – 975 | fake 自己的数据(没用) |
| 976(0x3D0)– 1023 | S_x.m_list.next / m_list.prev(两个内核指针) |
| 1024 起 | S_x.m_type / m_ts / next / security … |
两个必须理解的点:
- 为什么不崩:kmalloc-1024 的 slab 是一整块连续页(几页、几十个对象),多读 3 KB 仍落在同一 slab 的页里,既不跨未映射页,也不会触发任何长度检查——
memcpy只是照单搬字节。 - 为什么这个指针有用:
S_x是队列 x 里的第二条消息,而队列是环形双向链P_x ⇄ S_x,所以S_x.m_list.next必然等于&P_x。⇒ 第一次泄露拿到 primary 的地址。
6.3 第 ⑨ 步:把"越界读"变成"任意地址读一跳"
先分清三个都叫 next 的东西——这是全篇最容易混的地方:
| 字段 | 位置 | 谁用它 | fake 里填什么 |
|---|---|---|---|
m_list.next / prev | fake+0x00 / +0x08 | msgrcv 真正删除消息时的 unlink | ⑦ 填 0x41414141(故意不可 unlink ⇒ 不会被误释放);⑩ 才改成 &fake 自环(可以 unlink ⇒ 能释放) |
msg_msg.next(类型是 struct msg_msgseg *) | fake+0x20 | copy_msg() 拷完头段后顺着它继续拷 | ⑦ 是 NULL;⑨ 改成 &P_x ← 就这一步改它 |
sk_buff 里指向数据区的指针(head/data) | 在 sk_buff 结构里,不在这块内存中 | socket 收发时读写 | 从头到尾没动过,一直指着这块 1024B —— 这才是"悬空引用"的来源 |
所以 ⑨ 改的不是 sk_buff 的指针,也不是队列里的 m_list(那个到 ⑩ 才动),而是 fake msg_msg 头里 offset 0x20 的段链指针 next:把 NULL 换成 &P_x。
改内容的方法(这块内存不在我们手上,只能"释放 → 重喷"):
read()一下 unix socket ⇒sk_buff释放它的数据区,这块 1024B 重新空出来(但sk_buff和P_4.next都还指着它);- 再
sendmsg()喷 1024 字节新内容(next = &P_x、m_ts加大)⇒ 同一个槽位被重新占住; - 再从 fake 队列
msgrcv(MSG_COPY),内核这次就按新内容走。
copy_msg 拷满 4048 字节后会顺着 next 继续拷,于是 跳到 P_x+8 处接着读(跳过 msg_msgseg.next 那 8 字节):
fake 头 → fake 数据 4048B → next = &P_x → 从 P_x+8 继续拷贝
读出来的就是 P_x 的 m_list.prev / next。而 P_x.m_list.next = &S_x —— 而 S_x 正是第 ⑧ 步读到的、紧挨在 fake 后面(fake+0x400) 的那个对象。于是:
& f a k e = & S x − 0 x 400 \&fake = \&S_x - 0x400 &fake=&Sx−0x400
原文那句 “Subtracting 1024 from that address, we finally have the address of the fake message” 说的就是这个。
[+] kheap_addr: ffff91a49cb7f000 ← 第 ⑧ 步:相邻 secondary / primary 地址
[+] kheap_addr: ffff91a49c7a0000 ← 第 ⑨ 步:得到 fake 自身地址
一句话记住:⑧ 用"读得多"泄漏邻居,⑨ 用"next 指哪读哪"确认自己。两步合起来,把"我只知道有个指针指错了"变成"我知道自己在堆上的确切地址"。
6.4 ⑩ 把 UAF #1 升级成 UAF #2
现在伪造对象的地址已知,就可以把 m_list.next = m_list.prev = 自己(自环)⇒ 这条伪造消息可以被安全地 unlink / 释放了。
但占位它的是 sk_buff 的数据缓冲区,sk_buff 里还留着指向它的指针。于是 msgrcv 把它 free 掉之后,sk_buff 侧就成了悬空引用:

- 左图(⑦⑧⑨):
m_ts = 4048让内核从 fake 往外多搬 3072B,抄到邻居S_x.next = &P_x;再把 fake 头 offset0x20的段链next(不是sk_buff的指针,也不是m_list)改成&P_x顺着段链折回,读到P_x.next = &S_x,于是fake = &S_x − 0x400(推导见 §6.2 / §6.3)。 - 右图(⑩):
m_list改成自环 ⇒ 这条 fake 能被安全 unlink / 释放;但sk_buff仍指着它 ⇒ UAF #2。
同一块内存的两个身份(图 D 的本质):
⑧⑨⑩ 三步全都围绕同一块 1024B 内存,而它在两个子系统眼里是两个完全不同的东西:
| 视角 | 这块内存是什么 | 谁持有指向它的指针 | 能对它做什么 |
|---|---|---|---|
socket / sk_buff | sk_buff 的数据区(skb->head) | sk_buff —— 全程没被改过 | 只能 sendmsg 重写内容 |
消息队列 / msg_msg | 一条 msg_msg(48B 头 + 976B 数据) | P_4.m_list.next —— 第 ④ 步被改出来的悬空指针 | msgrcv(MSG_COPY) 读(⑧⑨);msgrcv 删除 ⇒ kfree(⑩) |
所以三者的分工是:
P_4.m_list.next只是"把手":它让我们能用标准msgrcv去操作这块内存。它本身不是"受害者",也没被谁再篡改一次。- 内容是 fake
msg_msg:m_ts决定拷多少(⑧ 放大到 4048)、next(+0x20,段链)决定拷到哪(⑨ 指&P_x)、m_list决定能不能被释放(⑩ 改成自环)。 sk_buff的指针始终不变 —— 正因为不变,⑩ 用msg_msg路径kfree掉这块内存后,sk_buff才成了悬空引用。
两条"释放"路,别搞混:
| 目的 | 手段 | 谁真的释放这块内存 | sk_buff 的处境 |
|---|---|---|---|
| 想换内容(⑧→⑨、⑨→⑩ 之间重造 fake) | 先 read(socket),再 sendmsg 重喷 | sk_buff 自己(read 时连带销毁旧 skb) | 旧 sk_buff 消失,新喷的 sk_buff 指向同一个槽位 |
| 想留悬空引用(⑩) | msgrcv(fake 队列) | msg_msg 路径(要求 m_list 能 unlink) | 仍然有效、仍指向那块已释放内存 ⇒ 悬空 |
P_4.m_list.next 这把"把手"的全部价值就在这里:它给了我们第二条释放路径 —— 用 msg_msg 的 API 去 kfree 一块 sk_buff 正在用的内存,于是造出一个 sk_buff 侧完全不知道的悬空引用。
一句话:不是 msg_msg 篡改了
sk_buff的指针,而是两者共用同一块内存 —— msg_msg 是"演员",sk_buff是不知情的"观众",P_4.m_list.next是让演员上台的那张"门票"。
为什么第 ⑩ 步是"更好的 UAF"(这一步是全链的枢纽):
之前(free 掉一个 msg_msg) | 现在(free 掉一个 sk_buff 数据区) |
|---|---|
| 只能释放满足"前两个成员是可写指针"的对象 | 可以释放这个 slab 上的任意对象 |
| 且必须能构造合法 unlink | 无需任何头部约束 |
七 Stage 3:绕过 KASLR(步 ⑪)
7.1 为什么是 pipe_buffer
要求:GFP_KERNEL_ACCOUNT、落在 kmalloc-1024(和 secondary 同尺寸)、结构里有我们能用的指针。
// fs/pipe.c
pipe->bufs = kcalloc(pipe_bufs, sizeof(struct pipe_buffer), GFP_KERNEL_ACCOUNT);
// 16 * 40 = 640 → kmalloc-1024 ✓
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops; // ← 间接函数指针
unsigned int flags;
unsigned long private;
};
static const struct pipe_buf_operations anon_pipe_buf_ops = { // 在 .data
.release = anon_pipe_buf_release, ...
};
写 pipe 时 pipe_buffer->ops = &anon_pipe_buf_ops。.data 与 .text 的相对偏移在给定内核镜像里固定 ⇒ 拿到 anon_pipe_buf_ops 就等于拿到 kbase。
7.2 泄露
spray 大量 pipe 并 write() ← 抢占悬空 sk_buff 数据区
read(unix socket) ← 通过悬空的 sk_buff 读回整块内容
→ 泄露 anon_pipe_buf_ops 地址 → kbase
[+] anon_pipe_buf_ops: ffffffffa1e78380
[+] kbase_addr: ffffffffa0e00000 (差值 0x1078380,镜像内固定)
顺带:read() 会把 sk_buff 的数据区再 free 一次 ⇒ 又出现一个悬空 pipe_buffer 槽位,且地址已知。
八 Stage 4:代码执行(步 ⑫)
8.1 结构关系
因为漏洞本身让我们知道堆地址,可以把假 pipe_buf_operations 和 ROP 链一起放在同一块可控内存里。
8.2 ROP 链做什么
- 先把
RBP存到一个内核 scratch 地址(为了最后能干净地返回); commit_creds(prepare_kernel_cred(NULL))—— 装 root 凭据;switch_task_namespaces(find_task_by_vpid(1), init_nsproxy)—— 把自己切到 init 进程的 namespace;- 恢复
RBP并返回 ⇒free_pipe_info()正常返回,不崩。
8.3 触发
close(所有 pipe fd) → pipe_release() → pipe_buf_release() → ops->release(...) ← 落地执行
为什么要"尽快栈迁移":JOP gadget 难找且脆弱。先用一个 pivot gadget 把栈搬到我们控制的内存,然后跑完整的 ROP 链最稳。
- 左半(⑪):
pipe_buffer[]占位(16×40=640B,同落 kmalloc-1024)⇒ 通过悬空的sk_buff读回ops⇒ kbase。 - 右半(⑫):喷回伪造
pipe_buffer→ops指向伪造的pipe_buf_operations→release是个 pivot gadget → 落到堆上的 ROP 链 →commit_creds+switch_task_namespaces。

九 Stage 5:逃逸容器、拿 root shell
setns(open("/proc/1/ns/mnt", O_RDONLY), 0);
setns(open("/proc/1/ns/pid", O_RDONLY), 0);
setns(open("/proc/1/ns/net", O_RDONLY), 0);
char *args[] = {"/bin/bash", "-i", NULL};
execve(args[0], args, NULL);
(此时已是 root,才有权限 setns 到 PID 1 的 namespace —— 从而跳出 Pod。)
十 三次 UAF 对比与关键数字
前面按 Stage 讲"怎么做",这里按"哪条指针悬空了"横向对比 —— 面试最常追问的就是这个。
10.1 三次 UAF 的区别(最容易被追问)
| UAF#1(步 ⑥) | UAF#2(步 ⑩) | UAF#3(步 ⑪) | |
|---|---|---|---|
| 悬空的是 | P_4.m_list.next(链表指针) | sk_buff 的数据指针 | pipe->bufs 数组指针 |
| 谁能触发它 | msgrcv(fake 队列) | read() unix socket | close(pipe) |
| 能顶替什么 | 只能顶替 msg_msg(头部要合法) | 任意 kmalloc-1024 对象 | 只能顶替 pipe_buffer[] |
| 用途 | 起手(把指针引导到我们手里) | 换受害者(换成带函数指针的对象) | 落地(把假 ops 塞进去) |
10.2 关键数字速查
| 数字 | 含义 |
|---|---|
DATALEN_MSG = 4048 | 4096 - sizeof(struct msg_msg),m_ts 被改大后的泄露上限 |
DATALEN_SEG = 4088 | 4096 - sizeof(struct msg_msgseg) |
sizeof(struct msg_msg) = 48 | list_head 16 + m_type 8 + m_ts 8 + next 8 + security 8 |
| 4096 / 1024 | primary / secondary 消息总长(→ kmalloc-4096 / kmalloc-1024) |
| 4096 | 消息队列个数(= tag 空间) |
| 每 1024 条挖 1 个洞 | 洞中放 xt_table_info,让它紧邻一条 primary |
| 2 字节 | 实际清零的字节数(不是 4 字节,为了保 64 KB 内偏移) |
1 - 1024/65536 ≈ 98.4% | 重定向命中"合法 secondary"的概率 |
0x4C | NFLOG target 下可达到的最大越界偏移 |
kmalloc-512 ~ kmalloc-8192 | xt_table_info 可选的目标尺寸区间 |
1024 | pipe_buffer 数组(16×40=640)落入的缓存 |
十一 整链时序与 PoC 输出
PoC 实跑输出:
[+] STAGE 1: Memory corruption
[*] Triggering out-of-bounds write...
[*] Searching for corrupted primary message...
[+] fake_idx: ffc
[+] real_idx: fc4
[+] STAGE 2: SMAP bypass
[+] kheap_addr: ffff91a49cb7f000
[+] kheap_addr: ffff91a49c7a0000
[+] STAGE 3: KASLR bypass
[+] anon_pipe_buf_ops: ffffffffa1e78380
[+] kbase_addr: ffffffffa0e00000
[+] STAGE 4: Kernel code execution
[+] Root privileges gained.
[+] STAGE 5: Post-exploitation
root@theflow:/# id
uid=0(root) gid=0(root)
十二 修复、检测与防御
- 修复思路:把
pad计入分配长度(xt_compat_target_from_user()与xt_alloc_table_info()的size计算对齐);上游补丁见 2021-04-13 合入。 - 检测:内核日志中
setsockopt大量失败/xt_table_info分配异常;进程在 user+net ns 中频繁做msgsnd大批量消息 +socketpair+pipe组合(典型 spray 指纹)。 - 防御:
- 允许 unprivileged user namespace 是这类漏洞的"启动条件"(
kernel.unprivileged_userns_clone=0或 seccomp 禁unshare(CLONE_NEWUSER)); - 容器侧用 gVisor / Kata 等强隔离;
CONFIG_SLAB_FREELIST_HARDENED、CONFIG_SLAB_FREELIST_RANDOM、CONFIG_INIT_ON_ALLOC_DEFAULT_ON(后者能把"伪造对象"这条路堵掉一大半);- 及时打补丁(5.12-rc 之后)。
- 允许 unprivileged user namespace 是这类漏洞的"启动条件"(
十三 面试复述要点(5 句)
- 漏洞本质:compat 转换时
memset的pad没进化分配长度 → 相邻对象尾部被写 0。 - 原语:可控偏移的少量
0x00越界写,GFP_KERNEL_ACCOUNT,slab 512~8192。 - 关键创新:
msg_msg的m_list.next在 offset 0 —— 2 字节清零就能把指针"向下对齐",重定向到同 64KB 区内另一条消息,从而两个队列共享一个对象 → UAF。 - 泄露链:伪造
msg_msg放大m_ts⇒ 越界读 ⇒ 堆地址;再用pipe_buffer->ops → anon_pipe_buf_ops(.data,距 kbase 固定)⇒ 内核基址。 - 执行链:借
sk_buff悬空指针把 UAF 泛化成"可释放任意对象",劫持pipe_buffer->ops->release,pivot 到 ROP,commit_creds+switch_task_namespaces,setns逃逸容器。
3379



被折叠的 条评论
为什么被折叠?



