Linux 内核这次的漏洞,表面上很小:一个 freelist 计数器少了上界检查,越界写 4 个字节。
但有些安全事故,坏就坏在“小”。写进去的不是任意地址、任意值,只是一个 0 到 N-1 的 u32 索引。听起来像没什么用。结果在 io_uring 新的 ZCRX 路径里,它被硬件、权限、slab 布局和老牌提权链条接上了电。
事故不大,但边界很清楚
ZCRX 是 Linux 6.15 引入的 io_uring 零拷贝接收子系统。它让网卡把数据直接 DMA 到用户注册的内存区域,减少一次拷贝。性能很好,生命周期也更复杂。
漏洞点在一个很朴素的结构:
| 项目 | 事实 |
|---|---|
| 影响版本 | Linux 6.15–6.19,且未合入 commit 770594e |
| 内核配置 | 需要 CONFIG_IO_URING_ZCRX=y |
| 硬件条件 | 需要真实 ZCRX-capable NIC,如 mlx5、ice、nfp 等 |
| 权限条件 | 需要 CAP_NET_ADMIN |
| 触发路径 | NIC teardown / page_pool_destroy,不是关闭 io_uring fd |
| 漏洞本质 | freelist[] 用 u32 存 niov 索引,free_count 缺少上界检查 |
ZCRX 把接收区域切成 4KB slot,每个 slot 对应一个 net_iov。空闲 slot 的索引放在 freelist[] 里,free_count 表示栈深度。
问题是,回收 niov 时直接写:free_count 当下标,写完递增。没有检查 free_count 是否已经等于 num_niovs。
单看这行代码,像低级错误。但真正让它成立的,是两个回收路径叠在一起:正常接收完成会把 niov 还回 freelist;NIC 关闭、队列重配时,page_pool_destroy 又会清扫一遍仍带引用的 niov。两个路径之间有窗口,计数可能被多推一次。
free_count 一旦超过数组长度,下一次 push 就写到 freelist 后面的 slab 对象里。
这不是远程洞。也不要把它理解成“普通用户随便打 Linux”。它要真实支持 ZCRX 的网卡,要内核打开对应配置,要 CAP_NET_ADMIN,还要走 NIC teardown。多数发行版内核未必默认满足这些条件,容器里的 capability 配置也差异很大。
但受限,不等于不重要。
小整数为什么能变成大问题
这次最反常的地方在这里:越界写的值并不强。它只是 niov_idx,一个小整数。不是任意写。
可攻击者能选择注册区域大小。区域大小决定 num_niovs,num_niovs 决定 freelist 的分配大小,分配大小又决定落在哪个 kmalloc slab cache。
换句话说,area size 不只是“我要多少缓冲区”,它还变成了“我要进入哪个堆场景”。
| num_niovs | freelist 大小 | 典型 slab | 写入值范围 |
|---|---|---|---|
| 8 | 32B | kmalloc-32 | 0–7 |
| 16 | 64B | kmalloc-64 | 0–15 |
| 32 | 128B | kmalloc-128 | 0–31 |
| 64 | 256B | kmalloc-256 | 0–63 |
| 128 | 512B | kmalloc-512 | 0–127 |
这就把“4 字节小整数”变成了堆布局问题。只要旁边对象挑得对,低 32 位被改成 7、15、31,也可能足够破坏链表指针、引用计数或长度字段。
原文利用链选择的是 msg_msg 这类经典内核堆对象:先通过堆喷把它放到 freelist 旁边,再让越界写污染对象头,后续配合信息泄露、KASLR 绕过和 modprobe_path 路径完成提权。
这里不该神化这个 primitive。它不是一把万能钥匙。它依赖 slab 可控性、相邻对象、时序窗口和后续堆喷链条。真正危险的不是“写了 4 字节”,而是内核里仍然存在大量可以被小整数撬动的状态机。
修复也很直白:commit 770594e 给 free_count 加了 free_count >= num_niovs 检查。双回收窗口仍可能出现,但第二次 push 会被丢弃,不再写出数组边界。
这类补丁看起来像补一颗螺丝。可螺丝掉的位置,是高速轮轴。
快的代价,最后都落到治理上
我更在意的不是“io_uring 又出洞”。io_uring 这些年一直是内核攻击面的高频词,这并不新鲜。
更值得盯住的是:性能优化正在把越来越复杂的生命周期管理推进内核深处。零拷贝、page pool、DMA、用户注册内存、网卡队列、异步完成路径,每一层都是为了少一次复制、少一次等待。但每多一条快路径,就多一组释放时机、引用状态和清理顺序。
快,不是免费的。
“天下熙熙,皆为利来。”放在这里,利就是吞吐、延迟和 CPU 占用。基础设施团队当然想要它,云厂商也想要它,数据库、代理、存储系统都想要它。问题是,性能收益往往被应用层拿走,生命周期复杂度却留在内核里结账。
ZCRX 这次的教训很具体:一个新子系统只审正常路径不够,teardown、错误回滚、驱动关闭、队列重配这些“脏路径”才是事故高发区。很多漏洞不是发生在系统奔跑时,而是发生在系统收摊时。
对安全团队来说,排查也不该泛化成“Linux 6.15–6.19 全部危险”。更现实的清单是:
- 内核是否启用 CONFIG_IO_URING_ZCRX;
- 机器是否有真实支持 ZCRX 的 NIC;
- 是否运行未合入 770594e 的版本;
- 容器或服务是否拿到了 CAP_NET_ADMIN;
- 是否存在可触发网卡 down/up、队列重配的本地攻击面。
这件事像早期高速铁路:车能跑得更快,调度、信号、检修也必须一起升级。不完全一样,但权力结构相似——速度越高,边界条件越不能靠侥幸。
一个 u32 能走到 root,不是因为 u32 神奇。是因为系统里有太多地方默认“不会多还一次”“不会刚好相邻”“不会被喷到那里”。安全事故最爱这种默认。
