How to measure nothing
26 September 2026 · Windows internals · Secure Kernel from the root, part 5 of 5
A zeroed buffer, a sixteen-byte window, an undecoded result code — and five other ways this investigation produced a plausible answer before it produced a true one.
The four posts before this one are a result. This one is the part that transfers: every heading below is a way the instrument told me what I already believed, each cost a run, and none of them is specific to Secure Kernel.
They share one shape. In every case the instrument, not the target, supplied the expectation — and in every case what caught it was the same move: read something whose shape is known independently.
1. A zeroed output buffer cannot tell data from silence
The probe zeroed the hypercall output page before each call. So "the protected page reads as all zeros" and "the hypervisor wrote nothing" were the same observation, and the instrument manufactured the very ambiguity that got written up as a finding.
Fill it with 0xAA instead and every byte answers for itself:
| Probe | Written span | Reading |
|---|---|---|
ReadGpa, ByteCount=16 |
+0 .. +23 |
AccessResult at +0, Data[16] at +8 |
ByteCount = 1 / 2 / 4 / 8 |
+0 .. +23 |
that many real bytes at +8, remainder zero-filled |
bogus call code 0x0FFF |
none — page still pure poison | the control: a rejected call writes nothing |
The layout the driver already used was correct all along. The bogus-code row is what makes every other row mean something: it demonstrates that "written" is a real observation and not an artefact of the buffer.
The same defect wears another hat on the write side. Every early write test wrote the bytes that were already there — a round trip deliberately chosen to be safe — which makes "unchanged afterwards" the expected outcome of both a real write and a silent no-op. A write API returning True is not a write. Give the return value its own control (does it refuse unmapped memory?), then write differing bytes somewhere you can restore exactly.
2. A sixteen-byte window judging a four-kilobyte page
HvCallReadGpa moves at most 16 bytes per call. That was recorded early as a throughput fact — a 4 KiB page costs 256 hypercalls — and it is also a sampling fact, which nobody wrote down.
So every probe read bytes 0–15 of each page and judged the page on them. Secure Kernel maps nothing in the low canonical half, so its top-level page table's first entries are legitimately zero: the page whose contents mattered most in the whole investigation was recorded as empty, and its first present entry sat at byte offset 0x850. Read whole, it identifies itself by its own self-map entry.
Why the poison discipline from (1) did not catch this one, which is the interesting part. Poisoning separates written from unwritten, and every one of these reads genuinely was written — with zeros, which were the true contents of the bytes requested. The defect is one level up: the window was too small to be representative, and no control established that it was.
A correction rarely costs just one claim. When this one landed it took two neighbouring conclusions with it, one of which had already been written as a heading — a range described as unreadable turned out to be 424 of 512 pages readable, because each run had been sampled at its first address only.
3. A named result code, on screen, undecoded
Every run printed AccessResult=0x2 and no table existed to read it with, so the finding was recorded as "all zeros". 2 is HvAccessGpaReadIntercept: the hypervisor was not failing to answer, it was refusing, and saying so in the field provided for saying so.
Worse, the refusal carries HV_STATUS = SUCCESS. A consumer checking only the top-level status renders zeros for exactly the memory it exists to inspect, with no error anywhere — and the write path does the same thing, so a refused write looks like it landed. Check the per-access result, not the status, and decode every field the ABI defines before writing down what a run means.
4. A needle built from a remembered constant fails silently
Static analysis of the image said the debugger data block's Size field is 0x3A8. A live scan searching for the eight bytes KDBG + 0x3A8 reported zero occurrences. The tag was three pages away the whole time; live, the field holds 0x3A0.
An exact match on a compound needle is a conjunction, and a conjunction fails without telling you which clause failed. Search for the part you are sure of — the tag — and report the rest of what you find. The same run then pins its own alignment: the owner tag and size sit at fixed offsets in a documented header, and the base pointer three fields later landed exactly on a base established independently.
5. Page tables are cyclic, and the walk ate the machine
Windows paging roots self-map, so a four-level descent re-enters the table it came from — 512 times per level. The first attempt at walking Secure Kernel's page tables grew its leaf list until Python exhausted the machine's memory, which starved every other process on the host: the browser died with an allocation failure, and the bench needed a reboot. Twice.
It was not a kernel fault, a driver leak or pool exhaustion — the pool and PTE counters were clean throughout. It was an unguarded graph walk in a script. Three guards fix it and all three are load-bearing:
skip any entry whose target PFN is the table it came from;
keep a visited set per level;
put hard budgets on both reads and collected leaves, so that exceeding one is reported rather than absorbed.
With them, the same walk costs 167 page reads. The third guard is the one people leave out, and it is the one that turns "my walk is wrong" into a line of output instead of a reboot.
6. Strings are evidence of a code path, not of when it executes
Twice, in two different components, a string got read as a behaviour.
The registration strings inside dbgeng.dll were read as proof that the engine registers its EXDI COM server on demand. It has that code; on the attach path it does not run it, and the attach fails with Class not registered having registered nothing.
And a 40-byte record in the engine's .data mapping a memory space to sk!KdVersionBlock was read as a built-in Secure Kernel bootstrap. Mapping .pdata and classifying every function that touches that table says otherwise: only functions that also reference EXDI strings reach its readers, 0 of 91 KD-transport strings appear in any of them, and the one function that distinguishes the sk record from the hv record has no direct callers and never has its address taken.
The fix is not scepticism, it is a different measurement: reachability, not presence. And then state the limits of that too — absence of a direct caller is not proof of dead code, since a computed jump table would not show in such a scan, and it was one build.
7. The error already told you which gate it was
A driver would not load and the CodeIntegrity event said so. The diagnosis — "CodeIntegrity refused it" — became "HVCI refused it", and HVCI was disabled on the host and the host rebooted to unblock it.
The probe then failed identically, citing the same policy id it had cited all along, which a companion event names as a cross-certificate exceptions policy that is activated at boot independently of HVCI. Meanwhile the vulnerable-driver blocklist — the obvious culprit, the one everybody guesses — was logging in audit mode and blocking nothing.
So a protection was turned off for no gain, on a misreading of a message that named the gate. Two questions, not one: can we afford to weaken this host, and does weakening it achieve the thing we are weakening it for. Only the first was asked.
8. A control needs a positive arm
The whole VTL1 result rests on a second guest identical but for virtualization-based security, because a read path landing in the wrong partition would have found a Secure Kernel anyway — the host runs VBS too — and matched it against the on-disk image just as happily.
But "the control found nothing" is worth nothing by itself, because a scan that finds nothing anywhere is indistinguishable from a broken scan. What makes the negative readable is that the control guest yields more PE headers than the VBS guest — 152 against 112 — over the same grid. The procedure demonstrably works there. It simply finds no Secure Kernel, and not one KDBG tag of any size.
The same principle sits one layer down in the register probe: asking for VTL0's CR3 alongside VTL1's is what separates "VTL1 is refused" from "my driver is broken". If both fail, the finding is about privilege or plumbing and must not be reported as a finding about VTL1.
The one rule underneath all eight
Every defect above was caught the same way: by reading something whose shape was known independently of the mechanism under test — a whole page instead of a prefix, a poison pattern instead of zeros, an on-disk image written by neither the hypercall nor the driver, a second partition, a bogus call code, a reachability scan instead of a string.
And one meta-rule, which is why the posts in this series read the way they do: keep the wrong version. The record of how a negative was overturned is as much a result as the pass is, and a corrected sentence left in place with its correction beside it is the only thing that stops the same wrong step being taken again by someone who never saw the first attempt — including me, three days later.
Back to the result: the hypervisor grants the registers and refuses the memory.