← writing

Following a silent Secure Kernel debugger

19 September 2026 · Windows internals · An investigation with WinDbg and windbg-mcp

The most interesting instruction in this investigation was a ret.

We had a Windows guest with VBS running, working NT and hypervisor debuggers, a configured Secure Kernel network endpoint, allocated VTL1 debug buffers, and a boot loader that successfully prepared the debugger descriptor. Yet the Secure Kernel debugger never connected. Following that silence eventually led to this implementation in the target's securekernel.exe:

; IumpDebugBreakRequestedByVtl0, image RVA 0x2ED60
33 c0    xor eax,eax
c3       ret

Two other routines made the picture more compelling: debugger initialization populated metadata, while the debugger-information query constructed three constant zero bytes. Five additional exact images had the same behavior in the three routines we compared.

That is not a proof that Secure Kernel debugging is impossible, or even that every native debugger path in those images is absent. It is a useful, narrower finding: the inspected paths are stub-like, and fixing the surrounding configuration did not produce a native VTL1 debugging session.

This post follows the evidence that got us there, including two real configuration failures, an initially misleading error message, and the distinction between code we inspected and execution we actually observed. It is a record of a lab investigation, not a production configuration guide.

A new switch, an unanswered question

The starting point was the WinDbg 1.2606.22001.0 release note, which added Secure Kernel configuration support to kdnet.exe through -s. The installed utility's help confirmed the switch. Its file version, and the debugger engine we used, were 10.0.29617.1000.

The missing piece was the target. The release note did not say which Windows images contained a working native Secure Kernel debugger, or which virtualized arrangements had been tested. Treating the new switch as proof of those things would have skipped the actual experiment.

The goal was to extend windbg-mcp, but the plan was deliberately lab-first: demonstrate a breakpoint, inspect registers and memory, single-step and resume in Secure Kernel before changing the MCP transport surface. The investigation was agent-assisted, with the human operator controlling host-side actions and confirming console health. Native debugger sessions and existing MCP NT controls supplied the execution evidence.

Ordinary NT debugging is not that demonstration. NT runs in VTL0; Secure Kernel runs in VTL1, behind protections enforced by the hypervisor. Connecting to NT does not automatically grant control over VTL1. Microsoft's VSM description explains the separation.

Our eventual arrangement was two sibling VMs on a client Hyper-V host: a debugger workspace and a disposable Windows 11 Insider target. The target ran 29671.1000. The outer host reported build 26200. VBS was running and memory integrity appeared in the running security services. Secure Boot was off in the disposable target for the debugging experiments; this was not a hardened guest configuration.

There were three separate endpoints, assigned by the utility and checked against the saved configuration:

Context Lab port Result
NT / VTL0 50010 Attach, inspect and resume worked
Hypervisor 50011 Attach, inspect and resume worked after target hypervisor setup
Secure Kernel / VTL1 50012 No successful attachment

Those are observations about this lab, not a general port-allocation rule. Connection keys and machine addresses are intentionally omitted.

The error that asked the wrong question

Before the Insider target, we tried an existing Server 2025 guest. A verified copy of the new KDNET utility failed with:

Failed to enable securekernel debugging.  Is secure boot enabled?

Secure Boot was independently measured off. Another diagnostic complained that hypervisorlaunchtype was absent, but a standalone hypervisor configuration later succeeded while emitting that same warning. Neither line, by itself, identified the fatal operation.

Searching the utility found a command string referring to vsmdebugtype. That justified investigating the setting, but not claiming that the failing invocation had executed it. This distinction mattered: an early attempt to move straight to EXDI was premature. A standalone BCDEdit reproduction could show rejection, but still could not prove which child command had made KDNET fail.

We then traced KDNET's child processes with CDB, recording process creation, selected command lines and exit status. After a BCD backup, the relevant sequence was:

Child operation Exit
Enable hypervisor debugging 0
Enumerate the current boot entry 0
bcdedit.exe /set {default} vsmdebugtype 3 1
Parent KDNET 0x80004005

The target's BCDEdit rejected the element. Now the command was execution evidence rather than an interesting embedded string. The older guest's bcdedit.exe was 26100.1; our separate binary survey found vsmdebugtype in 28000.1 and later sampled images, but not the inspected 26100 samples. This identified an earlier positive sample, not the exact introduction build or a supported-target guarantee.

We did not replace the system BCDEdit or force an undocumented numeric element into the older OS. A newer configuration editor would not establish that its loader and Secure Kernel understood the configuration. Moving to a full Insider target let the next layer be tested instead.

On 29671, KDNET configuration succeeded. Attachment still did not.

No packets is a symptom, not a root cause

A listener started before target reboot did not connect to the SK endpoint. Host-side packet captures contained NT traffic but no recorded SK traffic. A later listener also failed. Those observations narrowed the problem, but a quiet capture could not distinguish failure to initialize, failure to initiate a session, a rejected request, or a path outside the capture's coverage.

One early automation attempt coincided with the workspace freezing and requiring a forced restart. The cause was not established. Subsequent live tests used one controller at a time, bounded experiments, explicit resume/detach, and NT controls afterward. WinRM timeouts were not treated as proof that the target was stopped: console responsiveness and advancing uptime sometimes showed continued execution while management access lagged.

The target's VBS status also did not prove that its own Hyper-V hypervisor had launched. Read-only feature checks found that the hypervisor feature was disabled. With approval, we enabled it inside the disposable target and selected automatic launch. A startup event then confirmed launch, and the hypervisor debugger connected.

That was a new observation point, not Secure Kernel success.

A real failure hiding behind the inactive port

Matching hypervisor symbols were unavailable, so this part used disassembly of the exact hvix64.exe image. Before using image-relative addresses live, we checked its reproducible-build stamp, checksum, image size and CodeView identity against the copied image. The field names below are interpretations of that code, not public structure definitions.

The root VTL1 debugger context existed. Its internal port record contained the expected SK port, but another field, the active-port word at context offset +0x12, remained 0xFFFF. The VTL0 context had its expected active port.

This was more informative than an empty capture. The activation routine assigned the active-port field only after registration and debug-buffer allocation. However, the configured port's presence in an internal node did not prove registration had succeeded: a helper wrote that node before attempting insertion.

An offline failure path suggested debug-pool exhaustion. We did not call that the cause until a separate early-boot trace measured the activation return:

VTL loop index Activation return in AX
0 0
1 0x1D

The stop was immediately after the activation call, at image RVA 0x2A04A0. In the matching code, that value propagated from the debug-buffer allocator when its free-page list was exhausted. A non-null free-list head afterward was not a contradiction: cleanup could return pages to the list.

After another backup and approval, we increased the displayed hypervisordebugpages reservation from 1000 to 2000. We changed neither VM RAM nor the outer host. On the next boot, the VTL1 active port was 50012, and both buffer descriptors were populated: one described two pages, the other 0xA0 pages, with size fields of 0x1000.

This follow-up used initialized state, not a second direct capture of the return register. Because the matching routine assigned the active port only after both allocation calls succeeded, the state established that the previously failing allocations had completed.

The SK listener still did not connect.

We had fixed a real failure without fixing the end-to-end problem. That is an important result to keep rather than compress into either “the setting was irrelevant” or “the problem was solved.”

Following the session request, then the loader

The next hypervisor trace watched a VTL-specific session-reset path. It reached our selected point twice for VTL0 and never for a nonzero VTL during the bounded boot interval.

The caveat is structural: the breakpoint was after initial guards. The count said nothing about requests rejected before that point, another path, or a later request. It was evidence about one location and interval, not proof that SK never requested a debugger session.

We then moved upstream. The loader's BCD table associated vsmdebugtype with element 0x2500013A. The inspected loader could accept the configured type and still leave its SK descriptor disabled if a later setup operation failed. An initialized hypervisor port did not prove successful loader preparation.

There was also a lifetime trap: SkScrubLoaderBlock explicitly cleared 0x920 bytes of the loader block. Reading zeros after cleanup would not reconstruct what had been passed at startup. The measurement had to happen at the right time.

The first boot-loader debugging attempt connected but did not stop where we needed it. Initial-break handling alone was insufficient in that observed transition. The retry armed loader module-load and initial-module-load breaks as well. WinDbg documents these as distinct event filters; our successful combination did not isolate which one would have sufficed alone.

At the loader stop, we verified the image and surrounding instructions, then used three one-shot trace points:

Measurement Loader RVA Observed value
Debug-type gate return 0x2B508 EAX = 3, NET
Descriptor-setup return 0x2B6C1 EAX = 0
Final configuration at this stage 0x2B71E Type 3, SK port 50012

The final type was at loader-block offset +0x740; the port was at +0x738. We read only selected fields because nearby configuration contained the debugger key. The breakpoints were consumed, the target resumed, and the temporary boot-debug element was removed. The current BCD entry matched its saved original afterward.

For this boot, the suspected loader-configuration failure had not occurred. This still did not prove that every later handoff preserved the fields, or that SK initialized a debugger. There was no SK listener during that single-controller loader trace, so it was not an attachment test either.

Three routines worth reading to the end

The downstream inspection used the target's exact securekernel.exe, version 10.0.29671.1000, and its matching PDB. The image's SHA-256 was:

F6112AEEA8D5306D401D9D8779A40BD67667CD67A323C3BF1E27BFA59086AAEA

First, SkdInitSystem, at RVA 0xA387C, called SkdInitDebuggerDataBlock only for phase zero and returned zero. Its callee populated debugger metadata, lists and offsets; its inspected body contained no transport initialization. SkInitBootProcessor called SkdInitSystem(0) and ignored the return.

In abbreviated pseudocode, not recovered source:

if (phase == 0)
    SkdInitDebuggerDataBlock();
return 0;

Second, IumpReadSecureKernelDebuggerInfo, at RVA 0x2C464, built its response from constants. The decisive instructions were:

xor r8d,r8d
mov word ptr [rsp+48h],r8w
mov byte ptr [rsp+4Ah],r8b
mov r8d,3

It reported a required length of three bytes, rejected a short buffer, and copied those three zeros through the appropriate copy path. It did not load debugger-state globals or inspect loader configuration. This is not “the debugger happened to be disabled when queried.” It is the inspected function constructing a constant response.

Third was IumpDebugBreakRequestedByVtl0, at RVA 0x2ED60: the xor eax,eax; ret at the beginning of this post. There was no hidden branch further down that function.

These are observations about specific function bodies. Debug metadata, debugger-named exports and exception traps can exist without demonstrating a usable native KDNET session. Conversely, not finding a transport loop in the paths we inspected does not establish that no alternative implementation exists anywhere in the image.

The query that never reached Secure Kernel

A read-only runtime query seemed like a useful independent check. Using the class identifiers in the PHNT header, we queried class 149 as an NT control and 237 as SystemSecureKernelDebuggerInformation.

Probe Result Returned length
Ordinary class 149 Success; bytes [1, 1, 1] 3
Ordinary class 237 STATUS_NOT_SUPPORTED 0
Extended class 237 with no input STATUS_INVALID_PARAMETER 0

It would have been tempting to present the second result as SK confirming that its debugger was unsupported. Resolving the NT dispatch tables showed why that would be wrong.

In this NT image, ordinary class 0xED dispatched to RVA 0x9BAD96, which set 0xC00000BB and branched to cleanup. NT returned STATUS_NOT_SUPPORTED without asking SK for its debugger state. The extended probe failed the missing-input check before class-specific handling; it did not test a correctly formed extended request.

Neither failure measured live SK debugger flags. The successful NT control bytes also did not prove a controller was currently attached. Undocumented interfaces need their own control-flow investigation, even when the class name looks perfectly descriptive.

Was 29671 an outlier?

We used Winbindex as an index, downloaded samples from Microsoft's public symbol server, and checked SHA-256 before comparison. The workspace supplied one local image. Matching PDBs identified the functions; images were opened as offline data, not executed or installed.

Exact SK file version Init RVA Information RVA Break-request RVA
26100.9457 0xAC3BC 0x25BE4 0x27CDC
28000.2952 0xB2BB4 0x2939C 0x2BA6C
29617.1000 0xB6720 0x41108 0x439EC
29639.1000 0xB8C00 0x3FF44 0x42840
29648.1000 0xA3A54 0x2D194 0x2FA90
29671.1000 0xA387C 0x2C464 0x2ED60

All six had the same behavior in these three routines. This included samples before and after a noticeable image-size reduction between 29639 and 29648; that reduction did not mark a change from implemented to stubbed behavior in the compared routines.

An older 19041.207 image loaded matching symbols but did not expose the three names. It remains unclassified by this comparison. A function named SkdpStub in that image contained exception-handling logic: even the word “stub” was not a substitute for reading the instructions.

Acquisition had its own traps. Four requests returned bytes different from the expected index record. Three also contained a different version from the requested one. We retained those downloads locally but excluded them from the matrix. A plausible filename and a successful HTTP response do not establish image identity.

This is a bounded survey, not a list of all supported or unsupported Windows builds. It found no known-working native SK debugger candidate and did not test the comparison images live.

What the investigation established

The sequence matters more than any single observation:

Old target: KDNET child command rejected by BCDEdit
    -> full Insider target: configuration succeeds
    -> target hypervisor launched: HV debugger connects
    -> VTL1 allocation failure captured: AX = 0x1D
    -> larger debug reservation: active port and buffers initialized
    -> loader trace: NET selected and descriptor setup succeeds
    -> SK inspection: metadata initialization, constant status, no-op break

These were separate experiments, not one uninterrupted trace through every layer. Together they support an implementation-stub hypothesis for the inspected paths, while leaving the end-to-end native attachment unsuccessful and overall support inconclusive.

There is no basis here for declaring that all retail SK images lack debugging, naming a minimum supported target build, or recommending another unexplained boot flag. The native investigation can reopen when there is a known-working image, a documented target requirement, or a concrete alternative implementation path to inspect.

No new MCP transport API was needed to reach this finding. Adding one before the native acceptance gate passed would have hidden a target-side investigation behind a server-side feature claim.

The next lab should not weaken the host

The software-only fallback is LiveCloudKd's EXDI route, which approaches the guest from the Hyper-V host. Its recipe calls for fixed memory, no nested virtualization on the debuggee, and prefers one vCPU for initial testing. It also calls for Classic scheduling on a Windows client host.

Our outer host used Root scheduling. Microsoft documents Root as the only supported scheduler configuration on Windows client systems. This was not a target-only adjustment: changing the scheduler would affect the shared host and require its reboot.

That host is hardened. Its configuration is not part of the experiment. We therefore stopped before EXDI installation or host changes and selected a separate nested lab as the next direction. The intended separation is a disposable Server Hyper-V lab VM with its own inner debuggee; the inner target need not itself expose another layer of virtualization. Capacity, provisioning and compatibility remain to be validated. Nothing about this post is a claim that that next lab already works.

Evidence and reproduction boundary

The investigation record preserves the chronological measurements and corrections. A sanitized evidence bundle carries selected offline disassembly, image hashes and measured summaries. It does not redistribute Windows binaries or PDBs, raw BCD exports, connection credentials, dumps or unreviewed packet/debugger logs.

To inspect your own exact image without executing it, open a copied securekernel.exe with CDB's -z image mode, configure a local symbol cache backed by Microsoft's symbol server, confirm lmvm identifies the expected image and PDB, then disassemble the three named functions with uf. A missing symbol is an unresolved comparison, not an automatic negative. The RVAs in this post are image-specific evidence, not addresses to paste into another live target.

The useful lesson was not that configuration work had been wasted. It exposed two real failures and established where they stopped explaining the result. A working port was not a working session; successful loader setup was not SK initialization; and an NT query error was not an answer from Secure Kernel. Reading the final three instructions only became persuasive after keeping those boundaries intact.