HVCI, and how far down a SYSTEM shell actually gets you
For there is nothing covered, that shall not be revealed; neither hid, that shall not be known.
Luke 12:2 (KJV)
i had a SYSTEM shell on the win-exp box from the SteelSeries updater bug and i wanted to know how far down it went. SYSTEM is the top of the usermode pile but it isn't the kernel, and the first thing i reached for, reading kernel memory to lift a token, didn't get me anything. that's HVCI. i hadn't actually sat down and worked out what it does and doesn't stop since it became the default, so i did. what holds, what people still get through, and what intel and microsoft are adding to close the two gaps that are left.
the g_CiOptions line
the old DSE trick was to zero CI!g_CiOptions (older builds had the same policy in nt!g_CiEnabled), load your unsigned driver in the PatchGuard window, restore the variable before KPP BSODs you. that worked for years and half the BYOVD tooling still ships it. VBS broke the direct write, CI.dll now passes g_CiOptions through MmProtectDriverSection, which is KDP, and a kernel write to the protected page faults even from WinDbg with DebugFlags set to 0x10. Chester walked through this on TrustedSec in 2022. people keep mixing up VBS and HVCI. VBS is the hypervisor umbrella. HVCI is the code-integrity bit under it. on a box with VBS on and HVCI off, DSE is still bypassable.
cryptoplague hit the same wall from driver-dev class while working through Zero-Point's offensive driver course. VBS wasn't even on, the g_CiOptions patch landed, and the unsigned driver still refused to load. he tried the same against a tailored CVE-2018-19320 example and got the same result, write succeeds, load fails, PatchGuard waiting in the wings. KDP was already shipping in pieces before the VBS checkbox told you it was there.
the route Chester documented, and XPN published the same week, is to stop patching the policy and patch the check. breakpoint CiCheckPolicyBits on a failed driver load and the stack is SeValidateImageHeader calling into CI!CiValidateImageHeader, which comes back 0xc0000428, STATUS_INVALID_IMAGE_HASH. stub that to return STATUS_SUCCESS and DSE is gone for as long as the patch holds. the function body isn't behind KDP. Chester finds MiGetPteAddress by signature in ntoskrnl.exe's .text section, pulls the PTE base out of the instruction (reboot-randomized, McGarr's PTE overwrite post is where that hunt comes from), locates CiValidateImageHeader by signature in ci.dll's PAGE section, sets PTE |= 2, writes 48 31 c0 c3 at the entry, loads the driver, then flips the PTE back and restores the original four bytes before PatchGuard notices.
he built it twice. once from a signed driver with kernel read/write, once from userland through Intel's iqvw64e.sys with only a copy primitive. the BYOVD path is the one that shows up outside a lab: resolve ntoskrnl.exe and CI.dll bases with NtQuerySystemInformation, map both as SEC_IMAGE in your process, signature-scan the same bytes user-side, compute kernel VAs, read the PTE through the vuln driver, OR the write bit, copy the patch across, revert everything. on defense he says HVCI first, and if you can't have HVCI, Microsoft's ASR blocklist for known-abused vulnerable drivers, weaker, given how many signed drivers still ship kernel R/W IOCTLs.
cryptoplague names three ways through DSE with VBS actually enabled. FortiGuard's swan song post covers two of them, page swapping on g_CiOptions and callback swapping on nt!SeCiCallbacks. Chester and XPN published the third, the PTE flip on CiValidateImageHeader. cryptoplague implemented callback swapping because it's the only one that needs a kernel write primitive, not full read/write:
- page swapping on
g_CiOptions. copy the KDP-protected page, edit the copy, repoint the PTE. same family as the remap Tanda documented later on other KDP targets. - PTE flip + patch on
CiValidateImageHeader. Chester's technique. makes an executable page writable long enough to patch it. - callback swapping on
nt!SeCiCallbacks. the pointer table lives inntoskrnland is not KDP-protected.SepInitializeCodeIntegritypasses the struct intoCI!CipInitialize, which fills the slots. cryptoplague pattern-scans userlandntoskrnl.exeloaded withLoadLibraryEx(..., DONT_RESOLVE_DLL_REFERENCES)forlea r8, [nt!SeCiCallbacks](ff 48 8b d3 4c 8d 05), walks the RIP-relative displacement, adds the0x20byte offset fromCipInitializeto theCiValidateImageHeaderslot, and overwrites that pointer withZwFlushInstructionCacheorFsRtlSyncVolumes, both exported, both return zero. oneDeviceIoControlwrite. the swapped function runs instead of the real validator and DSE is gone.
HVCI kills the second technique, not the first or third. Chester re-ran the PTE patch with HVCI on: the PTE flip looked fine, but the memcpy into CiValidateImageHeader faulted with SYSTEM_SERVICE_EXCEPTION anyway, because SLAT/EPT won't let an executable page go writable no matter what the guest PTE says. page-swap and callback-swap never touch executable pages, so they still work on hardware without HVPT/HLAT locking page tables behind KDP. HVCI is code integrity, not data integrity. "VBS enabled" on a compliance sheet is not the same as HVCI on the machine you're sitting at.
DSE bypass VBS off VBS on, no HVCI HVCI on, no HVPT
---------------------------------------------------------------------------
patch g_CiOptions direct works KDP blocks KDP blocks
g_CiOptions page swap n/a works works
patch CiValidateImageHeader n/a works HVCI blocks
SeCiCallbacks pointer swap n/a works works
disabling DSE still isn't owning the kernel on an HVCI box. even with DSE out of the way you still can't map RWX or run unsigned code in ring 0. what you get is a BYOVD read/write primitive and sometimes a loaded unsigned driver whose code HVCI won't let you execute. the rest of this post is what happens after that.
the model
HVCI uses the hypervisor to split the machine into two trust levels. the normal windows kernel runs in VTL0. a small separate kernel, the Secure Kernel, runs in VTL1, and it owns the EPT, which is the hypervisor's set of page tables sitting underneath the ones windows manages. VTL0 can read and write its own page tables all it likes and it still can't touch the EPT. so every page ends up carrying two sets of permissions, the PTE windows controls and the EPT entry the Secure Kernel controls, and when the two disagree the hardware goes with the EPT entry. McGarr's phrasing for it is that physical memory trumps virtual memory. you can mark a page read-write-execute in the PTE and it changes nothing, because the EPT entry behind it says read-only and that's the one the CPU obeys.
virtual address
|
v
[ PTE ] R W X <- windows controls this, you can edit it
|
v
guest-physical page
|
v
[ EPTE ] R - - <- Secure Kernel controls this, you can't
|
v
effective R - - <- the two get AND'd. the EPTE wins.
this is what kills the standard kernel exploit and what killed Chester's CiValidateImageHeader patch. the usual ending is to get a page that's both writable and executable, drop shellcode into it, and jump. you can't, because nothing is allowed to be writable and executable at the same time at the EPT level. McGarr walked through it directly: he corrupted a PTE to mark a page executable, wrote a block of NOPs into it, and jumped in, and the box bugchecked. windows believed the page was executable because the PTE said so, but the EPT entry behind it still had execute cleared, and there is nothing in VTL0 that can clear it. the variant where you flip a page's user/supervisor bit so a user-mode RWX page runs as kernel code is gone too, because MBEC marks user pages non-executable whenever the processor is in the kernel.
what still gets through
so HVCI stops you running your own code in the kernel. that's it. on a box with HVCI and nothing newer there are two routes, and both have writeups.
the first is reusing the kernel's own code with ROP. kernel CFG checks the target of every indirect call and jump against a bitmap, but it never looks at return addresses. so you don't bring code, you reuse what's already loaded. with a read/write primitive you find a thread you control, overwrite a saved return address on its kernel stack, and when that function returns it runs a chain of gadgets out of the signed kernel image that's sitting in memory anyway. nothing unsigned ever runs, so HVCI has no reason to care. McGarr's HVCI post builds this end to end: you can't run your shellcode, so you call the same kernel functions your shellcode would have called.
the second route isn't about code. HVCI protects code from being changed or faked. it doesn't care whether your data still means what you think it means. Kernel Data Protection is what microsoft added for that, chosen kernel data marked read-only in the EPT, so a kernel write to the page itself faults. but KDP protects the contents of a physical page, not the path your virtual address takes to reach that page. microsoft said this themselves when they shipped it in 2020, that the Secure Kernel only checks on a periodic basis that a protected region still translates to the physical page it's supposed to. so you leave the protected page alone. you copy it somewhere ordinary, edit the copy, and change the page tables so the protected virtual address now resolves to your copy instead of the original. Tanda wrote up the three steps. your copy lives in a normal writable page so there's no EPT violation, and from there on everything that reads the protected address reads your version of it. the hypervisor never sees the switch, because seeing it would mean trapping and inspecting every single write to a page table, and a VM exit on every page-table write costs far more than anyone is willing to pay.
FortiGuard's g_CiOptions page swap and cryptoplague's SeCiCallbacks swap are the same trick on different targets, remap the protected policy page, or overwrite an unprotected pointer table. both are data-only. both work under HVCI until something starts protecting the page tables.
before after the PFN swap
prot. VA --PTE--> GPA_a prot. VA --PTE--> GPA_b
| |
EPTE R-- locked EPTE RW- normal page
| |
the real data your modified copy
the EPTE on GPA_a never changed. you just stopped pointing at it.
no write to protected memory, so no EPT violation ever fires.
the weak point is narrow but it's real. the EPT enforces the permissions of whatever page an address resolves to right now. it never checks that the address still resolves to the page it was meant to.
what's closing it
both gaps have answers, and they're shipping.
the ROP one is closed by kernel CET. the processor keeps its own protected copy of every return address and checks the real stack against it on each return, and if they don't match it faults. that takes away the writable return address the whole technique stands on. it's been in windows since the 22H2 build and it only runs with HVCI on, so the two travel together.
the remapping one is closed in hardware, by intel's VT-rp, and mostly by the HLAT piece of it. when HLAT is on, for the ranges it's protecting, the processor stops using the guest page tables and walks a separate set the hypervisor owns, found through a field in the VMCS. changing the PFN in the guest PTE does nothing now, because the guest PTE isn't part of the translation any more. two supporting pieces, paging-write and guest-paging verification, exist to stop you sidestepping that by aliasing the hypervisor's own page tables. microsoft ships the whole thing as HVPT, and they're clear that its job is to protect the page tables behind KDP, shadow stacks, and CFG. it's on by default in windows 11 24H2, on tiger lake and intel 11th gen and up. that's what closes the g_CiOptions page-swap path too, not just the generic KDP remap.
so where does that leave it
which of these you're up against depends on the hardware, not the checkbox. on
an older CPU, or any machine where HVPT isn't running, the remap attack works
the way Tanda laid it out, KDP comes apart, a SeCiCallbacks swap
still disables DSE, and a kernel read/write still gets you where you want. on a
24H2 machine with current silicon, CET closes the ROP side and HLAT closes the
remap side. same three words on the settings page, HVCI is enabled, two very
different targets.
path plain HVCI + kCET / HLAT
-----------------------------------------------------------------
unsigned code (RWX / KRWX) blocked blocked
user/supervisor flip blocked (MBEC) blocked
patch executable pages blocked blocked
DSE: callback swap works works
DSE: g_CiOptions page swap works closed by HLAT
ROP through signed code works closed by kCET
data-only remap (KDP) works closed by HLAT/HVPT
it isn't finished closing either. HLAT only locks the ranges the hypervisor pointed it at, not all of memory, and it has a fallback that drops back to ordinary paging under some conditions. the structures it doesn't cover, and whatever can trigger that fallback, are probably where the next round lands. Allievi has been publishing on the Secure Kernel internals, Tanda documented VT-rp end to end, and McGarr took the control-flow half of this to Black Hat this year. the data-only route isn't theoretical, either, Lazarus' FudModule rootkit goes admin to kernel and then does its work entirely through data with no unsigned code anywhere in it.
i don't have a clean way past HLAT on current hardware and i'm not going to pretend i do. the cheap, universal version of kernel-read/write-to-SYSTEM is gone. the two routes that are left are both being closed as the install base turns over. whether either one still works on the box in front of you is a question about the exact CPU and the exact windows build, not about whether the HVCI checkbox is ticked.
there's a third angle that doesn't touch any of this, because it goes below the VTL model entirely: disk DMA. LabGuy94 published a technique for hijacking Hyper-V at runtime with no bootkit. the core premise is that Hyper-V doesn't virtualize guest access to devices when IOMMU is disabled, which means disk hardware can DMA directly to any physical address, including the ranges Hyper-V uses for itself. a VHD is used as a proxy to safely read and write physical memory without corrupting critical structures; from there you signature-scan the VM exit handler, find free pages inside Hyper-V's protected range, write shellcode into them, and redirect execution. PTE, EPT, SLAT, HLAT, all of that is software and microarchitecture that sits above the DMA path. none of it applies. IOMMU is what closes this, not HVPT or kCET, and whether IOMMU is enabled and enforcing is a separate question from whether HVCI is on.
reading
- Adam Chester, g_CiOptions in a Virtualized World, TrustedSec 2022. VBS/KDP on the policy variable,
CiValidateImageHeaderPTE patch, BYOVD path, HVCI killing the patch. - cryptoplague, The dusk of g_CiOptions: circumventing DSE with VBS enabled. the three VBS-era techniques,
SeCiCallbacksswap end to end. - Adam Chester / XPN, g_CiOptions in a Virtualized World. same research line, XPN's writeup.
- FortiGuard Labs, The Swan Song for Driver Signature Enforcement Tampering. page swapping and callback swapping.
- Connor McGarr, No Code Execution? No Problem! Living The Age of VBS, HVCI, and Kernel CFG. the KRWX crash and the signed-code ROP technique.
- Connor McGarr, Out Of Control: How KCFG and KCET Redefine Control Flow, Black Hat USA 2025.
- Satoshi Tanda, Intel VT-rp part 1 (HLAT) and part 2 (paging-write and guest-paging verification). the remap attack, the three steps, and the hardware fix.
- Microsoft, Introducing Kernel Data Protection. the periodic-verification line is theirs.
- Microsoft, Hardware security: silicon-assisted security. HVPT and kCET, on by default in 24H2.
- Andrea Allievi, Secure Kernel internals, NoHat 2024.
- LabGuy94, Runtime Hyper-V Hijacking. disk DMA to read and write Hyper-V's physical memory when IOMMU is disabled; VM exit handler redirect without a bootkit.
thanks
@AstharotRTO and
@meridi6n sent me the Chester and
cryptoplague links. i had the McGarr/Tanda half drafted and was going to skip
past g_CiOptions. those posts are why the middle section exists.
There is gold, and a multitude of rubies: but the lips of knowledge are a precious jewel.
Proverbs 20:15 (KJV)
. _SiCk · afflicted.sh