.. /blog
2026-03-05 // 6 min

KERNEL_SECURITY_CHECK_FAILURE: Proof the Kernel Saw My Fake Bits

bsodvbsdebugging

I ran sc stop athas and the machine blue-screened. Bug check 0x139 -- KERNEL_SECURITY_CHECK_FAILURE. For a moment I thought I'd broken something fundamental. Then I realized: this BSOD was actually proof that my VBS spoofing was working perfectly.

The kernel was so convinced that HVCI was running that when I pulled the rug out during teardown, it detected the "integrity violation" and panicked. My fake bits were indistinguishable from the real thing.

What was happening during teardown

My driver's unload routine devirtualizes each core. It restores the machine to bare metal. Part of that restoration was undoing the NPT shadow on g_CiOptions -- the variable in CI.dll that holds the Code Integrity flags, including the HVCI bit (0x20).

The NPT shadow works by remapping the physical page containing g_CiOptions. When the kernel reads that address, NPT translates it to my shadow page where the HVCI bit is set. When the kernel writes, it goes to the real page. This is the core of the VBS spoof.

// teardown sequence -- the buggy version

fn teardown_vbs_spoof(npt: &mut NptRoot) {
    // step 1: restore g_CiOptions NPT PTE to original page
    npt.restore_original_mapping(G_CIOPTIONS_GPA);

    // step 2: devirtualize this core
    // ... turn off SVM, restore bare metal ...

    // THE PROBLEM:
    // between step 1 and step 2, the hypervisor is still running
    // the kernel is still a guest
    // but now the NPT shadow is gone
    // the kernel reads g_CiOptions and sees: HVCI bit = 0
    // it JUST saw HVCI bit = 1 a moment ago
    // CI.dll detects the change and calls KeBugCheck(0x139)
}

The window between restoring the NPT mapping and actually devirtualizing the core is where the kernel can observe the change. And CI.dll does observe it. It periodically validates g_CiOptions against its expected state. When the HVCI bit vanishes mid-flight, it calls KeBugCheck with KERNEL_SECURITY_CHECK_FAILURE.

Why this is actually good news

Think about what this BSOD means. The kernel's code integrity subsystem was monitoring g_CiOptions continuously. It detected when the value changed. It responded by crashing the system to prevent a potential integrity violation.

That means my shadow page was being read by CI.dll's validation checks. The kernel was seeing my fake HVCI bit and treating it as real. If the spoof wasn't working, the kernel would never have established the "HVCI is on" state in the first place, and removing it wouldn't trigger a security check.

The BSOD at teardown is the ultimate validation test. If the kernel panics because HVCI disappeared, it means the kernel genuinely believed HVCI was present. The spoof was indistinguishable from real HVCI at the CI.dll level.

The fix

The fix is straightforward: don't restore the NPT mapping during teardown. The NPT is about to be destroyed anyway -- once I devirtualize the core, there are no more nested page tables. The guest physical address space becomes the real physical address space. The shadow page mapping ceases to exist automatically.

// fixed teardown sequence

fn teardown_vbs_spoof(npt: &mut NptRoot) {
    // DO NOT restore g_CiOptions PTE
    // the kernel is still watching it
    // let it keep seeing the shadow until devirt

    // step 1: devirtualize this core
    devirtualize_core();

    // at this point:
    // - NPT doesn't exist anymore
    // - the CPU reads real physical memory directly
    // - g_CiOptions reads from the REAL page now
    // - the real page has the original value (no HVCI bit)
    //
    // but here's the thing: CI.dll's check happens periodically
    // the transition from "shadow" to "real" is atomic with devirt
    // there's no window where the kernel can observe the change
    // because the very next instruction after devirt runs on bare metal
    // and CI.dll will re-read g_CiOptions on its next check cycle
    // by then, everything is consistent: HVCI bit gone, no hypervisor

    // step 2: free the shadow page memory
    free_shadow_page(G_CIOPTIONS_SHADOW);
}

The key insight is that devirtualization and NPT removal are atomic from the kernel's perspective. The kernel can't observe the transition because the transition happens on the same instruction boundary as the VMEXIT-to-bare-metal switch. One moment the kernel reads through NPT (sees shadow). Next moment, no NPT exists (sees real). There's no in-between state where the kernel is running as a guest but the shadow is gone.

What CI.dll actually checks

I dug into CI.dll to understand why the check fires. CiValidateDynamicCodeOptions is called from several paths -- DPC timers, on process creation, and during driver load. It reads g_CiOptions and compares certain bits against an internal "expected" state. If the HVCI-related bits (0x20, 0x400) changed since the last check, it calls __fastfail, which triggers bug check 0x139.

// pseudocode from CI.dll (decompiled)

void CiValidateDynamicCodeOptions() {
    ULONG current = *g_CiOptions;
    ULONG expected = g_CiExpectedOptions;

    // check if security-critical bits changed
    if ((current ^ expected) & SECURITY_BITS_MASK) {
        // integrity violation -- someone tampered with CI options
        __fastfail(0x139);
    }
}

This is actually a PatchGuard-adjacent defense. Microsoft doesn't just set g_CiOptions once -- they continuously verify it hasn't been tampered with. If a rootkit clears the HVCI bit to disable code integrity, this check catches it. My situation was the reverse: I set the bit, and then accidentally cleared it during teardown.

The broader lesson

Teardown is harder than setup. When I virtualize the machine, I can take my time -- set up NPT, configure the shadow, make sure everything is consistent before exposing it to the kernel. But during teardown, the kernel is actively watching. It has cached state. It has periodic checks. It has invariants that it established based on the state I showed it.

I can't just "undo" the spoof and hope the kernel doesn't notice. I have to make the transition atomic. The shadow must persist right up to the moment of devirtualization, and then disappear simultaneously with the entire NPT. No gap, no window, no observable intermediate state.

After the fix, sc stop athas works cleanly. Every core devirtualizes, the NPT and shadow pages are freed, and the machine runs on bare metal again. No BSOD. And CI.dll's next check cycle sees the real g_CiOptions with no HVCI bit, which is correct because there's no hypervisor anymore.