.. /blog
2026-02-07 // 12 min

Faking VBS: Spoofing Core Isolation from L0

vbsstealthnpt

I wanted Windows to believe Core Isolation was enabled -- Memory Integrity on, Hyper-V detected, the whole thing -- while my hypervisor sits at L0. The goal is to make any software-level check return "yes, VBS is active" even though it's my code running the show, not Microsoft's.

This turned out to involve three separate spoofing layers: CPUID identity, the g_CiOptions variable inside CI.dll, and a specific registry key. Each one targets a different check that Windows or third-party software uses to verify VBS status. Getting all three right without breaking anything was harder than it sounds.

CPUID: "Microsoft Hv"

The first check anything does is CPUID leaf 0x40000000. If a hypervisor is present, this leaf returns the vendor string. I intercept CPUID in my #VMEXIT handler and return "Microsoft Hv" -- the exact 12-byte string that Hyper-V uses. When systeminfo says "A hypervisor has been detected," it's reading this leaf. I've been doing this since Stage 7, so this part was already working.

Finding g_CiOptions in CI.dll

The real target is g_CiOptions -- a global variable inside CI.dll (Code Integrity) that stores the current enforcement level. When HVCI (Hypervisor-enforced Code Integrity) is active, specific bits are set in this variable. Kernel-mode code reads g_CiOptions to decide whether to enforce driver signature requirements at the hypervisor level.

The variable isn't exported, so I pattern scan for it. The compiler always generates a MOV [rip+disp32], ecx instruction -- opcode 89 0D -- at a known point in CI.dll's initialization. I scan the CI.dll image for that pattern, extract the RIP-relative displacement, and compute the virtual address of g_CiOptions.

// pattern scan CI.dll for the g_CiOptions store
let ci_base = find_module_base("CI.dll");
let ci_size = get_module_size(ci_base);

// scan for: 89 0D xx xx xx xx (MOV [rip+disp32], ecx)
let pattern: [u8; 2] = [0x89, 0x0D];
let offset = scan_for_pattern(ci_base, ci_size, &pattern);

// extract RIP-relative displacement
let disp = unsafe { *(offset.add(2) as *const i32) };
let g_ci_options_va = offset.add(6).offset(disp as isize);

NPT shadow page for g_CiOptions

Now I know where g_CiOptions lives in virtual memory. I translate that VA to a physical address by walking CR3 page tables. Then I use NPT to serve a fake copy of that page -- my shadow page -- with the HVCI bits already set.

The process: find the 2MB PDE that covers the physical page containing g_CiOptions. Split it into 512 individual 4KB PTEs using split_2mb_to_4kb(). Replace the single 4KB PTE for the target page with a pointer to my shadow page. The shadow page is a byte-for-byte copy of the original, except I've set the HVCI bits in the g_CiOptions dword.

// split 2MB page into 4KB entries
split_2mb_to_4kb(npt, target_gpa);

// allocate + populate shadow page
let shadow = allocate_page();
copy_page(original_pa, shadow);
// set HVCI bits in the shadow copy
let ci_offset = target_gpa & 0xFFF;
unsafe {
    let ptr = (shadow_va.add(ci_offset) as *mut u32);
    *ptr |= CIOPTIONS_HVCI_BITS;
}

// remap the NPT entry: guest reads my shadow, not the real page
npt_remap_4k(npt, target_gpa, virt_to_phys(shadow));
The hardest part was npt_pa_to_va(). I needed to find the virtual address of an NPT page given its physical address, but MmGetVirtualForPhysical returns null for pool-allocated pages. I had to write a custom function that searches my G_NPT.pages[] array, comparing physical addresses one by one. Not elegant, but it works.

The registry: volatile, not persistent

Some checks look at HKLM\SYSTEM\CurrentControlSet\Control\CI\State\HVCIEnabled. I set this to 1 at runtime using ZwSetValueKey -- but only as a volatile key. This is critical.

I almost shipped this with persistent writes to DeviceGuard registry keys. That would have been catastrophic. Those keys tell the Windows boot loader to actually enable Hyper-V and VBS on next reboot. If I'd written them persistently, the next reboot would have turned on real Hyper-V, which would conflict with my hypervisor, which would BSOD the machine, which would trigger Automatic Repair, which might clear the keys -- or might not. I caught this in testing before it ever hit a real scenario. Volatile keys only. They disappear on reboot.

What works, what doesn't

After all three layers: systeminfo reports a hypervisor is detected. The CI\State\HVCIEnabled registry key reads 1. Any code that reads g_CiOptions sees HVCI bits set. Programs that check CPUID see Microsoft Hv.

But Windows Security still shows Memory Integrity as OFF. The reason: Win32_DeviceGuard WMI queries don't read g_CiOptions or the registry. They query the Secure Kernel via a secure hypercall interface -- HvlQuerySecureKernelState or similar. Since there is no Secure Kernel running (that's a VTL 1 thing, and I don't implement VTL), those queries return "not running."

Post-boot spoofing has a hard ceiling. I can fool kernel-mode code and most usermode checks, but I can't fake the Secure Kernel without actually implementing VTL 1. The Windows Security app talks to a real isolation boundary that doesn't exist in my setup. For that to work, I'd need pre-boot launch -- which is exactly what SmmInfect gives me, and where future work is headed.