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_niovsfreelist 大小典型 slab写入值范围
832Bkmalloc-320–7
1664Bkmalloc-640–15
32128Bkmalloc-1280–31
64256Bkmalloc-2560–63
128512Bkmalloc-5120–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 神奇。是因为系统里有太多地方默认“不会多还一次”“不会刚好相邻”“不会被喷到那里”。安全事故最爱这种默认。