Skip to content

[AUTOMATED] fix(p2): a Windows int 0x29 (__fastfail) ends the flow instead of unbalancing the stack - #417

Merged
mahaloz merged 2 commits into
mainfrom
feat/re-large-function-malformed-output
Sep 5, 2026
Merged

[AUTOMATED] fix(p2): a Windows int 0x29 (__fastfail) ends the flow instead of unbalancing the stack#417
mahaloz merged 2 commits into
mainfrom
feat/re-large-function-malformed-output

Conversation

@mahaloz

@mahaloz mahaloz commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

The problem

A Windows int 0x29 raises the stack pointer by eight bytes, which wrecks the
frame of every function containing one. Any MSVC-built PE reproduces it; here is
the smallest one, vendored in this PR:

$ kuna decompile decompiler/crates/kuna-analysis/tests/fixtures/fastfail_x86_64.exe 0x140001000 --addr
void sub_140001000(long long *a0)
{
  char v1 [8];
  char v2 [48];
  char *v3; // rsp
  ...
  if (!v4) {
    (*(void *)swi(0x29))(5);
    v3 = v2;
  }
  *(unsigned long long *)&v3[-8] = 0x14000103d;
  BCryptGenerateSymmetricKey(*(unsigned long long *)&v3[0x38],&v3[0x30],&v3[0x28],0x20);
}

v3 is RSP tracked as a variable, &v3[-8] = 0x14000103d is the call's own
return address stored through it, and the API call takes stack blobs where it
takes values. On a real 240 KB MSVC crackme (selam.exe, function 0x140002d50,
eleven int 0x29) that is 1,267 lines, 160 declarations and 63 of those stores.

x86 SLEIGH lifts INT imm8 to intloc = swi(imm8); call [intloc] — a call
with no matching push, unlike every other x86 CALL, which lifts as
RSP = RSP - 8; push &next; call target. Nothing downstream tells them apart, so
x86-64-win.cspec's __fastcall (extrapop="8" stackshift="8") hands eight
bytes back after it. Once two paths join carrying stack pointers eight apart, the
frame stops being a constant offset from the spacebase and everything above
follows.

The fix

  • New option fastfailnoreturn (default on, DIV-119). On Windows, int 0x29 is
    __fastfail — the MSVC /GS and STL _STL_VERIFY failure path, which
    terminates the process — so its call spec is marked no-return and flow.rs
    plants the halt it already plants for a named no-return callee. The block ends
    at the interrupt and the unbalanced stack pointer reaches no join.
  • Gated three ways: the Windows compiler spec (windows/clangwindows), the
    1-byte constant vector 0x29, and the CALLIND having to read the storage the
    swi CALLOTHER wrote in the same instruction. int 0x80 is a Linux syscall and
    int1/int3/into carry a return in their own semantics; none of them match.
  • Fixing the stack model instead — never apply extrapop to a call whose
    instruction did not push — is more general and needs no Windows gate, but it
    changes the stack solver for every architecture and int3 genuinely does
    return. That belongs in its own change.
  • No "Subroutine does not return" warning: the divergence is definitional, and
    one function can hold a dozen sites.

The tests

tests/stages/kuna-fastfailnoreturn.xml (3 assertions, two-pass: off is the
bug, default is the fix) and tests/cli/large-function-malformed-output.json
against the vendored PE32+ above; both fail without the change. On the witness
image 22 of 858 functions change, all of them shrinking, gotos 456 → 390, and no
named call is lost; across four further PEs the only non-improvement is
byte-identical output on the one with no int 0x29. make test 675/675 PARITY
OK (baseline not re-pinned), make test-stages 631/631, make rust-test green,
make check-spec OK, make test-cli 30/30, catalog OK. 20.5% faster on the
witness function (1,323.65 → 1,052.49 ms, 7 samples): cutting flow decodes less.

🤖 Generated with Claude Code

mahaloz and others added 2 commits September 5, 2026 21:18
…an INT 0x29 stack unbalance, not C++ type recovery

The need filed a 1,267-line MSVC checker with 165 anonymous variables, impossible
conditions, raw synthetic stack writes and mangled BCrypt calls, and blamed C++
small-string type recovery. Measured on the witness, it is three independent
defects and none of them is that.

x86 SLEIGH lowers `INT imm8` to `intloc = swi(imm8); call [intloc]` — a call with
no matching push — while x86-64-win.cspec's default __fastcall carries
extrapop=8/stackshift=8, so every INT grows RSP by 8. The witness has eleven
`INT 0x29` (__fastfail). Once two paths join 8 apart the frame stops being a
constant offset from the spacebase, and the CALL return-address pushes survive as
explicit stores.

Proven twice without engine code: `--assert 'flow <addr> callreturn'` on the 11
sites gives 1267 -> 1171 lines, 63 -> 0 synthetic stores, 159 -> 144 declarations;
byte-patching the 30 `MOV ECX,5 ; INT 0x29` sites to NOPs gives 1121 / 0. Across
all 24 affected functions in the image: 2802 -> 2583 lines, 85 -> 0, 558 -> 493.

The impossible condition does NOT fall out of that fix — it is faithful to the
binary and its real home is 21 unrecovered REP STOSB loops. And the acceptance's
`std::string` clause is not soundly satisfiable here: the function constructs no
std::string, and std::string has no RTTI. Replacement clauses are proposed and
verified in both directions.

No engine code; proposal only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…0x29 (__fastfail) ends the flow

x86 SLEIGH lifts `INT imm8` to `intloc = swi(imm8); call [intloc]` (ia.sinc:3671) —
a `call` with no matching push, unlike every other x86 `CALL`, which lifts as
`RSP = RSP - 8; push &next; call target`. Nothing downstream tells them apart, so
x86-64-win.cspec's default __fastcall (extrapop="8" stackshift="8") hands eight
bytes back after it and every interrupt raises the stack pointer by eight. Where
two paths join carrying stack pointers eight apart, the frame stops being a
constant offset from the spacebase: stack locals degenerate into offsets off a
`char *` and each CALL's return-address push survives as an explicit store.

On Windows `int 0x29` is __fastfail and never returns, so `option fastfailnoreturn`
(default on, DIV-117) marks the call spec no-return at the P2 CALLIND seam and
flow.rs plants the halt it already plants for a named no-return callee.

Measured on crackmes.one 6a3822888a86e4c2c55254fe selam.exe 0x140002d50 (eleven
int 0x29): 1267 -> 1147 lines, 63 -> 0 return-address stores, 160 -> 149
declarations, and BCryptGenerateSymmetricKey(*(unsigned long long *)&v66[0x50],..)
becomes BCryptGenerateSymmetricKey(v52,&v55,v62,v54). Over the whole image 22 of
858 functions change, all shrinking, gotos 456 -> 390, no named call lost.
Byte-identical on the 675-assertion datatest corpus and 20.5% faster on the
witness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mahaloz
mahaloz force-pushed the feat/re-large-function-malformed-output branch from 8d7577c to f3820e1 Compare September 5, 2026 21:29
@mahaloz mahaloz changed the title [AUTOMATED] [PROPOSAL] large-function-malformed-output: the noise is an INT 0x29 stack unbalance, not C++ type recovery [AUTOMATED] fix(p2): a Windows int 0x29 (__fastfail) ends the flow instead of unbalancing the stack Sep 5, 2026
@mahaloz
mahaloz marked this pull request as ready for review September 5, 2026 21:30
@mahaloz mahaloz added the full-ci Run the full cargo workspace suite on this PR before merge (internal PRs skip it by default) label Sep 5, 2026
@mahaloz
mahaloz merged commit bae88a0 into main Sep 5, 2026
10 of 11 checks passed
@mahaloz
mahaloz deleted the feat/re-large-function-malformed-output branch September 5, 2026 21:43
mahaloz added a commit that referenced this pull request Sep 5, 2026
The three needs that shipped in round 3 -- #417, #421, #422 -- had their
acceptance probes flip from FAIL to PASS on a freshly built main at
80e965c, but the record on disk still said `status: open`. Anyone reading
docs/re-needs/ (or any selector that reads it) would see three closed gaps
as dispatchable work.

This is the second time this edit has been applied: the first application
was made in the main working tree and never committed, and it was lost when
that tree was moved between branches. Committing it is the fix.

Statuses, `closed_in_round` and `closing_pr` come from
.kuna-repipe/rounds/3/acceptance-suite.json (sha 80e965c, 3 closed, 0
regressed) -- no verdict is re-derived here.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-ci Run the full cargo workspace suite on this PR before merge (internal PRs skip it by default)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant