Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/V4bel/Januscape/llms.txt

Use this file to discover all available pages before exploring further.

The role-mismatch reuse in kvm_mmu_get_child_sp can only fire if two conditions hold simultaneously: the stale child shadow page (wrong role) is still linked at sptep, and a faulter is calling kvm_mmu_get_child_sp with the new role. L0’s shadow MMU does not update these atomically — there is a window between when L0 commits a new PDE value observed by a faulter and when it calls kvm_page_track_write to zap the old shadow link. The race in poc.c is designed to land a faulter precisely inside that window. With multiple faulters contending over mmu_lock, the window is widened enough that the race wins deterministically on any unpatched kernel given enough time.

Thread Roles

Each kthread launched by the module is assigned one of three roles:
role_of[cpu] = (cpu == 0)            ? R_WRITER :
               (cpu <= nflood && !amd) ? R_FLOOD  :
                                          R_FAULT;
  • R_WRITER (CPU 0): The sole writer. Continuously toggles nest_pd[PDE_IDX] between a huge-page entry and a table entry with a configurable spin delay (dwell, default 256 cpu_relax() iterations) between each write.
  • R_FLOOD (Intel only, CPUs 1..nflood): EPT-pointer flood threads. On each VMEXIT they switch the VMCS EPT_POINTER to a different pre-built EPT root from the press_root[] array. This forces L0 to process many different EPT contexts, increasing TLB and shadow MMU pressure to widen the race window. The AMD path suppresses this role (!amd).
  • R_FAULT (remaining CPUs, default 7): Faulter threads. Each runs L2 in a tight loop, generating a constant stream of nested EPT/NPT violations through the PDE_IDX entry to feed L0’s shadow MMU fetch path.

The Writer Loop

nest_pd[PDE_IDX] = ops->huge_pte(greg_pa);   // [19]
for (k = 0; k < dwell; k++) cpu_relax();
nest_pd[PDE_IDX] = ops->tbl_pte(ptg_pa);   // [20]
for (k = 0; k < dwell; k++) cpu_relax();
Huge write [19]: The PDE tries to map greg as a 2 MB page. However, because ptg (which shares greg_pa) is already tracked as a shadowed page table, account_shadowed() has registered a disallow_lpage for that gfn. When a faulter takes a nested fault through this PDE, kvm_mmu_hugepage_adjust() observes the disallow and lowers the fault to a 4 KB resolution. L0 then creates a direct-split shadow page (direct=1) at greg_pa >> 12 and links it at sptep. Table write [20]: The PDE now points to ptg as a page table. When a faulter takes a nested fault through this PDE, L0’s FNAME(fetch) needs an indirect shadow page (direct=0) at the same gfn (ptg_pa >> 12 == greg_pa >> 12).

The Race Window

The non-atomic window that the exploit targets:
  1. The writer stores the new PDE value (nest_pd[PDE_IDX] = ops->tbl_pte(ptg_pa) at [20]).
  2. L0 begins processing a page-track write notification for the previous (huge) PDE.
  3. Race window opens: L0 has not yet called kvm_page_track_write to zap the old direct-split shadow link at sptep.
  4. A faulter faults through the PDE and calls kvm_mmu_get_child_sp(vcpu, sptep, gfn, /*direct=*/false, access).
  5. The check at [1] sees spte_to_child_sp(*sptep)->gfn == gfn (true — same greg_pa >> 12) and returns -EEXIST without checking role.word.
  6. The fetch path reuses the direct-split page as if it were the indirect shadow page it requested.
  7. Race window closes when kvm_page_track_write finally zaps sptep — but the damage is already done.

How Contention Widens the Window

With nvcpu=8 (default), seven faulter kthreads hammer the mmu_lock spinlock simultaneously. Each time the writer calls kvm_page_track_write, it must acquire mmu_lock to zap old shadow entries. The competing faulters hold mmu_lock for their own fault processing, delaying the writer’s track_write and systematically lengthening the window. The more faulters contend, the wider the window, and the shorter the expected time to win.
With CONFIG_BUG_ON_DATA_CORRUPTION=y (the default on RHEL and many distributions), the panic fires immediately the first time the rmap invariant is broken — no need for repeated hits. Even a single successful race window entry is sufficient.

Time to Win

The time from module load to host panic is non-deterministic and depends on CPU count, scheduler latency, and mmu_lock contention patterns. In practice, on vulnerable kernels, the race triggers within seconds to minutes. The run_ms module parameter (default 600,000 ms) sets a deadline after which the writer self-terminates without winning, but on real hardware the race almost always fires well within the default window.

Diagnostic Output

The writer periodically prints progress and the faulters log their first VMEXIT details. After the race triggers, the sequence visible in the host kernel log is:
[*] poc step 4/4: race live -- host DoS triggering
...
gfn mismatch under direct page 8a00 (expected 8b00, got 256e4)
WARNING: CPU: 6 PID: 974281 at arch/x86/kvm/mmu/mmu.c:689 ...
kernel BUG at arch/x86/kvm/mmu/mmu.c:1091!
The gfn mismatch line is the first visible symptom — a WARN_ONCE at [6] — followed immediately by the fatal BUG from pte_list_remove at [7].

Build docs developers (and LLMs) love