Kernel64 devlog: entry paths, stack bounds, and real unwind checks
The Kernel64 runtime needed more than an AMD64 emitter that returned the right arithmetic result. The work in this entry ties together active-code compaction, retained entry stubs, a call-graph stack budget, and Windows unwind behavior. One concrete fix was only eight bytes of stack alignment, but finding it required checking the emitted call graph rather than trusting the runtime template.
Reuse the integer runtime, not its assumptions
Kernel64 and User64 share a pure-integer x64 source blob and the machinery that identifies active handlers and analyzes machine-code control flow. Their admission policies are different. A kernel request must describe an AMD64 Native-subsystem system image, and the selected function must be complete, lowerable to the supported integer semantics, and free of exception-sensitive boundaries.
The policy requires HVCI-conscious output, prohibits self-modifying code and writable-executable input sections, and requires signing after protection. The runtime does not call CNG, allocate dynamically, wait, or modify executable pages. These rules deliberately remove runtime behaviors that would need a different kernel safety argument.
A configuration flag named hvci_compatible is a requirement placed on the compiler path. It is not a certificate saying the resulting driver has already loaded successfully under HVCI.
The original entry still executes
Moving the protected body into a nonpaged section is not enough if execution first passes through an original pageable entry. The policy now validates every retained entry and thunk in the actual path, with a bound of eight entries. Each retained section must be executable, nonwritable, and nondiscardable.
The important negative case is a PAGE-prefixed original entry. Even if the final body lives in .aegis, the original jump instruction still runs. A request that calls this path nonpaged or DISPATCH_LEVEL-safe contradicts the retained entry and must fail. The same check catches a writable or discardable stub that would otherwise disappear behind a reassuring description of the destination section.
// Policy sketch: inspect the path that will execute.
for entry in retained_entry_chain:
section = section_containing(entry)
require section.executable
require not section.writable
require not section.discardable
require section.residency matches requested_IRQL_contract
require retained_entry_chain is nonempty and boundedThis is why the unit of validation is the path, not just the new section. Microsoft’s pageable-code rules provide the operating-system contract; the compiler has to apply that contract to the bytes it leaves reachable.
Count the live call chain, not the largest frame
The generated runtime has a local added-stack budget of 4 KiB. The bound is computed over nested direct calls and includes return addresses and caller frames that remain live while their callees execute. Looking only for the largest individual SUB RSP would miss the accumulated demand.
For a simple illustrative chain, a caller holding 512 bytes and calling a helper that holds 256 bytes needs more than the larger of those two values: both frames and the call’s return-address contribution coexist. The real analysis must use the stack state at each call site, since a function can call different helpers while holding different temporary allocations.
// Explanatory recurrence for a bounded, acyclic call graph.
peak(function) = max(
local_peak(function),
live_stack_at(call) + return_address_size + peak(call.target)
for each reachable call
)
reject unbounded recursion
reject unmodeled stack changes
reject misaligned call sites
reject generated_peak > 4096The weighted call-graph check found that vm_contextual_produce itself made another call without the required alignment slot. The fix allocates and releases eight bytes and updates the associated route-state offsets. A tiny local assembly change can invalidate other offsets, so the frame description and the code have to move together.
The 4 KiB number is the protector’s own budget. It is not the Windows kernel stack size, and it says nothing about how much stack an external caller has already consumed. Whole-driver call chains and the target IRQL still need their own validation. Private helpers also retain an internal VM register contract: the enclosing VM preserves the original host state, so those helpers should not all be described as independent C ABI functions.
Compact first, describe the final code second
The kernel output previously carried unused construction material: a 64 MiB handler arena, a 4 MiB dispatcher-slot suffix, and inactive handler templates. The active set now starts from the final packet inventory and expands through real execution dependencies. This removes dead construction material without disabling nesting, fragmentation, or DSCF.
Unwind analysis runs against the resulting layout. Actual roots, indirect targets, and helpers are analyzed after compaction, then associated with function-owned chained unwind records. The original .pdata records remain present. The runtime source identity is also checked before reusing the shared blob, so a later template replacement cannot silently inherit an obsolete stack contract.
The emitter’s target inventory is part of this proof boundary. An empty indirect-target list is rejected; a new indirect entry must be registered before the analysis can honestly claim coverage. A decoder cannot infer an unlisted target merely because the rest of the graph looks complete.
Why function ownership matters to unwinding
The related ordinary User64 work had already exposed a useful failure. A real Trap Flag run crossed between two independent frame records with an internal jump. Windows interpreted that boundary as a tail epilogue and popped a return pointer while a 696-byte VM frame was still live. Earlier static checks and simpler Windows API tests had passed.
That observation belongs to the User64 work, not to a claimed kernel crash. It explains why the shared machinery now preserves owning-function identity through chained state views. An internal branch may cross a frame-state range without leaving its function. A helper reached by a genuine call has a different owner. A chain node itself does not represent another stack allocation.
Kernel64 reuses the ownership-aware machinery, but its separate residency and local-stack checks remain necessary. Passing the ordinary user-mode suite does not automatically validate the kernel backend.
The execution harness is intentionally narrow
The controlled Kernel64 fixture uses the Strong configuration and three deterministic seeds covering zero, one, and two nested levels. The harness maps the arithmetic fixture with DONT_RESOLVE_DLL_REFERENCES and invokes only the known aegis_kernel_formula(u32,u32) export. It does not resolve imports, call DriverEntry, install a driver, or validate a signature.
Each build receives 49 boundary combinations and 1,024 random inputs: 1,073 input cases compared against the original function and the public reference. The harness also checks Windows runtime-function lookup over generated ranges and performs real TF stepping, attempting to unwind the actual execution context back to the original caller.
These layers answer different questions. The behavior vectors check the arithmetic fixture. Lookup checks whether the emitted ranges can be found. Single-step unwinding checks the frame state at executed instruction boundaries. None turns user-mode mapped execution into a kernel deployment test.
Keep the evidence snapshots separate
The retained machine-readable Release report currently contains the following matrix. It differs from the older table in the prose kernel note, so this entry does not combine their artifact sizes and instruction counts into one invented run.
| Nested depth | File bytes | Local stack bound | TF steps | Unwind frames |
|---|---|---|---|---|
| 1 | 4,879,360 | 1,048 B | 51,963 | 55,776 |
| 0 | 3,974,144 | 1,048 B | 47,980 | 50,146 |
| 2 | 4,973,568 | 1,112 B | 51,919 | 55,856 |
All three recorded TF summaries report zero failures. The same JSON records pageable_entry_rejected: true and identifies the native-island negative configuration. Separately, the prose report records a combined Release regression of 108/108 in 56.88 seconds and Debug 108/108 in 246.51 seconds. Those historical suite totals are not measurements of the exact three artifacts in the table above.
What is still outside the result
Protection clears the original signature directory and marks the output as requiring final signing. The release path still has to validate the transformed image, sign it through the appropriate process, and test signature verification, driver loading, HVCI, Driver Verifier, and the intended Windows versions in an isolated environment.
The repository has IOCTL, worker, interlocked, PAGE, and DPC-shaped inputs, but that corpus is not a functional WDK driver. The current runtime checks also do not establish that the software is difficult to reverse engineer. Correct arithmetic, bounded generated stack use, and correct unwinding are compatibility obligations; extraction resistance needs a different experiment.
Evidence note: this entry uses the kernel policy implementation, the Kernel64 report, the related User64 unwind note, and the retained compact-unwind JSON. It checks and distinguishes existing records; no driver was installed and no historical suite was rerun for this article.
References
Microsoft x64 calling convention · x64 exception handling and chained unwind records · Pageable driver code and data