Skip to content

[AUTOMATED] fix(p4): win32-process-enumeration-loses — a callee-body walk with no path terminator proves nothing - #444

Merged
mahaloz merged 1 commit into
mainfrom
feat/re-win32-process-enumeration-loses
Sep 6, 2026
Merged

[AUTOMATED] fix(p4): win32-process-enumeration-loses — a callee-body walk with no path terminator proves nothing#444
mahaloz merged 1 commit into
mainfrom
feat/re-win32-process-enumeration-loses

Conversation

@mahaloz

@mahaloz mahaloz commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The problem

calleedeadarg drops an argument when the callee's body proves the callee
overwrites that register before reading it. On a PE import the "callee body" is
the IAT slot, so the walk decodes a pointer as instructions — and when that
decode never reaches a path terminator, the proof it hands back is vacuous and
the argument disappears.

Build the fixture this PR adds and decompile its entry:

$ python3 decompiler/crates/kuna-analysis/tests/fixtures/calleedeadarg_noterm_x86_64.py
$ kuna decompile calleedeadarg_noterm_x86_64.exe 0x140001000 --addr

void sub_140001000(void)
{
  CreateToolhelp32Snapshot(2);
  if (!CONCAT44(dat_4,v1)) {
    Process32NextW();
    return;
  }
  if (!CloseHandle())
    return;
  abort(); // no-return
}

The snapshot handle is in RCX at both calls and in the disassembly, and
--option calleedeadarg off prints it. Same shape on the crackme this came
from (crackmes.one 640a526833c5d447bc761899, sub_1400015c0), where
Process32FirstW keeps both its arguments in the same run.

The fix

  • A callee-body walk that recorded no path terminator now proves nothing.
    The pass's test is "the register is written before every terminator", which
    is a conjunction — over an empty list it holds for every register at once.
  • The walk ends that way when every path closes back onto an address it has
    already visited: a body that is one endless loop, and an IAT slot whose first
    byte decodes to HLT, whose p-code branches to itself.
  • The guard is in proves_dead, not in the walk: the walk really did cover
    every path with nothing abandoned, so complete stays honest and only the
    claim built on it is withheld.

The tests

tests/cli/win32-process-enumeration-loses.json (promoted acceptance probe) plus
a unit test pinning both directions — no terminator proves nothing, one
terminator that wrote RCX still proves it dead. Gates: make test 675/675
PARITY OK, make test-stages 635/635 PARITY OK, make test-cli 40/40,
make rust-test green, make check-spec green, catalog OK. A pre/post
decompile-all sweep over 160 binaries and 15,048 functions (107 in-repo
fixtures plus 53 RE crackmes) moved exactly one: the witness itself.

🤖 Generated with Claude Code


Before / after — sub_1400015c0 in KeyCheker.exe

Real captured kuna output on the function this feature was built for, with option None flipped. Ported from kuna.

metric None off (default) None on
gotos 0 0
labels 0 0
loc 28 28
switches 0 0
kuna — before (option None off)
void sub_1400015c0(unsigned long long a0)
{
  int4 v1; // eax
  unsigned long long v2; // rax
  char v3 [32];
  unsigned int v4 [2]; // stack - 0x258
  char v5 [532];
  int4 v6; // stack - 0x250
  uint8 v7; // stack - 0x18
  
  v7 = dat_140008010 ^ (uint8)v3;
  v2 = CreateToolhelp32Snapshot(2);
  v4[0] = 0x238;
  v1 = Process32FirstW(v2,v4);
  do {
    if (!v1) {
      sub_140002f80(v7 ^ (uint8)v3); // return-dupe
      return;
    }
    if (wcsstr(v5,a0)) {
      CloseHandle(v2);
      if (v6)
        abort(); // no-return
      sub_140002f80(v7 ^ (uint8)v3);
      return;
    }
    v1 = Process32NextW(v2,v4);
  } while( true );
}
kuna — after (option None on)
void sub_1400015c0(unsigned long long a0)
{
  int4 v1; // eax
  unsigned long long v2; // rax
  char v3 [32];
  unsigned int v4 [2]; // stack - 0x258
  char v5 [532];
  int4 v6; // stack - 0x250
  uint8 v7; // stack - 0x18
  
  v7 = dat_140008010 ^ (uint8)v3;
  v2 = CreateToolhelp32Snapshot(2);
  v4[0] = 0x238;
  v1 = Process32FirstW(v2,v4);
  do {
    if (!v1) {
      sub_140002f80(v7 ^ (uint8)v3); // return-dupe
      return;
    }
    if (wcsstr(v5,a0)) {
      CloseHandle(v2);
      if (v6)
        abort(); // no-return
      sub_140002f80(v7 ^ (uint8)v3);
      return;
    }
    v1 = Process32NextW(v2,v4);
  } while( true );
}
kuna reference (the goal, v9.2.223)
typedef struct PROCESSENTRY32W {
    unsigned int dwSize;
    unsigned int cntUsage;
    unsigned int th32ProcessID;
    char padding_c[4];
    UIntPtr th32DefaultHeapID;
    unsigned int th32ModuleID;
    unsigned int cntThreads;
    unsigned int th32ParentProcessID;
    int pcPriClassBase;
    unsigned int dwFlags;
    Char szExeFile[260];
} PROCESSENTRY32W;

int sub_1400015c0(short *a0)
{
    HANDLE v2;  // rax
    HANDLE v3;  // rcx
    unsigned int v4;  // eax
    unsigned int v5;  // eax
    unsigned int v6;  // eax
    PROCESSENTRY32W v0;  // [bp-0x258]

    v2 = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    v0.dwSize = 568;
    if (!Process32FirstW(v2, &v0))
        return v6;
    while (true)
    {
        v3 = v2;
        if (wcsstr(v0.szExeFile, a0))
            break;
        if (!Process32NextW(v3, &v0))
            return v4;
    }
    CloseHandle(v3);
    if (!v0.th32ProcessID)
        return v5;
    abort(); /* do not return */
    __debugbreak();
}

…walk with no path terminator proves nothing

`calleedeadarg` deletes a call argument when the callee's own body is shown to
overwrite that register before reading it. The test is "the register is written
before EVERY path terminator", which over an empty terminator list holds
vacuously — for every register at once. A PE import lands there: its entry
address is the IAT slot, so the walk decodes a pointer as instructions, and a
slot starting `F4` is `hlt`, whose p-code branches to itself.

Reduced to a 2560-byte PE fixture built here byte by byte; the acceptance probe
of the need is promoted verbatim against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@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 6, 2026
@mahaloz
mahaloz merged commit c5f4073 into main Sep 6, 2026
10 of 11 checks passed
@mahaloz
mahaloz deleted the feat/re-win32-process-enumeration-loses branch September 6, 2026 04:41
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