← writing

A receiver with no sender

4 October 2026 · Windows internals · Secure Kernel from the root, part 6 of 10

The hypervisor's VTL1 debug port is up and nothing has ever connected to it. Ten securekernel.exe builds say why — and then four weeks of trying to stop VTL1 from outside instead, which cost two guests and ended holding a breakpoint inside a trustlet.

Everything in parts 1 through 5 reads Secure Kernel. Nothing in them stops it. That gap has a precise shape, and part 4 named it: a software breakpoint is a memory patch plus a trap that arrives somewhere, the patch half works — int 3 bytes land in VTL1 and read back — and the catch half has nowhere to arrive.

Two facts had been sitting in this record for weeks without being joined.

They sit uncomfortably beside each other, and the first thing this part asks is whether they are the same wall.

The shape of the result in one line. They are the same wall, for the route a scan can cover: Secure Kernel has no hypercall or MSR path to that port, so the port is a receiver with no sender. What is left is a stop driven entirely from outside the guest — which turns out to be possible, to require owning the partition from creation, and to be two very different things depending on whether you want VTL1 kernel mode or a trustlet.

The scan, and the control that makes a zero mean something

A VTL1 debuggee cannot own the NIC — the hypervisor does, which is the whole point of hypervisordebug multiplexing three ports on one wire — so a debuggee must hand KD packets to the hypervisor. That means hypercalls, and hypercalls are visible in an image.

kdhvcom.dll is Windows' own implementation of exactly that, and it is the positive control. Without it, a negative on Secure Kernel would be indistinguishable from a broken scanner.

reading C:\Windows\System32\kdhvcom.dll
exports KdInitialize, KdPower, KdReceivePacket, KdSendPacket, KdSetHiberRange — the five-function KD transport contract, the same five kdnet.dll exports
imports six ntoskrnl.exe symbols, none hypercall-related
hypercall instructions 2 — vmcall at +001FE0, vmmcall at +001FF0 (Intel and AMD)
control codes written 3, and only 3: 0x69 at +001A6F, 0x6B at +001B74, 0x6A at +001C7F, each mov dword ptr [rsp+X], imm

The code-to-name mapping is pinned by this binary's own structure rather than by a cited table. By call-graph reachability from each export: KdInitialize reaches 0x6B only; KdSendPacket and KdReceivePacket reach all three; KdPower and KdSetHiberRange reach none. An initialize that reaches one code, and a send/receive pair sharing it plus two others, is HvResetDebugSession with HvPostDebugData and HvRetrieveDebugData beside it. So the control is a complete, self-describing sender in 8 KB.

Then the subject: ten securekernel.exe builds, 19041.207 through 29667.1000. Entirely offline — a PE parse plus capstone, no VM queried, attached, reconfigured or rebooted.

build executable bytes 0069/6A/6B written vmcall SynDbg MSR wrapper call sites codes resolved
19041.207 653,715 0 0 0 30 21
19041.7725 † 660,803 0 0 0 30 21
22621.317 † 752,348 0 0 0 29 19
22621.7582 † 780,892 0 0 0 30 20
28000.2952 1,065,757 0 0 0 39 25
29617.1000 1,083,601 0 0 0 39 25
29639.1000 1,103,817 0 0 0 39 25
29648.1000 1,017,801 0 0 0 32 25
29667.1000 † 1,036,777 0 0 0 32 25
26100.9457 1,012,571 0 0 0 63 38

† identity unverified — four downloads whose bytes did not match the requested index record. They agree with the six verified rows and are listed separately rather than counted with them.

No sample contains a vmcall or vmmcall instruction at all, which is a structural gift: every hypercall Secure Kernel makes goes through the hypercall code page, which lives in two globals, so the set of functions that can reach the hypervisor is derivable rather than a list of names to maintain.

Which matters, because the name moved. The generic invoker is HvcallpInitiateHypercall before 26100 and HvcallInitiateHypercall after. A first pass asked for the later spelling, resolved nothing on nine of ten samples, and reported "0 hypercall sites" for every one of them — the conclusion's exact shape, arrived at by a broken instrument. One of several near-misses on this gate; a sample whose filename silently defeated cdb symbol resolution produced the same zero a different way.

Dismissing a candidate is harder than finding one

The scan takes instruction boundaries from four unioned seeds: the functions the exception directory declares, a linear sweep of each executable section, a recursive descent from every direct call and jump target iterated to a fixpoint, and the export table. The last two are what reach leaf functions, which need no unwind data and are therefore absent from .pdata entirely. A leaf is reached by being called, so its entry is a call target; one reachable only through an export is not, which is why the export table is seeded too — measured as a no-op on these images, delta zero, so it closes a hole in the reasoning rather than in the result.

Beside the decoders sit two passes that cannot desynchronise because they do not decode: a raw opcode byte search, and a byte-anchored search for the constants. An immediate is encoded little-endian, so the low bytes appear verbatim whatever the operand width; finding those bytes and decoding from all sixteen starts that could cover them locates any instruction carrying the value without knowing where instructions begin. For the SynDbg MSR numbers that is both sound and precise, since 0x400000Fx cannot be encoded as imm8 or imm16. For the one-byte debug codes it is sound but noisy, so it is reported as a superset.

And the superset is decided rather than merely reported, which was this gate's own first mistake. The byte-anchored pass finds 40 to 96 decodings per build at offsets no pass recognises as a boundary — forms like in al, 0x6b and enter -0x6e18, 0x6b. Listing them and acting on none left a real gap, because a candidate sitting in bytes no instruction covers is not a misreading of anything: it is unexamined code. So each is tested against a bitmap of bytes lying inside some instruction a decoder placed at a boundary. Every one, on all ten builds, is a shadow of a covering instruction. A candidate that was not would make the scan inconclusive.

Asymmetry worth stating, because it decides which seeds may be trusted. Finding an instruction is safe from any pass — a spurious one only gives the scan another place to look. Dismissing a candidate as a shadow is not, because a shadow cast by a misaligned decode is worthless. And the linear sweep does desynchronise on these images: measured against .pdata as 2,617 to 3,221 independent checkpoints per build, it decodes straight through 2 or 3 of them, always in one small region. So dismissal uses only seeds that start at known function entries. Measured before the change: no candidate on any build relied on the sweep for its dismissal, so this costs nothing and stops the question arising.

One ABI detail that a naive test would have missed: a control code does not have to be bare. It sits in bits 0–15 of a hypercall input value with the fast flag at bit 16, so 0x00010069 is HvPostDebugData and an exact-equality test misses it. This scan was doing that while its repertoire reading masked with & 0xFFFF.

The verdict, and its limits. Every occurrence of 0x0069, 0x006A or 0x006B at a recognised boundary, in any of the ten images — 5 to 10 per build — is a cmp, in code unrelated to debugging. Against it, the control writes all three distinct values and compares none. No build materialises a synthetic-debugger MSR number at all. The enumerated hypercall repertoire (19–38 codes per build) holds none of the three either, and that reading comes from an abstract evaluator walking in address order without following control flow — a heuristic lower bound whose job is to be the negative control and which is never allowed to produce the verdict.

So: a statement about those readings over those ten images, not a proof that no mechanism of any kind exists. But the port has no sender in Secure Kernel, and the two walls are one wall.

So drive the stop from outside

What remains is the one route needing no guest-side code at all. If Secure Kernel will not send a packet, the hypervisor and the root partition must do the stopping themselves.

A parent may install an exception intercept on a child, and may not aim one at VTL1. That is the first boundary, and it is a feature of the interface rather than a refusal: the intercept has no VTL parameter to aim with.

The root can stop a child's VP, though — and the stop is not what we wanted. HvCallSetVpRegisters writing HvRegisterExplicitSuspend halts a running child's virtual processor from the root: no guest-side code, no exception, no intercept, no port. While it is halted, the child's VTL1 CR3 and VTL1 RIP read back as VTL1's own.

Measuring that it actually stopped took its own instrument, because a status is not a stop. HV_STATUS_SUCCESS says a register write was accepted. HvRegisterVpRuntime is what says a processor stopped — it counts executed time in 100 ns units and is read-only, so a working suspend shows as a counter that stops advancing.

arm VP runtime over 2000 ms
null model, no suspend (three samples) 84,075 / 53,293 / 60,149
VTL0 suspend on the VTL0-only child, before 45,434
while suspended 430
after release 43,907
VTL1 suspend on the VBS child, before 41,291
while suspended 1,032
after release 61,453
VTL0 suspend on the VBS child, while suspended 495

A first run used a 60 ms window, read 139 against a before of 105, and called it nothing — because an idle guest's VP and a suspended one are indistinguishable at that scale. The window went to 2000 ms and a null model went in beside it: three samples with no suspend at all, to show what idle looks like when nothing is being done to it.

Two corrections that stayed in the record. An earlier version of this claimed "two orders of magnitude in every suspended arm" — true of the control, not of the test. Against the lowest idle sample the suspended arms are 40× to 106× below it, and the figure that carries the claim is the narrowest one: 40×, not 100×. And an earlier guard looked for a catch-up burst on release and called a clean drop "weak" — an idle guest has no queued work to catch up on, so the burst was never the discriminator.

The stop is VP-wide. Naming VTL1 stops the whole processor, not VTL1's execution. That is an inspector's stop, not a debugger's.

Making VTL1 run, so there is something to catch

An inspector's stop is still useful if VTL1 is executing when you take it — and the first measurements said it essentially never is. The next question was therefore how to make VTL1 run on demand.

Three routes, and the useful part is which closed:

It goes to VID, and the message names the enclave's own instruction. That second half is what a register halt cannot give.

arm intercept markers raiser
VTL1, no intercept none finished, 2,000/2,000 handled, 7.63 µs each
VTL1, intercept standing both VPs, 0x80010003 / vector 3 / reason 2 did not finish in 40 s
VTL0, intercept standing both VPs, same markers did not finish
VTL1, no intercept (interleaved) none finished, 2,000/2,000 handled, 7.49 µs each

"Did not finish" is all that raiser column measures, and the record says so: a loop costing 15.3 ms unarmed did not complete inside a 40-second window armed. A freeze on the first raise produces that, and so does a large slowdown. The two numbers are not a clean ratio — the 15.3 ms is raiser loop time and the 40 s is wall clock on a PowerShell Direct call, most of which is the call. (A draft wrote the loop cost as "15 s", three orders out.)

The intercept message's Rip is 0x00000243071E500D, and the raiser reported its enclave routine at 0x00000243071E5130 in that same run — 0x123 bytes away, the int3 inside it. So the intercept is keyed to the VTL1 instruction rather than to anything that runs after it.

And it agrees with the earlier VP halts across instruments, gates and boots, which neither could give alone:

halted RIP, read directly intercept message Rip, a day later
VTL1 0x000001D1009E500D 0x00000243071E500D
VTL0 0x00007FF7D5AF748D 0x00007FF7600B748D

Different boots, different ASLR bases, identical low 16 bits in both arms — the same instruction in the same image, named by a VP halt and by an intercept message taken by different mechanisms.

So the trap is delivered, it is attributed correctly, and it arrives at Vid!VidInterceptPreprocess. All that remains is to be the thing that receives it.

The gate, and the one line of code it is

Receiving means registering as the handler for that vector. The route to it closes and reopens several times, and each step is a measurement:

So the refusal was read out of Vid.sys rather than guessed at, and it is a process-identity check: PsGetCurrentProcess() compared against [partition+0x3780]. Not a token, not a privilege, not a policy — are you the process that created this partition.

That is testable from kernel mode, because KeStackAttachProcess changes what PsGetCurrentProcess() returns. And before issuing anything, the probe resolves the duplicated handle to its file object, takes FsContext & ~3 as the partition, and reads the field back:

worker process [partition+0x3780] attached EPROCESS matched
Lab Guest Control 0xFFFFD5064DC21080 0xFFFFD5064DC21080 yes
Lab Guest Hyper-V 0xFFFFD5064DA9F080 0xFFFFD5064DA9F080 yes

Then the same call, from the two sides of the gate:

user mode kernel mode, attached
the partition handle 0xC0000022 ACCESS_DENIED 0x00000000 SUCCESS, handle returned
the other three handles 0xC0000002 NOT_IMPLEMENTED 0xC0000002 NOT_IMPLEMENTED
unregister not run 0x00000000 SUCCESS

As controlled as a pair gets: the same handle value, the same control code 0x221148, the same 16-byte input, the same 8-byte poisoned output. The only difference is which process PsGetCurrentProcess() returns, and that flips ACCESS_DENIED to SUCCESS.

And it reset both guests

Each arm's own health check passed — "responsive, procs=102", LsaIso alive — and then the guest it had touched went down.

guest delay between the arm and the reset
Lab Guest Control ~20–30 s
Lab Guest Hyper-V ~1–2 s

Kernel-Power 41 in each guest, no 1001 bug-check record, and "successfully booted an operating system" on the host afterwards. So the partition was hard-reset, not crashed from inside — the guest OS never got to write a bug check. The host was untouched.

Candidate mechanism, offered as that: the registration points a client entry at vector 3, and nothing in this arm ever mapped or drained a message slot for it. So the first #BP the guest raises after the arm has an intercept installed and no consumer. The variable delay is what fits that — ~1–2 s on one guest, ~20–30 s on the other, which is what waiting for the next #BP looks like.

The rule this changes: pairing an install with its removal is necessary and not sufficient, because the arming is what does the damage. Map and drain the slot first, or expect to lose the guest. And a post-arm liveness probe measures nothing on this path — both of them answered cheerfully seconds before the guest went.

Draining the slot, which is how we learned whose messages they are

So: map the slot, drain it, then arm. Two mechanical facts had to come first, and neither was reachable by reasoning about the wrapper.

0x221107 is the only METHOD_NEITHER code of the four in use. With METHOD_NEITHER the driver receives the raw pointer and probes it, and ProbeForRead refuses a kernel address — so the first attempt, passing a kernel stack buffer, returned STATUS_ACCESS_VIOLATION and consumed nothing. The input must live in the attached process's user space.

And Flags must be 4. Swept 0,1,2,3,4,7,0x10,0x100: every value but 4 returns STATUS_INVALID_PARAMETER. Bit 2 is required and its meaning is unread.

That sweep cost nothing, by construction, and this is the most reusable thing in the section. The completion call does not need the intercept armed — so the driver gained a SkipArm mode that maps and completes without ever registering, and the guest's uptime was unchanged across the whole sweep. Arming is what kills a guest. Separating the probe from the arming turned nine reboots into one free sweep.

Then the loop, armed and consuming:

map SUCCESS, slot VA 0x247A8741000
register SUCCESS
consumed 64 messages, 0 empty polls, deadline not reached
every completion SUCCESS
unregister SUCCESS
message type, first and last 0x01000010, payload 0x30

Every call succeeded. And 0x01000010 is exactly what was already sitting in that slot before anything was armed — the slot-map arm read that type minutes earlier with no intercept installed. It is not 0x80010003, the exception-intercept type the delivery arm established.

So those 64 messages were not our #BP intercepts. They were vmwp.exe's own stream, and we acknowledged 64 of them. The guest reset inside ten seconds.

That is contention measured rather than predicted, and it closes the chain at every link: the gate admits exactly one process per partition; on a Hyper-V VM that process is vmwp; satisfying the gate means being vmwp and sharing its slot; consuming from that slot takes the owner's traffic; and the guest does not survive it.

A usable receive loop therefore needs a partition with exactly one client — ours. Which is a justification by measurement for something that had previously only been costed.

Owning a partition from creation

Can VTL be retrofitted onto a partition that already exists? No, and the refusal is informative.

VidVsmEnableVpVtl sends IOCTL 0x221214, METHOD_BUFFERED, 0xE8 bytes — {ULONG VpIndex; UCHAR TargetVtl; pad; UCHAR Context[0xE0]}, and 224 bytes is the size shape of HV_INITIAL_VP_CONTEXT. Decompiled, Vid.sys's handler checks only the buffer length before handing the caller's context straight to WinHvEnableVpVtl. VID imposes nothing on that context.

So the probe called the wrapper directly with an all-zero context — chosen for discrimination, not safety, a distinction a later round had to force: on a partition whose ordering gate passes, the context may be used, so zero is a fault or a reset there rather than a refusal. It was harmless here only because the state gate refused first.

Both partitions identified themselves by their own VTL state: STATUS_HV_VTL_ALREADY_ENABLED for the VBS guest, STATUS_HV_INVALID_VTL_STATE for the one with MaximumVtl=0. Neither refusal names the context — no INVALID_PARAMETER, no ACCESS_DENIED, no measurement and no policy. The hypervisor checks VTL state and returns before examining the 224 bytes.

And the level above refuses invariantly: WinHvEnablePartitionVtl on the non-VBS guest returns STATUS_HV_INVALID_PARAMETER across flags of 0–7, 0x100, 0x101 and both VTL 1 and 2 — while the same call on the VBS guest's partition answers differently. That is a discriminating control: the parameters reach the hypervisor, and the refusal is about that partition.

One inference is flagged rather than promoted. "The VSM capability bit is fixed at creation" was an inference, and the candidate review named has since been read: VsmmVsmSetPartitionConfig writes [partition+0x30f0], [partition+0x30f4] and the per-VTL info, and does not touch [partition+0x10] — incidentally writing VID's enabled-VTL bookkeeping with no hypercall at all. But where that bit is written is still untraced, and a name-filtered scan finding nothing is the same weak instrument that earlier read as "no VID handle". So "VTL cannot be retrofitted" remains an inference.

What this prices rather than blocks: our own code at VTL1 kernel privilege needs a partition created VSM-capable, its partition VTL enabled, then the VP enabled with our context. That is a VID/VSM rig — and emphatically not "write a VMM". The 19-module, ~10 MB device-and-firmware cliff is the price of booting Windows in a partition; running our own code in VTL1 needs no guest OS, no firmware and no vTPM.

And the next obstacle after that one is removed, by a call that had been cited without being made. The costing above rested on a device-model activation probe recorded in its own notes as unrun — "registered rather than shown to activate. No call was made" — while a later evidence line read "the device classes activate out of process, which proves a registered class factory." That states a measurement nobody took; flattening a conditional into a result is the defect, and the conditional was right as written.

So the call was made. The registry census reproduces exactly — 24 CLSIDs backed by seven vm* DLLs, all ThreadingModel=Free — and CoCreateInstance(CLSCTX_INPROC_SERVER, IID_IUnknown) runs one child process per CLSID, so a faulting device model costs one result rather than 23: 20 S_OK, 4 CLASS_E_CLASSNOTAVAILABLE, no faults, with an msxml3 XMLHTTP activation as the positive control and an unregistered CLSID answering REGDB_E_CLASSNOTREG as the negative. The four refusals are a registration artefact rather than a policy: their backing DLL loads into the process before refusing, and their GUID bytes appear in none of the 45 vm*.dlls in System32.

Each object is live rather than merely constructed — refcounts AddRef→2, Release→1, Release→0, and answers E_NOINTERFACE with a nulled out-pointer for an IID nothing implements, the out-pointer having been poisoned with 0xAA first so that writing nothing cannot read as writing null. And it is not admin-gated: one of them activates under a restricted token and again at genuine medium integrity with no Administrators membership, so does process context decide it is answered no for construction. What that does not establish is the tier — these objects implement private interfaces whose IIDs this record has never read.

A controlled stop in VTL1 kernel mode

So: create the partition, own it, and hold the stop.

The probe creates only its own one-VP VID partition under a freshly generated bare GUID, starts the VP in VTL0, enables partition and VP VTL1, enters VTL1 through a fixed interrupt targeted at VTL1, and holds the VP when a selected VTL1 int3 raises #BP. It never enumerates, opens or attaches to an existing VM; its only handle comes from VidCreatePartition, and every run — normal or failed — deletes that partition. An unexpected mapped message is never completed; the partition is deleted instead.

VID is undocumented and build-specific, so the executable refuses to load unless the inbox files match the exact pair whose wrappers and handlers were decoded — vid.dll 10.0.26100.5074 at 263,552 bytes, Vid.sys 10.0.26100.9278 at 910,816 bytes — reading VS_FIXEDFILEINFO, because the displayed product version of vid.dll differs from its fixed file version. An offline self-test checks every private ABI size, the page tables, GDT, TSS, IDT, initial VP contexts, the selected int3 and the exact mapped-message filter, making no VID call at all.

The live run stopped at the marked VTL1 vector-3 instruction and verified the execution witness. It held the intercept for 266 ms, completed it, stopped the VP, deleted the disposable partition, and exited zero. The required memory pairing is explicit rather than discovered: a VSM-capable VA memory block and a GPA range that applies default VTL protections.

Then the same probe, pointed at real Secure Kernel code. A second mode maps the matching shipping securekernel.exe at its preferred VA — gated additionally on the PDB identity, image base 0x140000000, image size 0x175000 and cc c3 at the symbol-derived DbgBreakPointWithStatus RVA 0x1FA70 — calls that stub from a VTL1 CPL0 interrupt trampoline, validates the pending stop's VTL, CPL and RIP, and re-enters the VID dispatch loop after completion.

It held the image-backed stop for 250 ms, retired the real int3; ret, and observed the handler's post-return witness. That is controlled execution of genuine Secure Kernel code at VTL1 kernel privilege, with the stop held and released on our terms.

And it is emphatically not a debugger attached to Windows' Secure Kernel. It is an image-backed execution proof in a partition we created, with no Windows booted in it and no Secure Kernel initialised. What remains open — and is filed as a decision rather than a schedule — is reproducing that stop inside an initialized Secure Kernel, which a managed VM blocks for the reason this part measured: the completion stream belongs to vmwp.exe.

The other axis: one byte, and a breakpoint inside a trustlet

Running in parallel with all of that is a different question with a much shorter path. Secure Kernel contains a gate that decides whether a trustlet may be debugged — the jne at securekernel.exe+0x1434E guarding the call to SkpsEnableDebugging inside IumInvokeSecureService. A published capability patches it.

Reproducing that from the Hyper-V root took one byte, 0x75 → 0xEB, and the discipline around it is most of the result:

The patch is in memory, by guest-physical address, and the on-disk image is untouched. The GPA is derived per run by walking Secure Kernel's own page tables from its CR3 — never from a remembered value — and the write is one byte, read back immediately. The run is its own proof in both directions: restoring one in-memory byte closed the gate again with no reboot, which a disk patch could not do; and a disk patch would have left the loaded image unchanged, so the open-gate arm could not have passed.

The partition is selected on Secure Kernel presence, not on a remembered id — a correction this run had to make before anything else, because the script carried TARGET_PARTITION_ID = 3 and the ids had turned over. It now picks the partition whose InfoSecureKernelBase is non-zero and prints the rejected one as the control.

With the gate open, against LsaIso.exe:

operation result
attach DebugActiveProcess → True; the injected break at 0x7FF888203AB0
read trustlet code the whole PE export directory walked in the trustlet's own memory to resolve ntdll!DbgUiRemoteBreakin = 0x7FF888215B90
write trustlet code VirtualProtectEx → old protection 0x20; one 0xCC written, read back 0xCC — the permission nothing had tested, and it is granted
register read GetThreadContext → RIP = 0x7FF888203AB1, one past the injected int 3
register write SetThreadContext redirecting RIP to 0x7FF888215B90
our breakpoint fires EXCEPTION_BREAKPOINT 0x80000003 at 0x7FF888215B90 — the address we chose — with RIP = 0x7FF888215B91, exactly address + 1
put back byte restored and verified, RIP restored, detached, trustlet alive

A-B-A, because a one-sided run is indistinguishable from a trustlet that was debuggable anyway: gate byte 0x75 → ACCESS_DENIED; patched 0xEB → every row above; restored 0x75 → ACCESS_DENIED again.

A second arm added single-stepping: EFLAGS.TF armed 0x246 → 0x346, two EXCEPTION_SINGLE_STEP events at 0x7FF888215B94 then 0x7FF888215B9D — advances of 4 and 9 bytes, matching sub rsp,0x28 and mov rax,gs:[0x60] as read from this host's ntdll — with TF self-cleared by each trap and EFLAGS moving 0x246 → 0x206 across the first step, which a replayed breakpoint would not do.

That arm also crashed the trustlet, and the cause was our harness. It restored RIP without RSP after stepping that sub rsp,0x28, so DbgBreakPoint's ret popped a value that was never a return address. WER logged IUMTrustletCrash twice. The guest survived — uptime monotonic, lsass alive, Secure System running — but LsaIso did not restart, so the single-step arm had no post-restore control.

Re-run on a rebooted guest with three harness defects fixed, and it is clean. The full 1,232-byte CONTEXT is now snapshotted and restored wholesale with a read-back check; the original page protection is restored (the first run left the page RWX, which the second saw as OLD=0x40 where the first read 0x20); and liveness is sampled at 1, 3 and 6 seconds instead of 400 ms — that early sample had reported TRUSTLET_ALIVE=True moments before the crash surfaced. Every address was re-derived, including a CR3 that read 0x1201000 for a third boot, which is why that value is not an identity. Zero IUMTrustletCrash events since that boot — counted from LastBootUpTime, because a 20-minute window spans the reboot and returns the previous run's crashes, which is how that check first read as a failure.

Still VTL1 user mode only, one build, one trustlet. Nothing there is a property of VBS.

A retraction worth more than the result it corrects

For several days this record carried the claim that "the virtual read path handles the Secure Kernel context and the virtual write path does not" — an SdkWriteVirtualMemory that segfaulted on VTL1 addresses. It shaped a design decision and it went into a changelog.

Both halves are wrong. Four arms — {VTL0 NT kernel, VTL1 Secure Kernel} × {PE header page, executable .text page} — each mutate a byte and read it back through the physical route at a GPA walked from Secure Kernel's own CR3, validated against the virtual read first and restored in a finally. All four land. Three runs, 12 of 12. The VTL0 arms are the control the original lacked, and the VTL1 .text arm is the patched gate's own page class.

The access violation that did end those runs comes from the bench's own Python binding. Its CfgParameters declares 12 fields and 48 bytes where the SDK header's VM_OPERATIONS_CONFIG has 17 and 56. So every call through that binding writes 8 bytes past a Python allocation, and the process dies wherever the garbage collector next walks it — which python -X faulthandler reports as Garbage-collecting, at a different place in every script.

Three things recorded with it. A 0xC0000005 exit from any of these probes says nothing about what the probe was doing, which is exactly how the claim arose. "Every run ends in an unload segfault" is false in both directions. And five configuration flags have never been settable from this bench at all.

A structure declaration eight bytes short was attributed to a Microsoft API for days, because the crash reliably followed the call that it reliably followed. The gate that chose the physical write route is unaffected — it patched physically and verified by read-back — and what changes is only the reason recorded for that choice.

Where this leaves the catch half

Established. Secure Kernel has no hypercall or MSR route to the hypervisor's VTL1 debug port, across ten builds, against a complete positive control. The root can halt a child's VP and read VTL1 while it is halted. A VTL1 #BP is delivered to VID with the enclave's own faulting instruction named. The receiver is reachable, the gate on it is a process-identity check, kernel mode satisfies it, and a partition we create and own ourselves can hold a VTL1 kernel-mode stop on real Secure Kernel code and release it — 266 ms synthetic, 250 ms image-backed. On the other axis, every clause of the published IUM capability reproduces from the root: attach, read, write, register read, register write, our breakpoint firing at our address, and two single steps.

Not established. That our own intercept was ever delivered on a managed VM — no 0x80010003 was seen, the slot saturating at 64 of the owner's messages — and the reset is over-determined between arming and stealing rather than pinned, because on that partition stealing is the only way to attempt arming. That a stop can be held inside an initialized Secure Kernel. That VTL can be retrofitted onto an existing partition, which remains an inference. And the enclave work is VTL1 user mode throughout.

Closed since, 4 October 2026. A stop was held inside an initialized Secure Kernel, by a route this part did not consider: not owning the managed VM's partition, but attaching a debugger to the vmwp.exe that already owns it. The attempt to become a VM host instead is part 8, and the stop is part 9. The two sentences above stay as they were written.

What it cost. Two guests hard-reset, each seconds after reporting itself healthy. One trustlet crashed twice by our own stack arithmetic. One Microsoft API blamed for days for a struct we declared eight bytes short. A Credential Guard route retired on a reading its own control invalidated.

One thing is still missing from the reading side, and it is the thing a snapshot cannot be. Everything in part 5 reads a file. The decode was built with a source seam precisely so a live source could join it — and when one finally did, it produced a refusal no fixture had ever managed, and a measurement that stopped a tool from shipping: a live source is not a snapshot.