日期 :2026-07-07 主题 :Januscape:潜伏 16 年的 KVM 虚拟机逃逸漏洞 原文 :Januscape:潜伏 16 年的 KVM 虚拟机逃逸漏洞 来源 :GitHub / oss-security 领域:🔒 安全/网络
背景
2026 年 7 月 6 日,安全研究员 Hyunwoo Kim(@v4bel)公开披露了一个编号为 CVE-2026-53359 的 Linux KVM 高危漏洞------Januscape。这个名字来自 Janus(罗马双面神),暗示它同时影响 Intel VMX 和 AMD SVM 两种架构的 KVM 实现。
这并非一次普通的漏洞披露。Januscape 是一个在 KVM 中 潜伏了整整 16 年 的 use-after-free 漏洞(从 2010 年内核提交 2032a93d66fa 到 2026 年 6 月修复补丁 81ccda30b4e8),它允许一个恶意虚拟机(guest)逃逸到宿主机(host)内核,在多租户公有云场景下,这意味着一个攻击者可以从自己租用的虚拟机实例突破到物理宿主机,进而控制同一台物理机上所有其他租户的虚拟机。
更令人瞩目的是,根据公开资料,这是首个同时影响 Intel 和 AMD 两种架构的 KVM guest-to-host 逃逸漏洞研究 。该漏洞此前已被成功用作 Google kvmCTF 竞赛中的 0-day 利用。
本文将深入分析 Januscape 的技术细节:漏洞根因在 KVM 内存管理单元的哪个环节、攻击者如何利用嵌套虚拟化触发它、以及修复补丁是如何一针见血地关闭这扇潜伏了 16 年的门。
KVM 地址翻译:两条路径
要理解 Januscape,先要理解 KVM 如何完成虚拟机地址翻译。
当一个 x86 虚拟机内的程序访问一个内存地址时,需要经过 两层地址翻译:
- GVA → GPA(客户虚拟地址 → 客户物理地址):由客户机自己的页表完成
- GPA → HPA(客户物理地址 → 宿主机物理地址):由 KVM 完成第二层翻译
KVM 提供 两种方式 来完成这第二层翻译:
硬件辅助两阶段分页(EPT/NPT)
现代 CPU 提供了硬件级的第二层翻译支持------Intel EPT(Extended Page Tables)和 AMD NPT(Nested Page Tables)。在这些 CPU 上,KVM 只需要配置好硬件页表,CPU 自动完成两层翻译。这是默认路径(kvm-intel.ept=1),KVM 使用 TDP MMU(两层分页内存管理单元)来管理这些硬件页表。
影子分页(Shadow Paging)
当硬件不支持两阶段分页,或者在某些特殊场景下,KVM 选择在软件中手动"影子化"客户机的页表结构------它为每个客户页表层级创建一个对应的 shadow page (struct kvm_mmu_page),并让 CPU 直接使用这些 shadow page 进行翻译。这就是 shadow MMU(影子内存管理单元)。
在默认配置的现代主机上,TDP MMU 是主力路径。但有一个关键场景会 强制降级 到 shadow MMU------那就是 嵌套虚拟化。
嵌套虚拟化:通往 Januscape 的门
嵌套虚拟化(nested virtualization)是指在一个虚拟机内部再运行另一个虚拟机。在云计算场景下,这是常见需求------比如 CI/CD 环境需要在虚拟机内再跑容器或嵌套虚拟机。当 L1 虚拟机(第一层客户机)创建 L2(第二层客户机)并使用 EPT/NPT 时,宿主机 L0 必须对 L1 为 L2 构建的 EPT/NPT 进行软件模拟。
问题来了:L1 为 L2 构建的页表是由客户机控制的 ,它们不是受信任的硬件页表。因此 KVM 不能直接用 TDP MMU 来处理它们,而是必须通过 shadow MMU 来模拟 。KVM 使用 kvm_init_shadow_ept_mmu 初始化一个特殊的 guest_mmu 上下文,其 root_role.direct = false,路径直接导向 FNAME(fetch) → kvm_mmu_get_child_sp()------这正是 Januscape 漏洞埋下的函数。
根因分析:角色混淆(Role Confusion)
Januscape 的根因隐藏在 kvm_mmu_get_child_sp() 函数中。这个函数的职责是:当 KVM 在 shadow MMU 中沿着客户页表树往下走时,获取或创建下一级 shadow page。
漏洞代码(在补丁 81ccda30b4e8 之前)的简化逻辑如下:
c
static struct kvm_mmu_page *kvm_mmu_get_child_sp(struct kvm_vcpu *vcpu,
u64 *sptep, gfn_t gfn,
bool direct, unsigned int access)
{
union kvm_mmu_page_role role;
// 判断是否可以直接复用已有的 child
if (is_shadow_present_pte(*sptep) && !is_large_pte(*sptep) &&
spte_to_child_sp(*sptep) && spte_to_child_sp(*sptep)->gfn == gfn)
return ERR_PTR(-EEXIST); // gfn 匹配,复用!
role = kvm_mmu_child_role(sptep, direct, access);
return kvm_mmu_get_shadow_page(vcpu, gfn, role);
}
问题出在粗体标注的那行条件判断上:它只比较了 child shadow page 的 gfn(客户物理页框号),却没有比较 role(角色)。
role 是一个关键字段,它记录了 shadow page 的类型。其中最重要的一个区分是:
direct=1:直接拆分页(direct split page)------当宿主机无法映射一个大页(2MB)时,将其拆分为 4KB 小页direct=0:间接页(indirect page)------模拟客户页表的 shadow page
这两种角色虽然服务的 gfn 相同,但语义完全不同。当同一个 gfn 同时需要两种角色时------这在嵌套虚拟化的 shadow MMU fetch 路径中很自然会发生------漏洞就出现了。
让我们看看 fetch 路径中的两段循环:
c
static int FNAME(fetch)(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault,
struct guest_walker *gw)
{
// 第一段:创建间接 shadow page(direct=0)
for_each_shadow_entry(vcpu, fault->addr, it) {
sp = kvm_mmu_get_child_sp(vcpu, it.sptep, table_gfn,
false, access); // direct=false
}
// 调整大页映射
kvm_mmu_hugepage_adjust(vcpu, fault);
// 第二段:创建直接拆分 shadow page(direct=1)
for (; shadow_walk_okay(&it); shadow_walk_next(&it)) {
sp = kvm_mmu_get_child_sp(vcpu, it.sptep, base_gfn,
true, direct_access); // direct=true
}
}
第一段创建间接页(direct=0,影子化客户页表),第二段创建直接拆分页(direct=1,拆分大页)。当两个阶段的 gfn 相同时,kvm_mmu_get_child_sp 在 gfn 匹配时就返回 -EEXIST,错误地复用了角色不匹配的已有 shadow page 。这就是 root cause------角色混淆(role confusion)。
从角色混淆到 use-after-free
角色混淆导致的直接后果是 rmap(反向映射)结构的状态不一致。
KVM 为每个 memslot 维护了一个 rmap 结构,用于根据 gfn 反向追踪所有映射该页的 leaf SPTE(影子页表末级表项)。当安装一个 leaf 时,SPTE 指针被添加到对应 gfn 的 rmap 中;当拆除时,从 rmap 中移除。这个数据结构的不变量是:安装和拆除必须使用相同的 gfn key。
角色混淆打破了这一不变量。当一个直接拆分页(direct=1)被错误复用为间接页用途时:
- 它没有
shadowed_translation字段 - KVM 计算它的 gfn 为
sp->gfn + index - 通过它安装的 leaf 在 rmap 中以真实 guest gfn 注册
- 但拆除时却以计算出的 gfn (
sp->gfn + index)查找
当这两个 gfn 不一致时,rmap 查找失败,触发内核的完整性检查 KVM_BUG_ON_DATA_CORRUPTION,在 CONFIG_BUG_ON_DATA_CORRUPTION=y(RHEL 等发行版的默认配置)且 panic_on_oops 的组合下,直接导致宿主机内核 panic。
更严重的是,当释放后的 shadow page 被重新分配给其他内核对象(victim)时,KVM 后续的清零操作会在已释放的内存上执行写入------这就是一个经典的 use-after-free 写入 。攻击者如果能够精心选择 victim 对象的类型和布局,就可以将这个固定值的写入转化为任意代码执行,实现完整的虚拟机逃逸。
攻击者的视角:PoC 分析
Januscape 的公开 PoC 是一个在客户机内部加载的内核模块。它的工作原理可以概括为下面几个步骤:
第一步:构建嵌套页表
客户机(L1)内核模块直接在自己的 RAM 中构建 L2 的嵌套页表和 L2 客户机镜像(build_world())。关键的设计是让同一物理页同时扮演 大页的 leaf 和 页表页 两个角色:
c
ptg = (u64 *)greg_va; // 这个页同时是...
nest_pd[PDE_IDX] = huge_pte(greg_pa); // ...2MB 大页的 leaf
ptg[0] = leaf4k(greg_pa); // ...也是页表
ptg[PRIME_IDX] = leaf4k(q_pa); // 指向分离的探针页
这里,greg_pa >> 12 就是 gfn。当 PDE 将 greg 映射为 2MB 大页时和当同一个 PDE 指向 ptg 作为页表时,两者的 gfn 相同,但角色不同------这正是触发漏洞所需的条件。
第二步:双架构设计
PoC 通过一个 virt_ops 抽象层同时支持 Intel 和 AMD:
c
ops = amd ? &svm_ops : &vmx_ops;
// Intel: EPT 页表项位
// AMD: NPT 页表项位
Intel 路径使用 vmxon/vmlaunch,AMD 路径使用 vmrun。所有架构差异被封装在页表项位操作中,核心逻辑完全共享。
第三步:竞争条件(Race Condition)
触发漏洞需要两种 vCPU:
- Writer vCPU :持续切换
nest_pd[PDE_IDX]在大页(huge)和页表(table)之间 - Faulter vCPU(通常 8 个):持续运行 L2,触发嵌套 EPT/NPT 缺页异常
关键点在于 PDE 切换的非原子性窗口 。当 L0 模拟 writer 的 PDE 写入时,存在一个时间窗口------在新 PDE 值已提交但旧的 shadow link 尚未被清除之间 。如果某个 faulter 恰好在这个窗口内触发缺页,kvm_mmu_get_child_sp() 就会以新角色被调用,但旧角色的 child 仍然链接在 sptep 上,触发 gfn 匹配复用,完成角色混淆。
多个 faulter 竞争 mmu_lock 会延迟 writer 的 track_write 处理,从而扩大这个窗口。从几秒到几分钟不等,但在易受攻击的代码上,足够的竞态一定会触发漏洞。
第四步:宿主机崩溃
当角色混淆成功后:
- 直接拆分页被复用为间接页
- 错误角色下安装的 leaf 的 gfn 与实际不符
kvm_mmu_page_set_translation()输出 "gfn mismatch under direct page" 警告- 后续拆除 leaf 时,
pte_list_remove()发现 rmap 为空,触发KVM_BUG_ON_DATA_CORRUPTION - 宿主机内核 BUG → panic
PoC 在 RHEL 6.12.0 上的典型崩溃日志:
bash
gfn mismatch under direct page 8a00 (expected 8b00, got 256e4)
kernel BUG at arch/x86/kvm/mmu/mmu.c:1091!
RIP: 0010:pte_list_remove.isra.0+0xd9/0xe0 [kvm]
影响范围与修复
漏洞影响
- 漏洞范围 :从内核提交
2032a93d66fa(2010 年 8 月)到81ccda30b4e8(2026 年 6 月),16 年间所有受影响的 KVM 版本 - 架构:x86 Intel VMX 和 AMD SVM(arm64 不受影响)
- 利用条件:攻击者需要在客户机内拥有 root 权限来加载内核模块,并且宿主机需要暴露嵌套虚拟化能力
- 真实影响:在多租户公有云(GCP、AWS 等)上,攻击者可以从一台虚拟机逃逸到宿主机,进而控制同一宿主机上的所有其他租户实例
- 已确认利用:该漏洞已被成功用作 Google kvmCTF 的 0-day 攻击
修复方案
修复补丁 81ccda30b4e8 由 KVM 维护者 Paolo Bonzini 编写,修改非常简洁但一针见血:
diff
- if (is_shadow_present_pte(*sptep) && !is_large_pte(*sptep) &&
- spte_to_child_sp(*sptep) && spte_to_child_sp(*sptep)->gfn == gfn)
+ if (is_shadow_present_pte(*sptep) && !is_large_pte(*sptep) &&
+ spte_to_child_sp(*sptep) && spte_to_child_sp(*sptep)->gfn == gfn &&
+ spte_to_child_sp(*sptep)->role.word == role.word)
return ERR_PTR(-EEXIST);
增加了 role.word 的比较。只有在 gfn 和 role 都完全匹配时,才复现有 child shadow page,否则创建正确角色的新 shadow page。这就从根源上消除了角色混淆的可能性。
时间线
| 日期 | 事件 |
|---|---|
| 2026-06-12 | 研究员向 security@kernel.org 提交漏洞详情与利用 |
| 2026-06-13 | 与 KVM 维护者讨论修复方案 |
| 2026-06-17 | Paolo 向内核邮件列表提交补丁 |
| 2026-06-19 | 补丁 81ccda30b4e8 合入主线内核 |
| 2026-07-01 | 向 linux-distros 列表提交,5 天 embargo |
| 2026-07-04 | 分配 CVE-2026-53359 |
| 2026-07-06 | 公开披露 |
总结与展望
Januscape 的价值不仅在于它是一个高危漏洞,更在于它揭示了虚拟化软件栈中一个容易被忽视的深层问题:同一函数的不同调用路径对共享数据结构的语义假设不一致。在 shadow MMU 中,gfn 匹配这条看似合理的复用优化,忽略了角色这个关键维度,导致了一个潜伏 16 年的隐患。
从实际防御角度,有三点值得关注:
-
嵌套虚拟化的安全成本:嵌套虚拟化强制 KVM 降级到 shadow MMU,绕过了现代硬件 EPT/NPT 的安全增益。对于不需要嵌套虚拟化的云工作负载,建议禁用该特性。
-
16 年潜伏期的启示:这段漏洞代码从 2010 年引入,经历了无数的代码审查和内核安全审计,直到 2026 年才被发现。虚拟化软件栈的复杂性使得一些看似无害的优化成为隐蔽的攻击面。
-
修复的及时性:从提交漏洞(6 月 12 日)到补丁合入主线(6 月 19 日),仅用了一周时间------这是 Linux 内核安全响应的一个教科书式案例。
对于运行多租户 x86 KVM 环境的团队,检查宿主机内核是否包含 81ccda30b4e8 补丁,是今天就应该做的事情。如果你使用的是已经打了补丁的内核(6.12.95、6.18.38、7.1.3 等稳定版本已包含此修复),那么------Januscape 的这扇门已经关上了。