Bypassing Kernel Code Execution: A Data-Only SSDT Hijack Under VBS/HVCI, but how?
Share
By Juan Sacco (Linkedin) founder of https://exploitpack.com
I have always been interested in Windows Kernel Exploitation, but this one was indeed a challenge! VBS/HVCI protections, are without doubts an awesome piece of technology by itself.
First, let's step back a little. This post goes over achieving Kernel Code Execution under VBS/HVCI and will not cover ways of obtaining primitives or other methods.
VBS/HVCI represents a two-layer security architecture within modern Windows operating systems that fundamentally rearchitects how the kernel is protected from compromise. At its core, VBS (Virtualization-Based Security) is a hypervisor-enforced isolation mechanism, while HVCI (Hypervisor-Protected Code Integrity) is a specific, critical policy enforcement capability that runs within that isolated environment.
Technically, VBS leverages hardware virtualization extensions (Intel VT-d/AMD-Vi and SLAT) to create and isolate a secure region of memory from the normal operating system kernel. This is not a traditional virtual machine for guest operating systems but a minimal, highly secured "virtual secure mode" (VSM). Within this VSM, sensitive security services run in a context that the Windows kernel itself cannot read from, write to, or interfere with, even if the kernel is fully compromised. This is achieved by the hypervisor (Windows Hyper-V) configuring and enforcing second-level address translation (SLAT) page tables that physically separate the memory of the normal kernel or VTL0 from the secure services or VTL1.
HVCI is the foremost service running within this VSM. It is the enforcement mechanism for Mandatory Kernel Mode Code Integrity (KMCI). Its primary technical function is to validate all code before it is allowed to execute in kernel mode. This validation ensures that every page of kernel-mode code, whether from a driver or the kernel itself, is digitally signed by a trusted authority and has not been tampered with. The crucial architectural shift HVCI introduces is where and how this policy is enforced. In traditional systems, code integrity checks were performed by the kernel itself, making them vulnerable to being disabled or bypassed by a kernel-level exploit. With HVCI, the decision logic and cryptographic signature databases reside within the VBS-protected environment.
The enforcement mechanism is even more granular. HVCI works in concert with the hypervisor to mark all kernel memory pages as non-executable by default, utilizing the No-eXecute (NX) bit at the hypervisor-managed page table level. When kernel code needs to execute, a page fault is triggered. This fault is intercepted and handled by the HVCI service within the secure world. HVCI then validates the requesting page against its policy. Only if the code is verified as legitimate does HVCI instruct the hypervisor to temporarily map the page as executable for the duration of the instruction fetch, after which it is typically returned to a non-executable state. This process, often referred to as "executable-only no-write" or "lazy mapping," prevents attackers from modifying existing, validated kernel code or injecting and executing shellcode in kernel memory, as any attempt to create new executable kernel memory will fail the integrity check.
Furthermore, HVCI implicitly enforces a W^X (write XOR execute) policy for kernel memory, meaning a page can be writable or executable, but never both simultaneously. This renders many kernel exploitation techniques, such as classic buffer or heap overflow code injection or even more advanced data-only attacks that attempt to corrupt function pointers, ineffective because any injected payload cannot be made executable, and any attempt to point execution to writable data pages will be blocked.
In short: VBS provides the isolated, hypervisor-protected execution environment, and HVCI is the specific tenant of that environment that performs cryptographically verified code integrity checks. It moves the authority for deciding what can run in the kernel outside of the kernel's own reach and enforces it at the memory page level through hypervisor-managed page table permissions, thereby creating a formidable barrier against kernel-mode malware and driver-based exploits.
Having a good understanding of how VBS/HVCI works and how VTL0/VTL1 interact is key to leverage your R/W primitives into code-execution. It's a matter of time until you conduct a data-only attack after you control R/W primitives, but that will not give you code execution in kernel context, and keep in mind that kCET (Shadow stacks) is not enforced but in most cases it's enabled, that leaves the ROP execution using suspended threads dead and out of the water under kCET (shadow stacks) because the enforced shadow stack makes return-slot rewriting impossible.
But a while ago there was another possible way: PFN/SSDT hijacking via direct page writes, but that also dies under the newer Hyper‑V protections—Microsoft calls it “HV‑protected text” (HVPT) in the VBS/HVCI stack—where EPT/MBEC marks kernel code pages execute‑only and rejects your RW attempts on the KiServiceTable page (and other text). In that configuration you’re left with data‑only attacks: temporary SSDT delta edits plus user‑mode stubs, or pure call‑gate/syscall‑stubs. But classic PFN/page‑table rewrites or kernel‑ROP chains won’t survive CET + HVPT/HVCI.
Suspended‑thread ROP path (no CET/HVPT)
Bypasses: SMEP/SMAP (because we never jump to user pages), KASLR (we resolve and patch kernel symbols directly), NX (we flip PTE/PDE RW/X to plant the ROP chain), code‑integrity at rest (we transiently modify kernel text/data), CFG in user mode (we drive the call from a custom stub), basic EPT W^X (we force RW on the SSDT page).
Not bypassed / blocked by: CET (shadow stacks kill the ret-slot rewrite), HVCI/HVPT/MBEC (EPT enforces execute‑only on code pages; our RW flip is denied), Kernel Code Integrity (would detect persistent code mods), PatchGuard (likely to trip if left patched), VT shadow stacks.
Data‑only SSDT hijack path (what this blog is about):
Bypasses: KASLR (symbol resolver finds real addresses), CFG in user mode (custom syscall stub), SMEP/SMAP (no user-kernel jump; we stay in kernel via SSDT), traditional code‑integrity checks (no kernel code is changed), PatchGuard in practice (only a brief SSDT delta; restored immediately so we are safe there), NX/MBEC (we don’t clear NX or make code writable).
Not bypassed / still enforced: CET (still active, but irrelevant because we don’t smash returns as with did with ROP), HVCI/HVPT (we rely on data‑only writes to the SSDT entry; if HVPT blocks SSDT writes, the patch fails), any EPT W^X that forbids writes to the SSDT page (same point), Kernel CI (would still block unsigned drivers—assumes primitive already present of course). The code integrity policy remains fully enforced; our attack succeeds only because the SSDT data page is not marked read-only by a policy like KDP. Critically, this technique does not violate HVCI. This technique works by redirecting calls through the SSDT, a writable kernel data table, to already approved, signed functions. This exploit a gap in data protection, not a flaw in code protection.
TL;DR: The ROP-Suspended thread defeats more defenses but it dies when CET/HVPT are active. The SSDT data‑only works under CET and doesn’t touch code, but it still depends on being able to write the SSDT entry.
Pre-requisites for this to work:
For this SSDT hijack to succeed I used: a working kernel context that can read/write kernel memory (physical or via CR3); dbghelp/symsrv available so _EPROCESS.ImageFileName and exports like DbgPrint/DbgPrintEx can be resolved from my library; ntdll loaded so the NtSetQuotaInformationFile syscall number can be read, but it's always loaded in most cases; the target kernel routine must be within the SSDT encoding range (±0x7FFF000 from the SSDT base); and a standard 64-bit KeServiceDescriptorTable layout (base/count at +0x0/+0x10). The approach is data‑only and runs entirely through user‑mode stubs, so it remains compatible with HVCI/Hyper‑V/CET
How it works:
We start by locating the native service table. The code reads KeServiceDescriptorTable to get the SSDT base address and the number of entries, then grabs the syscall index for NtSetQuotaInformationFile straight from the ntdll user-mode stub. It resolves kernel exports for the two things we want to call: first PsLookupProcessByProcessId, later DbgPrint/DbgPrintEx.
To hijack the call, it only edits data: the SSDT entry for NtSetQuotaInformationFile. The SSDT stores a 32-bit signed offset to the target routine. We compute delta = target – ssdt_base, check it’s within the ±0x7FFF000 encoding limit, save the original entry, and write the patched value. No page permissions or kernel code are touched at this point.
Execution is driven from user mode with a tiny stub built on the fly (RunSyscallStub). The stub moves RCX to R10, sets RAX to the chosen syscall number, lays out arguments (and an inline string if needed) per the Windows x64 ABI, executes syscall, and returns the NTSTATUS. First we call PsLookupProcessByProcessId(pid 4/System) and print the _EPROCESS.ImageFileName. Field offsets come from the dbghelp-based symbol resolver, and each resolve logs what was requested and what was found.
Using the same SSDT slot, we repatch the entry to point to DbgPrint/DbgPrintEx, rebuild the stub with the message “Hello, from kernel\n”, and issue the syscall. If the export is present and the delta is in range, you see the DbgPrint NTSTATUS in the output.
Cleanup: restore the original SSDT entry. Because we never changed PTEs/PDEs or injected code into kernel memory, there’s nothing else to undo. The path is compatible with HVCI/Hyper‑V/CET: all execution happens in user mode; only a data pointer in the SSDT is temporarily modified. Logs show every major step: SSDT addresses, syscall index, original/patched entry, PsLookupProcessByProcessId result, ImageFileName, DbgPrint status, and the final restoration line.
Resolving SSDT and syscall index (user mode):

Patching the SSDT entry (data‑only):

Generic user‑mode syscall stub (built during runtime):

Symbol/gadget resolution (NTKernelWalkerLib, that builts during runtime the offsets/gadgets):

Download NTKernelWalker Lib here: https://github.com/jsacco/NTKernelWalkerLib/tree/main
Verifying the data‑only attack (no PTE/EP TE):
The attack flow starts by resolving syscall target:
Load ntdll, read NtSetQuotaInformationFile syscall number from the user‑mode stub.
Read KeServiceDescriptorTable to get native SSDT base and service count.
Resolve kernel targets via exports/symbols:
PsLookupProcessByProcessId for the first call.
DbgPrint/DbgPrintEx for the second call (if available).
Patch SSDT entry (data‑only)
Compute delta = target – ssdt_base, ensure it fits the ±0x7FFF000 encoding.
Read and save the original 32‑bit entry.
Write the patched entry (delta>>4 in bits 31:8, arg count in low nibble).
Invoke via generic user‑mode stub
Build a small user‑mode code stub at runtime (RunSyscallStub):
Moves RCX→R10, sets RAX=syscall#, copies args into shadow space/stack, places an inline string if needed, issues syscall, returns status.
First call: invokes PsLookupProcessByProcessId(pid=4, &eprocess_out).
Reads _EPROCESS.ImageFileName using symbol resolver to show the process name.
Second call (same SSDT slot reused)
Repatch the same SSDT entry to point to DbgPrint/DbgPrintEx.
Build a stub with args (ComponentId=0, Level=4, Format="Hello, from kernel\n").
Issue syscall; log NTSTATUS.
Restore
Rewrite the original SSDT entry.
No PTE/PDE or kernel code modifications are left behind.
Key properties
Data‑only: all writes are to the SSDT entry; no kernel code is injected or executed in kernel context.
Safe with HVCI/Hyper‑V/CET: execution happens entirely in user mode; only the SSDT delta changes briefly.
Symbol resolver logs: every struct field request/resolve is printed ([sym] request/resolved).
Using existing virtual-to-physical address translation primitives: SSDT reads/writes use the existing VA→PA/CR3 paths already present.
Artifacts you’ll see in output
[ssdt] KeServiceDescriptorTable VA, native SSDT base/count
Target syscall index and original entry
Patched entry effective address
PsLookupProcessByProcessId status and ImageFileName
DbgPrint syscall status
Final “restored entry” message confirming cleanup.
All together in the mandatory NT/System screenshot while executing the ssdt hijack to call PsLookupProcessByProcessId and DbgPrint.

VBS/HVCI is extraordinarily effective at preventing code injection and code modification attacks, raising the bar immensely. However, the persistence of powerful data-only attacks highlights that control flow integrity (which CET addresses in part) and critical data structure integrity (which KDP addresses) are separate, necessary layers in a defense-in-depth strategy.
The main takeaway: VBS/HVCI alone is not a silver bullet to stop Windows Kernel Exploits.