WinDbg already ships an EXDI server, and one option reset the host
26 September 2026 · Windows internals · Secure Kernel from the root, part 2 of 5
A Secure Kernel record in dbgeng.dll that nothing reaches, a registration step the engine will not do for you, and an in-process load that took the machine down.
Part 1 ended with a target that ships no debug transport. The next question is whether the debugger can come at it from outside, through DbgEng's EXDI path — the interface WinDbg uses to drive targets it cannot reach over KD.
Everything below is either an offline read of dbgeng.dll 10.0.29617.1000 (no PDB is served for that build, so the readings are from string cross-references, .pdata and imports rather than symbols) or a measurement on one bench. The conclusion is that this route is parked, and the reasons are more interesting than the verdict.
The good news first: the adaptation layer already ships
Read-only inspection of the installed WinDbg package (1.2606.22001.0) finds ExdiGdbSrv.dll present for amd64 (1,455,416 B) and arm64 (1,523,512 B), with exdiConfigData.xml beside it. It is a generic EXDI COM server that speaks the GDB remote serial protocol.
That is a materially better contract than it first appears. A backend does not implement an EXDI COM interface; it implements a GDB stub over TCP. No third-party debugger component is a dependency, and the COM surface disappears from the design.
Then the bad news, in four measurements.
1. The Kd= option set, and the one option that matters
An EXDI connection string is a list of Name=Value pairs, and Kd= selects one of six kernel-discovery modes. The parser spans image RVA 0x2FC810–0x2FCB46; each option is a string compare followed by a mode number stored at ctx+0x20.
Kd= value |
Mode | Compared with | Carries an address |
|---|---|---|---|
Ioctl |
1 | _wcsicmp |
no |
GsPcr |
2 | _wcsicmp |
no |
VerAddr:<addr> |
3 | _wcsnicmp, length 8 |
yes — %I64i into ctx+0x28 |
Guess |
4 | _wcsicmp |
no |
NTBaseAddr |
5 | _wcsicmp |
no |
HwDbgBlock |
6 | _wcsicmp |
no; also sets ctx+0x44 |
A malformed address returns 0x80070057 and the conversion is checked for exactly one field, so VerAddr: is parsed rather than merely recognised. The remaining option names in that parser: CLSID, DataBreaks, Desc, EBC, Exdi, ForceX86, Args, Linux, Inproc, PathToSrvCfgFiles.
Kd=VerAddr:<address> is the load-bearing find. It lets a caller name the version block instead of letting the engine hunt for NT's — which is exactly the mechanism a Secure Kernel bind would need, given that part 1 found SK's data block populated and sitting there.
2. There is an sk record. Nothing reaches it.
A table of 40-byte records at .data RVA 0xA1F718 maps an EXDI memory space to a kernel module and its version-block symbol: nt/User Mode, nt/Supervisor-Kernel, hv/Hypervisor, and — the interesting row — sk/Hypervisor with sk!KdVersionBlock. Those space names are the ones exdiConfigData.xml gates with SupervisorMemory and HypervisorMemory.
That looks like a built-in Secure Kernel bootstrap, and it was first recorded as one. It is not. Mapping .pdata (18,091 functions) and classifying every function that touches the table gives a different answer:
| Question | Measurement |
|---|---|
| Who reaches the three table readers? | Only functions that also reference EXDI strings (0x3042F4, 0x305900, 0x42EE70) |
| Any KD-transport caller? | None — 0 of 91 KD-transport strings appear in any function touching the table |
The hv-vs-sk selector at 0x42EDE0 |
No direct callers; its address is never taken |
So the path is EXDI-gated, and the one function that distinguishes the sk record from the hv record is unreferenced in this build. A hypervisor KD session cannot be steered to sk by this machinery. The record is real; whether it is reachable at all is unproven, and it may be vestigial.
The caveat is not a formality. Absence of a direct caller is not proof of dead code — a computed jump table would not show up in that scan — and this is one engine build. What the measurement rules out is the claim that the engine already knows how to bind Secure Kernel, which is what the record's existence had been taken to mean.
One string is a red herring worth naming so it is not re-read as evidence: the only securekernel occurrence with a code reference (RVA 0x814178) sits in a partially-mapped-image diagnostic, not a bootstrap. The securekernel.exe and securekernella57.exe names at 0x813E60/0x813EC0 have no code reference at all — they live in a name table.
3. Shipping is not registration
The CLSID {29f9906e-9dbe-4d4b-b0fb-6acf7fb6d014} appears in the DLL's strings and is absent from HKLM and HKCU \SOFTWARE\Classes\CLSID on a stock machine. Elevated, with it absent:
kd -kx exdi:CLSID={29f9906e-...},Kd=Guess,DataBreaks=Exdi
-> 0x80040154 Class not registered (and it registers nothing)
An earlier revision of my own notes claimed the engine self-registers, reading the registration strings inside dbgeng.dll as proof. It has that code; it did not run it there. Strings are evidence of a code path, not of when it executes — the same error as reading the sk record as a working bootstrap, two measurements apart.
What registration actually produces shapes everything after it. regsvr32 writes InprocServer32 with ThreadingModel = Apartment and an AppID {1FC9AD2A-EEC4-467E-AA40-951987327C81} (ExdiTestServer1) whose DllSurrogate is the empty string — the registration that asks COM to host the server in dllhost.exe. So on the registered path the "EXDI server" is a separate process owned by RPCSS, not a DLL inside the debugger. Two consequences fall straight out:
EXDI_GDBSRV_XML_CONFIG_FILEcannot reach it. That variable is read from the server's environment, and a surrogate is spawned by RPCSS rather than by the debugger, so it inherits that service's environment and never sees a variable set forkd.PathToSrvCfgFilestravels inside the connection string, across the COM boundary, and is the one that works. Measured with the variable set and a private config naming a loopback listener: nothing connected.It cannot be registered where it ships.
LoadLibraryExWagainst the package's own copy returns error 5,Access is denied— under plain flags,ALTERED_SEARCH_PATHandSEARCH_DEFAULT_DIRSalike, because of WindowsApps ACLs — soregsvr32exits 3 and writes nothing. That failure shows up only in the registry, not in the exit code. Copy the DLL to an ordinary directory; its imports areADVAPI32,KERNEL32,OLEAUT32,SHLWAPI,USER32,WS2_32,XmlLiteandole32, all system DLLs, so it travels alone.kd.exeruns from WindowsApps unchanged.
Also measured, and an easy half-hour to lose: cdb.exe has no -k switch at all. Kernel work needs kd.exe, whose -kx <options> is the EXDI connection.
4. The option that reset the host
There is an option that appears to let you skip registration, and it is a trap. Inproc=<value> resolves <debugger module directory>\<value> and calls LoadLibraryExW, so the value is a bare filename: Inproc=1 tried ...\amd64\1, and an absolute path was concatenated onto the debugger directory and failed with error 126. With the real filename, the engine prints its own warning and nothing further:
EXDI WARNING! The /Inproc option is not compatible with connecting remote clients.
Consider removing it if you encounter hangs or strange behavior.
That attempt hung the host hard enough to require a reset. The box came back with a two-minute uptime and no surviving debugger processes. A private GDB-RSP responder on the far end logged no connection at all, so nothing reached the transport: neither a runaway Kd=Guess scan nor the responder's replies were involved, and heuristicScanSize was already 0xffe. The hang was not observed directly — the session was interrupted and the machine reset — so in-process COM load is where the evidence points rather than a proven cause. What is established is that the warning was the last output and the machine did not recover.
The attempt carried a 60-second kill on the child, and the kill did not save the machine. So the bound a retry needs is a job object, not a WaitForExit timeout. Which then turned out to be necessary and not sufficient:
The job does not contain the EXDI server. Measured over two runs:
kdcreated suspended, assigned to a job carryingJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, then resumed — and thedllhost.exeholding the EXDI server had svchost (RPCSS) as its parent, so it was never in the job. Both runs left a surrogate running after the job was terminated and its handle closed, and each had to be swept by matchingDllHost.execommand lines against the/Processid:of thatAppID. A harness needs the job and the sweep.
And then it did not connect anyway
The pass criteria for an EXDI rig need a kernel, but the half that has twice ended in a hang rather than an error — registration, config parsing, the RSP handshake, the register contract — can be answered against a bare TCP listener. That is worth doing first, and it did not pass.
Two runs against a minimal RSP responder on loopback, Kd=Guess, no Inproc, kd bounded in a job: 75 s and 60 s, and the responder logged no connection at all. The surrogate, where one appeared, held no socket — so the stall is before anything is dialled, not in the exchange. Activation alone reproduces it with kd out of the picture entirely: a bare CreateInstance on the CLSID blocked for more than 17 s having launched no surrogate, with no DCOM 10010 in the System log inside that window.
An earlier reading of this as a hung CoCreateInstance rested on a test that also called Get-Member, which can block on COM type info by itself. Timed in isolation, the activation does block — but the first evidence for it did not show that, and the distinction is the difference between a measurement and a story that fits.
What the run did establish, beyond the registration and ACL facts: the register block this route depends on is 66 entries, 608 bytes, 1216 hex characters for a g reply. In that config file Size is decimal and Order is hex, so a reader treating both as one radix gets 752 or 776 bytes instead — and then a correct stub fails a handshake for arithmetic reasons.
Check which
exdiConfigData.xmlyou are reading. The package ships two that are not the same file. In the copy beside the debugger binaries,VMWareistargetArchitecture="X64"with both an X64 and an x86 register block andforceLegacyResumeStepCommands="yes"; in thewinext\copy the same entry is **X86** with no X64 block at all, and a differentHostNameAndPort.QEMUis identical in both, which is one reason to start from that entry. Which copy the engine loads when neitherEXDI_GDBSRV_XML_CONFIG_FILEnorPathToSrvCfgFilesis set was not established.
Parked, with a condition for unparking it
Put the measurements together and the trade collapses. For reaching Secure Kernel, EXDI would buy a generic memory target — because the sk record is not reachably wired — at the price of an activation stall that has already cost one host reset. Parts 3 and 4 produce that same generic memory access by another route, which makes DbgEng's marginal contribution here close to nothing.
That is narrower than "EXDI is a dead end", and the difference is worth keeping. The route becomes attractive again when two things change together, which is written down so it is checkable rather than remembered:
the activation stall is resolved by something other than the in-process load — which is materially what
Inproc=arranges, and is what the host reset points at; andthe
skrecord turns out to be reachable after all, the place to look being the caveat above: a computed jump table would not have shown in that scan, and it was one engine build.
Either alone is not enough. Resolving activation while the record stays unreachable buys a generic target; a reachable record with no activation buys nothing at all. What the route gives up by being parked is DbgEng's symbol handling — lm, PDB type resolution, structure formatting — and most of that is recoverable against the image without a live target, which is the one piece of luck in the whole assessment.