The hypervisor grants the registers and refuses the memory
26 September 2026 · Windows internals · Secure Kernel from the root, part 4 of 5
Reading a VBS guest's Secure Kernel from the root partition: HvCallGetVpRegisters hands over VTL1's CR3, HvCallReadGpa refuses the page it points at, and a direct mapping reads it anyway.
The question is narrow and falsifiable: can a root-partition component read a guest's Secure Kernel state well enough to drive a debugger? Not "is there a way in principle" — a specific mechanism, with a control, and a stop condition written before the work so that a sunk cost does not get to decide.
The answer is yes, and it takes two mechanisms with different permission models rather than one. securekernel.exe was located in a VBS guest's VTL1 address space from the root partition and identified against the image on disk on all 18 section names, its timestamp and its SizeOfImage. The route that got there is not the route the design expected, and the difference is the finding.
The shape of the result in one line. The hypervisor grants a parent partition a child's VTL1 registers through a documented hypercall, and refuses that child's VTL1 memory through the only guest-physical read that interface offers — while the root's own direct mapping of the guest's memory is subject to neither refusal. It guards one door and hands over the key to the building through another.
The lab and the driver this rests on are part 3. The gate discipline is worth one sentence: each step below had a pass condition, a named control and a stop condition written down in advance, and a gate that passes without its control having passed is withdrawn rather than caveated.
Desk work first: what the specification permits
The pivotal call is documented as parent-callable. HvCallGetVpRegisters, call code 0x0050, states under Restrictions:
The caller must either be the parent of the partition specified by PartitionId,
or the partition specified must be "self" and the partition must have the
AccessVpRegisters privilege.
Two things follow. A parent may read a child's VP registers at all, which is the premise the whole route rests on. And the AccessVpRegisters privilege is attached to the self case — being the parent is itself the authorization in that text, so the privilege is not obviously the gating factor for our caller.
It is also VTL-parameterised, and the register we want is VTL-private. The input carries TargetVtl at offset 12; HV_INPUT_VTL is TargetVtl : 4 with UseTargetVtl : 1, documented as allowing a target virtual trust level for operations that cross VTL boundaries. And the VSM documentation lists CR3 among the private registers each VTL maintains — so a VTL1 CR3 is a genuinely distinct value rather than the VTL0 one under another name.
The prohibition people cite here does not, by its terms, cover a parent. It reads "Software running at a lower VTL cannot access the higher VTL's private virtual processor's register state" — a rule about software within a partition. A parent partition is not a VTL of its child, and the hypercall's own restriction text contemplates exactly that caller.
What reading the specification did not establish. No text says a parent may name a child's VTL1 specifically. What was found is the absence of a prohibition, which is not permission. A documented interface is evidence of a code path, not of what a given build permits at runtime — the same distinction that made an earlier draft of part 2 claim DbgEng self-registers.
One design consequence, which is the useful part of reading the spec. HvCallTranslateVirtualAddress (0x0052) takes a partition id and a VP index and an opaque flags field, but its page documents no Restrictions section at all and does not expand those flags — so parent-calling and VTL selection are not established for it to the standard the register read reached. Budget the swizzled page-table walk as the expected cost, not as the fallback. That raises the estimate early instead of discovering it late.
The registers: granted, and the control is what makes it readable
Both guests were enumerated as children of the root and probed identically. The VBS-off twin is the control from part 3.
child partition 0x2 |
child partition 0x3 |
|
|---|---|---|
VTL0 CR3 |
SUCCESS 0x00007D5000 |
SUCCESS 0x0001A75000 |
VTL1 CR3 |
0x0015 (refused) |
SUCCESS 0x0001201000 |
HvRegisterVsmVpStatus |
EnabledVtlSet=0x0001 |
EnabledVtlSet=0x0003 |
HvRegisterVsmPartitionStatus |
EnabledVtlSet=0x0001, MaximumVtl=0 |
EnabledVtlSet=0x0003, MaximumVtl=1 |
The control discriminated exactly as designed, which is what makes the positive readable. On partition 0x2 the VTL0 read succeeded while VTL1 was refused — so the refusal is a statement about VTL1 and not about privilege or plumbing, which is the distinction the VTL0 control exists to draw. If both had failed, the finding would have been about the rig and would have said nothing about VTL1.
On 0x3 the VTL1 read returned a CR3 distinct from that partition's VTL0 CR3, so it is not the VTL0 value under another name.
And the interpretation does not depend on decoding the refusal. 0x0015 is not enumerated on the published HV_STATUS page, so it is left unnamed here rather than guessed at — it does not need naming, because the two VSM status registers say independently that partition 0x2 has only VTL0 enabled and a maximum VTL of 0. There is no VTL1 there to read. Those registers were gathered as corroboration for a measurement that looked self-explanatory, and they ended up carrying the reading.
Identification is by VSM state, not by partition number. Partition
0x3is the VBS guest because itsEnabledVtlSetis0x0003, not because 3 sorts after 2. The ids are the hypervisor's and carry no ordering guarantee worth relying on.
The memory: refused, with HV_STATUS = SUCCESS
A parent may read a VBS-enabled child's VTL0 memory freely. The pages VTL1 protects come back from HvCallReadGpa (0x0053) as HvAccessGpaReadIntercept with actively written zeros.
| Read | HV_STATUS |
AccessResult |
Data |
|---|---|---|---|
partition 0x3 VTL0 CR3 page |
SUCCESS |
0 Success |
real contents |
partition 0x3 VTL1 CR3 page |
SUCCESS |
2 ReadIntercept |
zeros, actively written |
partition 0x1 (the root itself) |
ACCESS_DENIED |
— | — |
HV_STATUS is SUCCESS on the refused read. A consumer that checks only the status renders zeros for exactly the memory it exists to inspect, with no error anywhere. That is the single most important operational fact in this post, and it is a property of the interface rather than of one probe.
The effect tracks VBS, measured against the control partition at page granularity over the same fixed grid, so the columns are comparable by construction:
| Partition | Pages scanned | ReadIntercept |
Contiguous runs |
|---|---|---|---|
0x2 — VBS off (control) |
32768 | 0 | 0 |
0x3 — VBS on |
32768 | 4608 (18 MiB) | 7 |
A sparser 4 GiB sweep agrees and extends it: 0 of 1984 mapped samples in the control against 11 in the VBS guest, with two further protected regions above the 128 MiB the dense scan covered -- so 18 MiB is a floor, not the footprint. Both partitions returned identical unmapped counts (63/63), which is the check that the two memory maps are comparable and the split is not an artefact of different layouts.
The shape carries more evidence than the rate. Every run is 2 MiB-aligned and a whole multiple of 2 MiB (512 and 1536 pages). That is large-page-granular protection. Scattered single pages would have indicated device overlays instead, which is the reading the control was there to exclude.
And the call cannot be talked round. HV_INPUT_READ_GPA is PartitionId, VpIndex, ByteCount, BaseGpa, ControlFlags — and ControlFlags is a cache type plus reserved bits. There is no parameter with which to request VTL1, so the read is performed as a VTL0-class access and VTL1-protected pages intercept it. This is not a permissions setting to be found.
One hard throughput fact for anything built on this call: ByteCount is capped at 16 — 17 and above return INVALID_PARAMETER. A 4 KiB page therefore costs 256 hypercalls. Remember that number; it is also a sampling fact, and that is the mistake further down.
A near-miss worth stating plainly. A secondary source proposing
0x0054as "the newer call code" was proposing the write. Firing it blind would have written into a child partition's physical memory. The hazard is structural rather than a one-off: this ABI places read/write pairs adjacently —0x0053/0x0054Read/WriteGpa,0x00CC/0x00CDQuery/ModifyVtlProtectionMaskRange. An off-by-one on a call code in this range mutates a partition where it meant to inspect one. No call code in this family gets fired without corroboration; the numbering used here is corroborated on this build at four independent call codes.
The narrowing: the refusal belongs to the call, not to the root
That result was first written up as the hypervisor withholds VTL1 memory from the parent. It is true only of the call it was measured with, and an independent oracle overturns the scope.
The oracle is independent in the one way that matters: its read routine takes the read route as a parameter, so the same library, the same partition and the same addresses can be read both ways — one path being winhvr.sys!WinHvReadGpa, i.e. hypercall 0x0053, exactly this route, and the other its own mapping through MmMapIoSpaceEx / MmGetPhysicalMemoryRanges / ZwMapViewOfSection. That makes it a within-instrument differential rather than a comparison of two tools that could differ for a hundred irrelevant reasons.
It validated itself against our own probe before being believed: on the hypercall route it returned byte-identical data at one VTL0 address, and "all zeros" on precisely the ranges the page scan found intercepted. Two independently built instruments agreeing on both a positive and a negative is what makes the next table evidence rather than an anecdote.
| GPA | Hypercall route | Direct mapping |
|---|---|---|
0x00C00000 |
all zeros | b03f641d02f8ffff... — read |
0x01201000 (the VTL1 CR3 page) |
all zeros | all zeros (see below) |
0x03E00000 |
all zeros | ff01000000010000... — read |
0x04800000 |
all zeros | 0000000000000060... — read |
| four control GPAs both routes can read | data | identical to the hypercall |
The controls are what license the reading: where both routes work they agree byte for byte, so the disagreement above is about the pages and not about the tools. b03f641d02f8ffff is 0xfffff8021d643fb0 -- a canonical kernel pointer, real content rather than noise.
So the hypervisor's refusal is a property of HvCallReadGpa rather than a property of the root's access to VTL1 memory. Which leaves one embarrassing row in that table: the page a debugger needs first.
The correction: a 16-byte window judging a 4096-byte page
HvCallReadGpa moves at most 16 bytes, so every probe in this investigation read bytes 0–15 of a 4096-byte page and judged the page on them. Secure Kernel maps nothing in the low canonical half, so its PML4's first entries are legitimately zero. Every "all zeros" above therefore means "the first 16 bytes are zero" and nothing more.
Read whole, GPA 0x1201000 is a page-table root, and it identifies itself structurally:
| Property | Value |
|---|---|
| self-map entry | index 388 = 0x8000000001201063 → PFN 0x1201, its own |
| present entries | 26 |
| non-zero bytes in the page | 123 of 4096 |
| first present entry's byte offset | 0x850 — past every probe's 16-byte window |
A self-referencing entry is decisive: it is the signature of an x64 paging root, it survives relocation, and it needs no address known in advance. So the VTL1 CR3 that HvCallGetVpRegisters returned is correct, current, and points where it says — the register result needed no qualification after all.
Two neighbouring claims fall with it. "Two of five withheld ranges unreadable even directly" was an artefact of sampling each run's first address only. Measured page by page, the run holding the CR3 is 424 of 512 pages readable by the direct route, and a page inside it holds PTEs with consecutive PFNs (0xd47, 0xd48, 0xd49, 0xd4a, flags 0x121 = Present | Accessed | Global). The direct route is reading Secure Kernel's page tables.
| Withheld run | Pages | Readable directly | First 16 bytes zero |
|---|---|---|---|
0x0C00000 |
512 | 455 | 57 |
0x1200000 (holds the CR3) |
512 | 424 | 88 |
0x3600000 |
512 | 13 | 499 |
0x3A00000 |
512 | 445 | 67 |
0x3E00000 |
1536 | 496 | 1040 |
0x4800000 |
512 | 396 | 116 |
0x4E00000 |
512 | 403 | 109 |
Measured again after an unrelated host reset: the same seven runs at the same addresses, the same 4608 intercepted pages, the control partition still at 0, and the VTL1 CR3 still 0x1201000. That makes these usable as landmarks rather than as one boot's accident.
Walking it, and finding the image
The route end to end is two primitives with different permission models, joined at a physical address:
HvCallGetVpRegisterswithTargetVtl = 1→ the guest's VTL1CR3. Documented, parent-callable, granted.That GPA holds Secure Kernel's PML4 — read whole, not 16 bytes at a time.
A four-level walk over SK's page tables, read by a non-hypercall memory route, since
HvCallReadGparefuses these pages.Scan the walked leaves for a PE header; identify what you find against the on-disk image.
The walk cost 167 page reads — 11 PDPTs, 24 PDs, 130 PTs — yielding 11,326 leaf pages in 9,201 contiguous VA runs. Finding the image cost more than the walk, because scanning leaves for MZ is one read per page. Six PE images turned up.
| In the guest's VTL1 space | C:\Windows\System32\securekernel.exe |
|
|---|---|---|
| sections | 18 | 18 |
| timestamp | 0x94DED27F |
0x94DED27F |
SizeOfImage |
0x175000 |
0x175000 |
| section names | (all 18, below) | identical |
.text KVASCODE TRNS PAGELK fothk ZEROPAGE CACHEALI .rdata .data
.pdata TABLERO ALMOSTRO MIRRDATA nlsdata FUNCTBL CFGRO .rsrc .reloc
Found at VA 0xFFFFF80220D89000, backed by GPA 0x00CD0000 — inside one of the seven runs the hypercall refuses. The names carry the identification on their own: KVASCODE, TRNS, ALMOSTRO, MIRRDATA, CFGRO and FUNCTBL are Secure Kernel's, and a coincidental match on all eighteen plus timestamp plus image size is not a reading anyone has to argue about.
The identification is independent of the mechanism that produced it — the on-disk image was written by neither the hypercall nor the driver — which is the control this required before any of it was believed. One expected mismatch, worth knowing before it wastes a day: a live image's import table is populated and will not match the file, so a comparison demanding whole-image equality fails for the wrong reason.
The strong form: the debugger data block and the module list
Both of the strong-form criteria are met, and each was found by a route that does not depend on the other. Offsets into the image are given beside the VAs, because the VA depends on the load base and the offset does not — the offsets are the coordinates to carry forward.
| What | VA | Image offset |
|---|---|---|
securekernel.exe base |
0xFFFFF80220D89000 |
— |
KdDebuggerDataBlock |
0xFFFFF80220EBC5E0 |
+0x1335E0 |
SkLoadedModuleList |
0xFFFFF80220EB0770 |
+0x127770 |
The two findings confirm each other. The list head was found structurally — searching SK's address space for a KLDR_DATA_TABLE_ENTRY whose DllBase is the SK base and whose SizeOfImage is 0x175000 sixteen bytes later, then following that entry's Blink — while the data block was found by its KDBG owner tag. The block's PsLoadedModuleList field equals the structurally-found head exactly, and its KernBase equals the base the PE walk had already established. Three independent agreements, none of them assumed.
| # | DllBase |
SizeOfImage |
Name |
|---|---|---|---|
| 1 | 0xFFFFF80220D89000 |
0x175000 |
securekernel.exe |
| 2 | 0xFFFFF80220F03000 |
0x54000 |
skci.dll |
| 3 | 0xFFFFF8022104A000 |
0xD000 |
symcryptk.dll |
| 4 | 0xFFFFF80220F5C000 |
0xE9000 |
cng.sys |
| 5 | 0xFFFFF8022105C000 |
0x15000 |
vmsvc.dll |
| 6 | 0xFFFFF8021C5B1000 |
0xA000 |
vmsvcext.sys |
That table is itself a cross-check. The PE-header scan found six images and could name only one; the module list names all six, and the two agree base for base and size for size. Neither method was told about the other's results.
And a correction to the number part 1 carried: the block's Size is 0x3A0 live, not the 0x3A8 read from the image. The offset is not in doubt — OwnerTag at +0x10 and Size at +0x14 are fixed by DBGKD_DEBUG_DATA_HEADER64, and KernBase at +0x18 lands exactly on the SK base found independently, which pins the alignment. The discrepancy cost a run: a first scan searching for the eight bytes KDBG + 0x3A8 reported zero occurrences, and the tag was three pages away the whole time. An exact-match needle built from a remembered constant fails silently; searching for the tag alone and reporting the size found is what recovered it.
The block is populated selectively, which is correct rather than partial: 26 of its 116 qwords are non-zero. KernBase, PsLoadedModuleList and BreakpointWithStatus are filled; PsActiveProcessHead, PspCidTable, KiCallUserMode, KeUserCallbackDispatcher and MmLoadedUserImageList are all zero. Those are NT concepts Secure Kernel has no equivalent for, so their absence is the expected shape of an SK debugger block. It also retires the hypothesis raised when the first scan failed — that the routine filling it might only run when secure debugging is enabled. It has run: the fields that mean anything for SK carry live pointers.
Cost: 10 page reads, against 13,769 for the structural search that preceded it. Once the coordinates are known this is cheap enough to do on every attach.
The control that decides whether any of it counts
This host runs VBS itself, so the root partition has its own Secure Kernel resident in memory. A read path that was landing in host memory — or in any partition other than the one named — would find securekernel.exe regardless, and the identification against the on-disk image would confirm it just as happily. Nothing above distinguishes those two worlds. One procedure, two partitions, the same fixed grid, in one process:
partition 0x3 — VBS on |
partition 0x2 — VBS off |
|
|---|---|---|
| pages scanned | 32768 | 32768 |
| pages with content | 32768 | 32768 |
| PE image headers | 112 | 152 |
matching securekernel.exe |
1, at GPA 0x00CD0000 |
0 |
KDBG tags |
4 | 0 |
KDBG with Size 0x3A0 |
1 | 0 |
The positive arm is not decoration. A scan that finds nothing anywhere is indistinguishable from a broken scan, so the negative is worth nothing on its own. The control guest yields more PE headers than the VBS guest — 152 against 112 — so the procedure demonstrably works there. It simply finds no Secure Kernel, and not one KDBG tag of any size.
A second result falls out of it. That scan reached securekernel.exe by walking raw physical pages and testing PE headers — touching no page table and using none of the VTL1 CR3 — and landed on GPA 0x00CD0000, the same GPA the four-level walk reported from an entirely different direction. Two methods that share only the partition handle agreeing on one address is a stronger statement than either made alone.
What the control does not establish: it says nothing about which mechanism reads VTL1 pages, and the two guests differ in more than VBS alone, so it is a control against one specific confusion rather than a general one.
Writes, because a breakpoint is a memory patch
The hypercall half is exactly symmetric with the read, and it was predicted from the ABI before it was run: HV_ACCESS_GPA_RESULT_CODE defines a write-intercept code, so symmetric refusal was the thing to expect.
The write input layout came from winhvr.sys!WinHvWriteGpa's disassembly on this build rather than from the header that agrees with it, because a wrong layout here writes the right bytes to the wrong address. The method was a round trip that changes nothing: read 16 bytes, write the identical bytes back, read again.
| Probe | Result |
|---|---|
ByteCount = 0 |
INVALID_PARAMETER — nothing written whatever the layout |
| unmapped GPA, hypercall write | SUCCESS / AccessResult = 1 Unmapped — the address field is honoured, nothing written |
| VTL0 page, hypercall write | SUCCESS / AccessResult = 0 Success — page unchanged |
| VTL1 page, hypercall read | SUCCESS / AccessResult = 2 ReadIntercept |
| VTL1 page, hypercall write | SUCCESS / AccessResult = 3 WriteIntercept — page unchanged |
HV_STATUS is SUCCESS on the refused write too. So the trap that makes a read look like zeros makes a write look like it landed.
Then the honest gap. Writing identical bytes makes "unchanged afterwards" the expected outcome of both a real write and a silent no-op — the safe round trip is also the one that cannot tell them apart. So the direct route's cheerful True got its own control before it was believed: it returns False on unmapped memory and True on a VTL1 page, so the value discriminates rather than rubber-stamping.
And then differing bytes, which is the only thing that actually shows a write:
| Test | Route | Result |
|---|---|---|
| ordinary scratch page, differing bytes | hypercall | lands — pattern read back, and read back identically through the direct route |
| ordinary scratch page, differing bytes | direct | lands — pattern read back, and read back identically through the hypercall |
VTL1 (securekernel.exe .text padding) |
hypercall | refused, AccessResult = 3, page unchanged |
| VTL1 (same address) | direct | LANDS — deadbeef... written and read back, then restored |
The mechanism check came first and it mattered. Every VTL1 write before this one wrote bytes that were already there, so neither route had been shown to write anything — the hypercall's Success on VTL0 was as unproven as the direct route's True. A scratch page settled both, and incidentally showed the two routes addressing the same memory, since each read back the other's pattern.
The VTL1 target was alignment padding inside Secure Kernel's own code — chosen as the lowest-risk VTL1 memory rather than as inert memory, which is a distinction worth keeping. The cave was located in the on-disk image first, a run of at least 96 0xCC bytes in .text, written at its centre, so what belonged there was known independently, the in-memory pre-state could be checked against it before writing, and the restore was exact rather than remembered.
Two things this deliberately does not claim. The scratch page was not reserved: two all-zero reads and exclusion from every known image are evidence that a page looks unused, not that the guest does not own it, and the guest can allocate it between the check and the write. Repeat this against a paused guest, an explicitly reserved page, or a snapshot that is discarded afterwards. And the guest surviving is not evidence that SKPG or HyperGuard does not checksum SK's code — the modification existed for milliseconds between two reads, and a periodic integrity check has no particular reason to fall inside that window. A persistent modification is an entirely different experiment.
So the asymmetry is complete, and symmetrical in an unexpected way:
| Hypercall | Direct mapping | |
|---|---|---|
| VTL0 read | yes | yes |
| VTL0 write | yes | yes |
| VTL1 read | ReadIntercept | yes |
| VTL1 write | WriteIntercept | yes |
For a debugger that means the patch half of a software breakpoint is solved — an int 3 can be planted in Secure Kernel — and the catch half is not. Planting one before the catch half is answered bugchecks the guest. Execution control was explicitly out of scope for this question, and it stays out: pausing a whole VM is not VTL1 single-stepping, and a plan that quietly grows execution control does not finish.
What this is worth, and what it is not
It is read/write access to a guest's VTL1 from the root partition, with the coordinates a debugger would start from: the SK base, its debugger data block, its loaded-module list, and image-relative offsets for the last two that survive a reboot. It needs a hypervisor root partition, administrative access, a test-signed driver and a host configured to load one. It reaches a guest's Secure Kernel and never the host's.
With that in hand, the last question was whether to feed it into DbgEng through an EXDI stub or expose the reads directly. Part 2 answers it: the engine's Secure Kernel record is not reachably wired, so DbgEng would contribute only memory plumbing — which is the part that now exists — at the price of an activation stall that has already cost a host reset. Direct it is.
Five of the measurements above were wrong before they were right, and the ways they were wrong turned out to be more portable than the result: how to measure nothing.