漏洞
PwnEscape:虚拟机发出的包,带出了宿主机内存
宿主机替虚拟机补算网络校验和,却把结果写进了已经检查过的 IPv4 包头。包头随之声称自己更长,后面的分片代码照着这个长度复制。在研究者展示的案例中,多复制的 32 字节将宿主内存带了出去。CVE-2026-99999 的修复要装在宿主机上,只更新虚拟机里的系统无效。

文章导航
把不可信代码放进虚拟机,通常是为了让它再怎么折腾,也只折腾自己的系统。代码要联网,宿主机就得接住虚拟网卡送出的东西。这次出问题的地方,正是这条正常的通信路径。
Vercel 在 10 月 14 日公布的沙箱挑战复盘确认,研究者 Paulos Yibelo 从一个普通沙箱出发,让宿主机内存出现在向外发送的数据包中。宿主机崩溃与管理员权限执行,则在研究者自有、匹配相关配置的 KVM 和 Firecracker 环境中演示。10 月 15 日,Linux CVE 团队公开编号 CVE-2026-99999。pwn.ai 的 PwnEscape 技术文章把其中的网络处理过程展开讲了出来。
1 宿主机为什么会替虚拟机写这个包
网络校验和用于发现传输中的数据错误。系统可以先准备好数据包,把最后的计算与填写交给网卡,减少 CPU 承担的工作。Linux 用 CHECKSUM_PARTIAL 表示这项工作还没完成,并保存计算起点 csum_start,以及从起点到结果字段的偏移 csum_offset。最终会把一个 16 位结果写到两者相加的位置。设备不能处理时,软件函数 skb_checksum_help() 可以代劳。内核的校验和卸载文档说明了这份约定,同时明确 IPv4 包头自身的校验和不属于这里的卸载工作。
虚拟网卡也会传递这类说明。虚拟机里的系统称为 guest,运行虚拟机的系统则是宿主。恶意 guest 可以把包的内容与校验和元数据一起交给虚拟机监控器,后者再把它们写入宿主机已有的 TAP 接口。TAP 把虚拟以太网帧送进宿主网络栈,virtio_net_hdr 则是设备之间用来描述卸载任务的附加头。它不属于互联网上的普通 IPv4 包。外部访客给服务器发一个 TCP 请求,不能因此随意指定服务器内部的 csum_start。
宿主机收到这些数据后,tun_get_user()为它建立内核里的包对象 sk_buff。启用 IFF_VNET_HDR 时,函数也会把附加头里的卸载说明存进去。处理 TAP 还要跳过以太网头,让后续代码从 IPv4 头开始读包。读包的起点移了,先前存下的校验和位置仍然留着,稍后软件补算时还会用到。
攻击者必须能控制这层设备数据。AWS 公告描述的是 guest 内具有特权访问的攻击者。Linux CNA 的 9.3 分场景描述恶意 microVM 租户,租户能控制获分配的 virtio-net 输出,但不需要宿主机凭据,也不必亲自打开宿主的 /dev/net/tun。只有普通网络请求能力的应用,尚不能据此控制 virtio_net_hdr里的卸载说明,也就不能直接认定具备相同的触发能力。
2 位置检查通过了,为什么还能写坏包头
先看包在内存里怎样存放。sk_buff 可以把前面一段数据连续保存,其余数据分散放在其他片段里,这段连续的有效包数据就叫线性数据。skb->head 指向底层分配的开头,skb->data 指向当前包数据的开头,两者之间为加包头等操作预留的空间叫 headroom。
接收虚拟网卡数据的旧代码想给 IPv4 头留够空间,要求校验和计算起点距离当前 data 至少 20 字节。20 是 IPv4 基本头长,问题在于 data 此时可能还指着以太网头,IPv4 要再往后找。两个起点不同,数出来的 20 字节就有了不同含义。
假设 data 仍指向普通 14 字节以太网头,IPv4 紧跟其后,校验和计算从距 data 20 字节的位置开始。旧检查接受这个 20,但从 IPv4 起点看,它只走了 20 - 14 = 6 字节,仍在头内。之后跳过以太网头,只是让 data 前进,已经存下的计算起点不会跟着移动。这组示意解释的是起算点差异,偏移 6 与后文原研究改写首字节的案例属于两组位置。
具体代码在 __virtio_net_hdr_to_skb() 第 97–117 行。这条设置了 VIRTIO_NET_HDR_F_NEEDS_CSUM 的 IPv4 分支,先保证需要的数据可读,再通过 skb_partial_csum_set()保存校验和状态。后者检查结果字段末端是否还在线性数据内,把相对 data 的计算起点加上 headroom,存为相对 head 的 csum_start。
保存起点时,transport_header也被设在这个位置。接下来的 skb_transport_offset()按偏移函数的定义,用它减去当前 data,得到的仍是从 data 算起的距离。旧检查把这个距离与初始为 20 的 nh_min_len比较,因而接受上例的 20,没能认出它其实落在 IPv4 头内。
// __virtio_net_hdr_to_skb() 中原有的下限检查
if (skb_transport_offset(skb) < nh_min_len)
return -EINVAL;
// 此分支设置后的量,统一写成相对 head 的位置
// D = 当前 data,N = network_header,C = csum_start
// 旧条件:C - D >= 20
// IPv4 头外的条件:C >= N + ip_hdrlen(skb)
真正执行写入的 skb_checksum_help() 第 3617–3668 行同样核对起点和结果字段有没有超出线性数据,再确保内存可写,最后填写计算结果。这些检查能挡住一些直接越界,却允许一次仍在包内、位置已经错了的写入。若目的字段位于 IPv4 头,合法的校验和完成动作就会修改已经被其他代码理解过的包头。
// skb_checksum_help() 中实际写入结果的一行
*(__sum16 *)(skb->data + offset) = csum_fold(csum) ?: CSUM_MANGLED_0;
3 20 字节的包头,检查之后变成了 60 字节
IPv4 第一个字节同时保存版本与头长。高四位为 4,表示 IPv4。低四位 IHL 以四字节为单位,5 表示 20 字节,15 表示 60 字节。头部多出的空间用于可选字段,称为 IPv4 options。原始包按 20 字节头被检查、参与路由,并不代表内核已经认可了 60 字节头。
原研究让输入的 IP 片段先在宿主上重组,再向外发送。重组让内核把收到的片段作为一个数据报处理,并不要求把它们全部复制到同一块连续内存。这个数据报不止 28 字节,但开头连续保存的 skb_headlen(skb)只有 28 字节,即 20 字节原始头和第一片留下的 8 字节数据,其余内容仍可分散存放。接下来输出要再次分片,新片需要各自的 IPv4 头。
这时数据报还带着 CHECKSUM_PARTIAL,宿主欠它一次校验和计算。分片函数内部会先补算,才读取头长。已经检查过的头能否保持原样,就取决于这次补算往哪里写。
哪些状态能让数据报走到这次补算
ip_frag_queue()先等首尾片齐全、收集的内容覆盖数据报,再调用重组。处理非末片时,若末端没有按八字节对齐,它会裁掉尾部,并把 CHECKSUM_PARTIAL 清成 CHECKSUM_NONE。接下来的 inet_frag_reasm_finish()把 data 恢复到网络头,再接上其余片段,各片状态不一致同样会清掉 PARTIAL。各片最初都带这个标记,还要在这些处理中保留下来,稍后才会补算。
GSO 是通用分段卸载,把一个待发大包拆成多个段的工作留到后面的软件或设备处理。输入重组则把已经收到的 IP 片段拼成一个数据报,两者在这里不能混为一项操作。输入片段上的 GSO 标记会在入重组队列时被清除,避免把原片的卸载说明错误带到重组对象上。
向外发送时,出口链路能承载的包大小上限称为 MTU。重组时还会在 IPCB(skb)->frag_max_size记下原片大小。普通 IPv4 输出的 __ip_finish_output()发现包长超过 MTU,或者这项记录非零,就要求分片。接着 ip_fragment()检查禁止分片位 DF。未设 DF 可以直接继续,设了 DF 则还受 ignore_df 与原片大小限制。无法继续时,函数发出需要分片的 ICMP,丢包并返回 -EMSGSIZE,也就不会进入后面的回写与复制。
包进入 ip_do_fragment() 第 760–801 行后,函数先调用 skb_checksum_help() 完成待处理的校验和。元数据指定计算和存放的位置,实际写入值来自包数据的校验和计算,guest 同时控制了送来的包内容。原研究记录的具体案例在这次回写中把首字节从 0x45 改成 0x4f。高四位仍是 IPv4 的 4,低四位却从 5变成了十六进制的 f,也就是十进制 15,头长随之从 20 变为 60 字节。helper 返回后,ip_do_fragment()才读取 iph->ihl,用上了这个新值。
// 补丁前 ip_do_fragment() 的实际顺序,省略无关语句
if (skb->ip_summed == CHECKSUM_PARTIAL &&
(err = skb_checksum_help(skb)))
goto fail;
iph = ip_hdr(skb);
// ……
hlen = iph->ihl * 4;
分片代码用刚读到的 hlen决定要复制多长的 IPv4 头。原先只有 20 字节的头,现在要按 60 字节处理,已经超过前面连续保存的 28 字节。这里没有另一个线程抢时间,同一个函数先写、后读,自己用上了被改坏的值。
接下来分片有两种办法,可以复用原片,也可以分配新包再复制。内核要检查原片布局是否适合复用,不能只凭线性数据的 28 字节判断它会选哪条路。下面的多复制发生在分配新包的慢路径中。
从复用原片到重新复制,分片函数怎样选择
头长改变后,函数还会检查 MTU 能否容下这个头和至少 8 字节数据,随后才选择怎样拆包。已有 frag_list 时,内核尽量复用原片,省掉重新分配与复制。它先检查首段长度、八字节对齐、已有分片标志、共享状态和预留头空间,再逐片检查几何与共享条件。不适合复用时会退回 slow_path,不是所有重组包都无条件进入同一条复制路径。
// ip_do_fragment() 的快路径几何检查之一
unsigned int first_len = skb_pagelen(skb);
if (first_len - hlen > mtu || /* 其余条件见固定源码 */
((first_len - hlen) & 7) || ip_is_fragment(iph) ||
skb_cloned(skb) || skb_headroom(skb) < ll_rs)
goto slow_path;
这里的 first_len 来自 skb_pagelen(),它把本 skb 的线性数据和 page fragments 都算进去。前面 28 字节只确定了 skb_headlen(),不能直接代入这里。假如 first_len 也为 28,头长却已经是 60,无符号的 first_len - hlen 就会变成很大的数,超过 MTU,快路径因此拒绝复用,转去复制。没有 frag_list 的包会直接走复制路径,两种入口最后汇合在同一个 slow_path。
进入慢路径后,ip_frag_init()保存新的 hlen。ip_frag_next()分配输出包,用 skb_copy_from_linear_data(..., state->hlen)复制头部。这次复制从线性数据起点连续读满 60 字节,不会沿非线性片段去拼接剩余正文。读过有效的 28 字节后,它继续取的是这块连续内存后面的内容。
ip_frag_next()随后补上输出头字段,重新计算头校验和,返回新包 skb2。调用它的 ip_do_fragment()慢路径再调用 output(net, sk, skb2)把包送出。多复制的内容被装进 IPv4 options,跟着数据包离开了宿主机。
这次复制把 60 - 28 = 32 字节的额外内存当成了头部。它越过的是有效线性数据末端,底层分配可能还有可读的尾部空间,因此未必越过内核分配的整个对象,即 slab 对象。程序读这些地址时可以不崩溃,读到的内容却已经不属于发送者的包。
4 内存泄露怎样与“逃逸”接上
多出的字节会不会原本就是发送方给的?pwn.ai 用头长不变的包作对照,再看触发回写后收到的包。研究者检查最终报文结构,寻找超出已知输入的非零字节,并与发送方已知、可控的对照内容比较,判断它们是否本来就在输入中。随后又核对应用实际收到的内容和原始抓包,排除构包、抓包带来的假象。按这组验证,guest 读到的是自己包以外的宿主内存,虚拟机与宿主机之间的隔离已经被跨过。
读到宿主内存之后,研究者还在自有 KVM 环境里继续推进。pwn.ai 报告了选择性的 64 字节内核写入,随后让验证程序以宿主 root 身份运行,把宿主生成的标记返回 guest。它报告四次从真正冷启动开始的测试都完成了这条链,Vercel 也确认自有匹配环境中的宿主执行演示。分配器、对象寿命与不同虚拟化配置的适配留给了后续文章,已公开的代码和解释尚不足以重建通用、稳定的管理员执行方案。
不可信租户已经能把畸形卸载元数据送进易受影响的宿主网络栈时,就应按宿主隔离漏洞处理,无需等到自己复现 root 执行才更新。单凭安装 KVM、运行 Linux 或开放普通网站,还无法确定这条入口是否存在。pwn.ai 另外提到 TUN 与 AF_PACKET 的相关本地入口,完整逃逸演示并没有覆盖每一个入口。
5 补丁拦在写入之前
Michael S. Tsirkin 的 正式补丁没有取消校验和卸载。它在 include/net/ip.h 第 72–82 行增加 ip_check_csum_start(),要求计算起点不早于当前 IPv4 头的末端。这里使用实际 ip_hdrlen(skb),因此也计入 options,不能把条件缩写成固定的“至少 20 字节”。
static inline int ip_check_csum_start(const struct sk_buff *skb)
{
if (unlikely(skb->csum_start < skb->network_header + ip_hdrlen(skb)))
return -EINVAL;
return 0;
}
第 2 章的旧检查从 data 开始数,IPv4 头却可能还在后面。新检查直接比较 csum_start 和 network_header,两者都从 head 开始数,data 再移动也不会改变它们之间的距离。令 N = network_header、H = ip_hdrlen(skb)、C = csum_start,要求就是 C >= N + H。等号允许计算从 IPv4 头末端开始,正常的传输层校验和仍可照常补算。
结果会写到 C + csum_offset。偏移不为负,起点已经在头外,结果也就不会倒退回 IPv4 头。skb_checksum_help() 仍要核对计算起点和结果末端都在线性数据内,再由 skb_ensure_writable()确保内存可写。新检查管“能否写进 IPv4 头”,旧检查继续管“是否超出有效数据、是否可写”。
例如 IPv4 头长 20、计算起点正好在其末端时,新下界通过,结果字段还要由旧检查确认能写下完整两字节。若起点在头内,即使结果仍在整块已分配内存中,也会先被新检查拒绝。若 IPv4 带 options、实际头长为 24,起点只越过基本的 20 字节仍然不够。对于已经不需要完成校验和的包,ip_do_fragment() 不进入这段 CHECKSUM_PARTIAL 分支。
// ip_do_fragment() 中替换原调用的完整条件块
if (skb->ip_summed == CHECKSUM_PARTIAL) {
err = ip_check_csum_start(skb);
if (!err)
err = skb_checksum_help(skb);
if (err)
goto fail;
}
修复后的分片路径先检查计算起点是否早于 IPv4 头末端,通过后才计算与回写。新检查或 skb_checksum_help()任一失败,都会进入已有的 fail,释放包、增加分片失败计数并返回错误。把头长改大的写入就此被提前挡住。这个检查仍未验证完整 TCP 或 UDP 结构,也没有确认目的字段就是该协议的校验和字段。提交说明将其称为纵深防御,入口的完整校验另需处理。
历史上,2008 年的 TUN 改动让用户态通过 IFF_VNET_HDR 传入卸载信息,CNA 据此把引入版本列为 2.6.27。修补提交同时引用了 2010 年加入 AF_PACKET 卸载支持的改动。这解释了它为何不只关乎某一家云平台,也不表示所有历史版本、设备和配置都已被逐一验证可逃逸。
截至 10 月 17 日,Linux CNA 列出的修复下限为 7.2.11,以及长期分支 6.18.57、6.12.113、6.6.159、6.1.190、5.15.223 和 5.10.272,kernel.org 的发布清单已列出这些版本。CNA 另列 7.3-rc7 为主线修复边界,该 tag 的实际源码确有新检查,不过 RC 是候选版,不应为了这项修复让生产环境跳到候选内核。发行版自己的回补包还要按完整包号判断。
6 为什么还要改另外四处
堵住 ip_do_fragment() 还不够,其他代码也会替包完成校验和。桥接有自己的分片函数,NFQUEUE 在把包交给用户态检查前可能补算,防火墙 CHECKSUM 规则和 IPsec 输出也会做同一件事。因此这次补丁改了六个文件,共用一个新检查,保护五处写入。下面四处各有自己的进入条件,拒绝后如何处理包也不同。
桥接连接跟踪分片。nf_ct_bridge_refrag()先看桥接控制块的 frag_max_size。为零就返回 NF_ACCEPT,非零才按 IPv4 或 IPv6 分派。IPv4 会进入独立的分片函数 nf_br_ip_fragment(),绕开普通的 ip_do_fragment(),因此这里也要增加检查。
桥接函数遇到 CHECKSUM_PARTIAL,先查计算起点是否已到 IPv4 头末端,再调用 skb_checksum_help()补算并写回。任一步失败都跳到 blackhole,释放当前 skb,然后返回 0。这个返回值对应的是已经丢掉的包。上层桥接调用者再返回 NF_STOLEN,告诉外层这个 skb 已被接管,不要继续处理。
NFQUEUE。内核把包的内容装进 Netlink 消息,交给用户态程序检查,原 skb 仍留在内核里。负责构建消息的 nfqnl_build_packet_message()在 NFQNL_COPY_PACKET模式下复制包内容。如果队列未启用 NFQA_CFG_F_GSO、skb 又标着 CHECKSUM_PARTIAL,它会先补算校验和。copy_range可以限制复制长度,即使只送出包的前缀,也得先补算。仅复制元数据,或允许接收卸载格式的队列,不经过这个调用点。
具体补算由 nf_queue_checksum_help()处理。遇到 IPv4,它先用 pskb_network_may_pull(sizeof(struct iphdr))确保基本头可读,再查计算起点是否位于 IPv4 头外。任一检查失败就返回 -EINVAL,停止后续写入。通过后仍沿用原来的算法分派,为 SCTP 选择 CRC32c helper,其他情况调用普通校验和 helper。
检查失败后,用户态程序收不到包,原包也不会继续发送。消息构建函数先返回 NULL,排队调用者把它记为 -ENOMEM并直接退出。错误退回 __nf_queue()后,它先释放描述这次排队的记录,即队列条目,外层 nf_queue()再释放原包。起点检查拒绝的畸形偏移,就这样被记成了像是内存不足的错误。
队列的失败放行设置救不回这个包。FAIL_OPEN只处理后面的排队、单播错误,这次消息尚未建好就已经返回。QUEUE_BYPASS则只在 -ESRCH这类无队列错误时放行,不适用于这里的 -ENOMEM。包最终仍在内核里丢弃。
防火墙 CHECKSUM 目标。checksum_tg()只给 CHECKSUM_PARTIAL 且非 GSO 的包调用 skb_checksum_help()补算。补丁在调用前,仅为 NFPROTO_IPV4 检查计算起点,失败就返回防火墙判决 NF_DROP。通过则调用补算函数,再返回 XT_CONTINUE继续处理规则。IPv6 不套用 IPv4 头长公式。GSO 和非 PARTIAL 的包被原有调用条件排除,也不会走到新增的起点检查。这里仍忽略 skb_checksum_help()的返回值,所以新增的丢包只来自位置检查,后续计算失败的处理保持原样。
XFRM 输出。xfrm_output()服务于 IPsec 等变换。包级硬件卸载先走自己的输出,GSO 包可能转去分段,支持 ESP 发送校验和的设备也可能跳过软件补算。只有继续走到后面的 CHECKSUM_PARTIAL 分支,才需要由 skb_checksum_help()补算。
补丁就在调用前为 IPv4 包检查计算起点,要求它不早于 IPv4 头末端。位置检查或补算失败,都会进入共用的 error,增加 LINUX_MIB_XFRMOUTERROR、释放 skb,再返回 err。成功的包继续到 xfrm_output2()。加密功能以及前面不同的硬件卸载路径仍然保留。
7 先找对需要更新的那台机器
对自建 KVM、Firecracker 或运行不可信 microVM 的平台,我会优先检查承载 guest 的宿主内核。只更新 guest 镜像,无法替换本文正在接收 TAP 数据、执行 IPv4 分片的那份代码。暂停新建不可信 guest 可以限制新增暴露,但已有 guest 仍能沿现有 TAP 发包。要处理这些入口,需要把负载迁到已修节点,或暂停相关负载。这会占用额外容量或中断业务,也不该把租户迁到另一台同样未修的宿主上。
Amazon Linux 给出了一个很能说明问题的回补例子。上游 6.12 分支的修复下限是 6.12.113,ALAS2023-2026-3196却已交付 kernel6.12-6.12.110-135.201.amzn2023。AWS 的专项公告明确把它列为修复包,并要求自行管理相关虚拟机的用户更新宿主、重启。只比较数字 6.12.110 < 6.12.113,会错过发行版回补。该 ALAS 还给出绑定仓库快照的更新方法,部署时应使用实际产品与内核流对应的公告,不从另一条分支照抄包名。
AWS 同时称其托管 EC2、Lambda 和 Fargate 服务不受影响,无需使用这些服务的客户采取本漏洞措施。这个声明不能替自行管理的 KVM 宿主背书,也不能从“托管 EC2 不受影响”推出所有使用 Amazon Linux 的机器都无需更新。截至 10 月 17 日,Red Hat 和 SUSE 的相应产品页仍列受影响或待处理,Ubuntu 多个内核流仍需评估,不能把缺少修复包号读成已经安全。
更新是否完成,最简单的核对从宿主的 uname -r 开始,再对上发行版公告里的完整包号、修补记录和实际启动内核。安装了新包但仍跑旧内核,风险没有随下载结束而消失。之后检查 guest 的正常联网、分片相关业务和监控,升级失败时退回一份已确认包含修复的宿主镜像,或继续把不可信负载留在已修节点,别自动恢复到原来的易受影响池。没有获准的隔离测试环境,就不要靠发送逃逸报文验收生产机。