The generated assembly lacks the unwind descriptions needed to recover caller contexts on Windows ARM64 and Windows x86-64. The proofs establish normal execution and register restoration on return, but do not establish correct unwinding from an intermediate instruction.
This is an ABI / stack-unwinding correctness defect. I have not demonstrated an exploit, key disclosure, or attacker-triggered memory corruption. Native Windows validation remains outstanding.
Where the gap enters the TCB
Reviewed revision: 49f67820510f0508d7be78f2f01a678ee92ebb21, the head of #338, based on cf53f72. The affected emission and calling-convention behavior predates #338.
- The AArch64 target includes Windows and uses
extern "C".
- The x86-64 target includes Windows and uses
extern "sysv64".
- The shared naked-function emitter emits the instruction bodies without unwind directives. The architecture printers do not add them.
abiPreserved checks the entry/exit relationship. Recovering saved registers, SP and the return address at intermediate PCs is outside that predicate.
Rust does not infer these descriptions from the instruction text: the Rust Reference's naked-assembly rules require explicit directives for unwinding, and rustc's naked-function lowering emits global assembly whose COFF prefix/suffix contains symbol and section directives, without SEH records.
ARM64: incorrect return PC and SP
Microsoft's ARM64 exception-handling specification, “.pdata records” requires records for stack-manipulating functions. Its leaf exemption is for functions requiring neither local storage nor nonvolatile-register saves. RtlVirtualUnwind2's FunctionEntry documentation specifies that a missing entry causes a leaf/no-frame fallback equivalent to a return.
For example, vg_sha256_update's prologue pushes x30 by 16 bytes and saves x19–x24 in the scratch buffer. It then calls vg_sha256_compress.
Immediately after that call returns, x30 points inside vg_sha256_update, and SP is still 16 bytes below its entry value. Treating this context as a leaf cannot recover its caller.
I executed the generated SHA-256 init/update/finalize bodies in Unicorn 2.1.4 on the 64-byte message bytes(range(64)). Normal execution restored the callee-saved registers and SP, and the digest matched Python's hashlib.sha256. At the instruction immediately following the compression call, the captured state was:
| Value |
Captured |
| PC and x30 |
0x10368c4 |
| SP |
0x21ffff0 |
| Original return address saved at SP |
0x2100000 |
| Original entry SP |
0x2200000 |
The documented fallback would retain the wrong SP and select the internal address as the return PC. That fallback result is derived from Microsoft's specification; I did not execute the Windows unwinder.
Preserving x29, as #338 does, does not describe how to restore the other registers or locate the saved return address.
x86-64: incorrect nonvolatile-register context
Microsoft's x64 exception-handling specification, “struct RUNTIME_FUNCTION” and “Unwind procedure,” requires entries for functions that allocate stack space or call another function. With no entry, it reads RIP from [RSP], increments RSP by eight, and continues.
The immediate failure differs from ARM64: where an x86-64 body leaves RSP unchanged, the fallback can recover the return PC, but still fails to restore nonvolatile registers. For example, vg_chacha20_block saves rbx, rbp, and r12–r15 in scratch memory before repurposing them. A mid-body unwind cannot restore those values without descriptions of their locations. An incorrect RBP can also prevent correct unwinding of a caller that uses it as a frame base.
extern "sysv64" changes the function's calling convention; it does not supply Windows unwind records. The x86-64 assessment is based on source inspection, not a native Windows reproducer.
Additional validation and scope
- Audited all 119 generated AArch64 functions at the reviewed revision: 16 move SP, 56 contain calls, and none contain unwind directives.
- Assembled the extracted generated bodies using LLVM 21.1.0 for ARM64 COFF: the resulting object had 250,248 bytes of text and no
.pdata or .xdata. This was assembly of the actual generated instruction bodies, not a full rustc crate build.
- Independently checked control-flow stack depths and transitive call-stack budgets: all balanced, maximum 32 bytes, no documented budget exceeded.
- The existing ABI strings are non-unwinding, and the verified callees do not throw Rust panics. This report does not claim that an ordinary Rust panic currently crosses these functions. Windows also uses unwind information for stack walking/debugging.
- ELF/DWARF and Apple unwind behavior need separate assessment; this issue concerns Windows.
Suggested fix
- Add Windows-specific emission and, where necessary, verified prologue/epilogue implementations that use save locations representable by Windows unwind codes.
- Generate
.seh_* directives / .pdata and .xdata from the same structured code and frame/save description used by the model. Account for saved nonvolatile registers, SP changes, return-address storage, and partially executed prologues/epilogues.
- Do not fix only the SP adjustment. Many current functions spill nonvolatile registers to argument-provided scratch buffers; standard Windows unwind codes generally describe stack-relative saves. Those implementations need compatible save layouts, or another fully specified and tested unwind mechanism.
- Add native Windows ARM64 and x86-64 regression tests that use
RtlLookupFunctionEntry and RtlVirtualUnwind/RtlVirtualUnwind2 on captured contexts across prologues, bodies, nested calls and epilogues. Check the reconstructed caller PC, SP and relevant nonvolatile registers. Also inspect the linked binary's unwind coverage.
- Until correct Windows support is implemented, an explicit compile-time exclusion with a clear diagnostic is a conservative interim option.
Changes to the target/emitter and any new unwind model are trust changes and should be reviewed explicitly.
The generated assembly lacks the unwind descriptions needed to recover caller contexts on Windows ARM64 and Windows x86-64. The proofs establish normal execution and register restoration on return, but do not establish correct unwinding from an intermediate instruction.
This is an ABI / stack-unwinding correctness defect. I have not demonstrated an exploit, key disclosure, or attacker-triggered memory corruption. Native Windows validation remains outstanding.
Where the gap enters the TCB
Reviewed revision: 49f67820510f0508d7be78f2f01a678ee92ebb21, the head of #338, based on cf53f72. The affected emission and calling-convention behavior predates #338.
extern "C".extern "sysv64".abiPreservedchecks the entry/exit relationship. Recovering saved registers, SP and the return address at intermediate PCs is outside that predicate.Rust does not infer these descriptions from the instruction text: the Rust Reference's naked-assembly rules require explicit directives for unwinding, and rustc's naked-function lowering emits global assembly whose COFF prefix/suffix contains symbol and section directives, without SEH records.
ARM64: incorrect return PC and SP
Microsoft's ARM64 exception-handling specification, “.pdata records” requires records for stack-manipulating functions. Its leaf exemption is for functions requiring neither local storage nor nonvolatile-register saves. RtlVirtualUnwind2's FunctionEntry documentation specifies that a missing entry causes a leaf/no-frame fallback equivalent to a return.
For example,
vg_sha256_update's prologue pushesx30by 16 bytes and savesx19–x24in the scratch buffer. It then callsvg_sha256_compress.Immediately after that call returns,
x30points insidevg_sha256_update, and SP is still 16 bytes below its entry value. Treating this context as a leaf cannot recover its caller.I executed the generated SHA-256 init/update/finalize bodies in Unicorn 2.1.4 on the 64-byte message
bytes(range(64)). Normal execution restored the callee-saved registers and SP, and the digest matched Python'shashlib.sha256. At the instruction immediately following the compression call, the captured state was:0x10368c40x21ffff00x21000000x2200000The documented fallback would retain the wrong SP and select the internal address as the return PC. That fallback result is derived from Microsoft's specification; I did not execute the Windows unwinder.
Preserving
x29, as #338 does, does not describe how to restore the other registers or locate the saved return address.x86-64: incorrect nonvolatile-register context
Microsoft's x64 exception-handling specification, “struct RUNTIME_FUNCTION” and “Unwind procedure,” requires entries for functions that allocate stack space or call another function. With no entry, it reads RIP from
[RSP], increments RSP by eight, and continues.The immediate failure differs from ARM64: where an x86-64 body leaves RSP unchanged, the fallback can recover the return PC, but still fails to restore nonvolatile registers. For example,
vg_chacha20_blocksavesrbx,rbp, andr12–r15in scratch memory before repurposing them. A mid-body unwind cannot restore those values without descriptions of their locations. An incorrect RBP can also prevent correct unwinding of a caller that uses it as a frame base.extern "sysv64"changes the function's calling convention; it does not supply Windows unwind records. The x86-64 assessment is based on source inspection, not a native Windows reproducer.Additional validation and scope
.pdataor.xdata. This was assembly of the actual generated instruction bodies, not a full rustc crate build.Suggested fix
.seh_*directives /.pdataand.xdatafrom the same structured code and frame/save description used by the model. Account for saved nonvolatile registers, SP changes, return-address storage, and partially executed prologues/epilogues.RtlLookupFunctionEntryandRtlVirtualUnwind/RtlVirtualUnwind2on captured contexts across prologues, bodies, nested calls and epilogues. Check the reconstructed caller PC, SP and relevant nonvolatile registers. Also inspect the linked binary's unwind coverage.Changes to the target/emitter and any new unwind model are trust changes and should be reviewed explicitly.