Two guests, one variable: building a lab a VTL1 claim can survive
26 September 2026 · Windows internals · Secure Kernel from the root, part 3 of 5
A control twin, a probe that failed for a policy nobody named, and a small driver that asks the hypervisor questions on behalf of the root partition.
Part 1 and part 2 closed the two routes that would have let someone else's code do the work. What is left is to ask the hypervisor directly, from the root partition that runs the guest — which means a driver, a weakened host, and a way to tell a real reading from a plausible one.
This post is the instrument. Part 4 is what it measured. The order is deliberate: on this problem an SK failure and a rig failure are indistinguishable from the calling side, and a wrong answer usually looks like a plausible number rather than an error. A gate without its control is not evidence, and the control has to exist before the result does.
The control is a second guest
Two Hyper-V guests, identical but for virtualization-based security. The second one is not optional — it is the control for every later reading, and without it a plausible-looking read cannot be told from a real one.
One tension had to be settled before building the pair rather than after. The target needs VBS so that a Secure Kernel exists at all; the tool considered as an oracle wants nested virtualization disabled on the guest. Are those the same knob? Measured first, on one guest, cheaply: a Generation 2 guest, configuration version 12.0, 2 vCPU, 4 GB static memory, vTPM enabled, and ExposeVirtualizationExtensions = False confirmed from the host, which is the authoritative side, reports from inside:
VBS=2 Running=2
So VTL1 is active and HVCI is running in a guest whose nesting is off. The two are independent knobs on this host, and the oracle survives.
Read those two fields as separate facts.
VirtualizationBasedSecurityStatus = 2says VTL1 is up andsecurekernel.exeis loaded.SecurityServicesRunningsays what is using it, and it read 0 on the first pass — VBS running with no service behind it. That state passes every gate here, since VTL1 exists and has a CR3 either way, but it is a thinner target than the subject of study: HyperGuard and SKPG are what make VTL1 interesting and they arrive with HVCI. Turning on Memory Integrity and rebooting moved it to 2.
Two traps came out of that, both worth carrying:
The
Secure Systemprocess is present whenever VBS runs and says nothing about which services are active. It cannot stand in for theSecurityServicesRunningreading — it was sampled in both states, identical but for a working set.Both the guest and the host report
VBS=2, so a bare status reading cannot be attributed to a machine. Every VBS reading in this work is taken with$env:COMPUTERNAMEbeside it, which is what caught the first one that was actually the host answering.
The twin is a clone, on purpose
The control was cloned from the target after pinning its configuration, so both are on the same build by construction rather than by two separate acts of configuration that could drift.
| Target | Control | |
|---|---|---|
VirtualizationBasedSecurityStatus |
2 | 0 |
SecurityServicesRunning |
2 | 0 |
Secure System process |
present | absent |
| Generation / Secure Boot / vTPM | Gen 2, on (MicrosoftWindows template), enabled |
identical |
| vCPU / memory / nesting | 2 / 4 GB static / off | identical |
| Build | 26200.9457, securekernel.exe and ntoskrnl.exe both 10.0.26100.9457 |
identical |
| Updates | wuauserv disabled, NoAutoUpdate=1 |
identical |
The single difference is VirtualizationBasedSecurityOptOut on the VM. Each guest is checkpointed.
The control's guest OS is still configured for HVCI — reporting Configured=2 while Running=0 — and that is deliberate. Turning HVCI off inside the control as well would have made the twins differ in two ways, and a later failure could then be attributed to either. Leaving the guest configuration identical means the only variable is whether the hypervisor grants VBS at all.
Three procedural notes, each of which cost something to learn:
A clone reports its parent's computer name. The control was renamed before any reading was taken from it. Without that,
$env:COMPUTERNAMEattribution fails silently in exactly the situation it exists for.A live export carries saved state. The copy had to be cold-booted rather than resumed, because resuming would have restored a VTL1 that the opt-out is supposed to prevent — a control that is not a control.
Import-VM -GenerateNewIdrequires-Copy, which preserves the source VHDX filename. So the destination must be a folder that does not already hold the original's disk, and the copy costs a second full-size allocation rather than none.
The cheap probe, and why it failed
Before writing a driver: is there a driver-free way for the root to read a guest's physical memory? One existing tool advertises a read method that goes through winhvr.sys rather than its own driver, and running someone else's GPL tool as an oracle is licence-safe in a way that deriving from its source is not. Configured from the tool's own published schema rather than by guesswork — read method and write method both set to the WinHv interface, driver reload off, maximum log level — and then, crucially, checked for the driver service afterwards rather than trusting the setting.
SdkEnumPartitions -> 0 partitions (with two guests running)
hvmm service -> ABSENT before, created during the call, failed to start
CodeIntegrity 3077 -> driver "did not meet the Authenticode signing level
requirements or violated code integrity policy"
CodeIntegrity 3004 -> "unable to verify the image integrity ... file hash
could not be found on the system"
SCM 7045 / 7000 -> service installed, then failed to start
Enumeration installs the driver regardless of the read method and regardless of the reload setting. So the WinHv option selects how memory is read and does not make the tool driver-free: enumeration is gated on that driver loading. The zero partitions is a downstream symptom of the blocked load, not an independent result. Which means the honest conclusion is narrower than "the driver-free path does not work": it was never exercised, and cannot be until enumeration succeeds. Treat "WinHv needs no driver" as untested rather than disproven.
The misdiagnosis worth publishing
The first diagnosis read "CodeIntegrity refused the driver" as "HVCI refused the driver". Those are different claims, and the event named which policy all along.
HVCI was disabled on the debugger host and the host rebooted — a real concession, made to unblock a probe — and the probe produced the identical failure: same zero partitions, same service install and failed start, and CodeIntegrity 3077 citing the same policy id, which event 3099 identifies as "Microsoft Windows Cross Certificates for Code Integrity Exceptions Policy". That policy is refreshed and activated at boot independently of HVCI, and it refuses the revoked 2013 certificate the tool's driver ships with regardless.
| Policy | Event | Effect |
|---|---|---|
{8f9cb695-5d48-48d6-a329-7202b44607e3} — cross-certificate exceptions |
3077 | blocked |
{784c4414-79f4-4c32-a6a5-f0fb42a51d0d} — vulnerable-driver blocklist |
3076 | audit only — logged, did not block |
The obvious guess -- the vulnerable-driver blocklist stopped it -- is wrong here, and acting on it would have meant disabling a protection that was not in the way. What actually loads that driver is test-signing plus re-signing it.
HVCI was restored. The lesson generalises past this bench: an event naming a policy id is naming which gate, and reading past it to the gate you expected is how a security posture gets weakened for nothing. The question is never only can we afford to weaken this host, but also does weakening it achieve the thing we are weakening it for — and the second question was not asked.
A driver of our own, and why that is the smaller attack surface
There is no distribution story for a driver whose purpose is handing user mode a read of memory it could not otherwise reach. Microsoft will not sign one; a driver that got signed anyway would be a blocklist candidate later. So any such driver is test-signed, self-signed, or admitted by an enterprise code-integrity policy — each of which means the operator reconfigures their machine. That makes it a research capability rather than a feature, however well it works.
The alternative is the bring-your-own-vulnerable-driver route: ride something already signed. The oracle carries exactly that option, naming a well-known graphics utility driver whose CVE gives arbitrary physical read and write to any caller that can reach it. This work does not take it, for reasons that are practical before they are anything else:
It is on Microsoft's vulnerable-driver blocklist and is refused by name, so it fails on exactly the hosts where a bypass would be wanted — the same wall as the revoked certificate, reached by another road.
It is among the most closely monitored binaries in existence, so EDR flags it and a blocklist update breaks it.
The asymmetry runs the wrong way. A purpose-built driver needs to issue specific hypercalls against a named partition. That one hands arbitrary physical memory to anyone who asks. Avoiding a driver of our own would produce the larger attack surface, not the smaller one.
So where a driver is unavoidable it is ours, minimal, and test-signed: the operator reconfigures their machine either way, and only one of the two is auditable.
The instrument, as built
nt!HvlInvokeHypercall is exported and resolvable at runtime, which is what makes a small driver sufficient: it issues arbitrary hypercalls without the driver building its own hypercall page, and MmGetSystemRoutineAddress reaches it without depending on it being in the public ntoskrnl.lib. HvlInvokeFastExtendedHypercall is exported beside it.
The probe enumerates child partitions with HvCallGetNextChildPartition (0x0047, Simple) and, for each child's VP 0, issues HvCallGetVpRegisters (0x0050, Rep, rep count 1) four times: CR3 at TargetVtl=0 — the control — CR3 at TargetVtl=1 — the test — then HvRegisterVsmVpStatus and HvRegisterVsmPartitionStatus as independent corroboration that VTL1 exists on that VP at all.
HvX64RegisterCr3 0x00040002
HvRegisterVsmVpStatus 0x000D0003
HvRegisterVsmPartitionStatus 0x000D0004
HV_INPUT_VTL = TargetVtl : 4 | UseTargetVtl : 1 at input offset 12
Every constant comes from Microsoft's published hypervisor top-level functional specification, not from the GPL tool's headers. Running that tool as an oracle is unrestricted; linking to or deriving from it is not, and keeping those separate is a condition of the work rather than a nicety. Note in the commit which document a structure came from.
The client also refuses to over-read its own result, which is the part that took design rather than typing:
a VTL1 failure is reported as a negative about VTL1 only when the VTL0 control succeeded;
a double failure is reported as being about privilege or plumbing, and the two are never merged into one finding;
a VTL1 "success" that returns the VTL0 value is flagged as suspect rather than counted.
Getting it loaded, and three build traps
bcdedit /set testsigning on was refused: "The value is protected by Secure Boot policy and cannot be modified or deleted." The debugger host is itself a Hyper-V guest, so Secure Boot came off from its parent, with the VM powered down (Set-VMFirmware -EnableSecureBoot Off), not from inside. The driver is then built and test-signed with a certificate trusted in LocalMachine\Root and TrustedPublisher.
CLandLINKare reserved MSVC environment variables. Setting them to tool paths makescl.exetreat its own binary as a source file.Kernel sources still need the UCRT include directory, or
ntdef.hfails onctype.h.Taking the address of a member of a
#pragma pack(1)struct is genuinely unsafe, not merely warned about. Gather results into locals and assign afterwards.
What it costs, and what it cannot reach
A debuggable Secure Kernel is a weakened VBS boundary by construction. That is the point of the exercise and it is also its price: test-signing on, Secure Boot off, and HVCI off when a test-signed driver has to load. This configuration is for a disposable lab guest, not for a machine holding anything of value, and verifying that VBS still reports as running afterwards confirms VTL1 is present to debug — it does not mean the guest's protections are intact in the sense a user would assume.
What makes those concessions defensible here is the blast radius, which is a separate question from whether any given change helps. The debugger host is itself a Hyper-V guest, and its parent is hardened independently with kCET enabled. So the cost is one rebuildable VM sitting behind a hardened boundary rather than the lab's trust anchor.
And there is one thing this technique structurally cannot do. It crosses a partition boundary, so it reaches a guest's Secure Kernel and never the host's. Debugging the physical host's SK this way is not a harder version of the same thing; it needs an independent machine.