native decompile - 1-3-7/disrobe GitHub Wiki

Native decompile

disrobe's x86-64 decompiler emits C or Rust. Its AArch64, ARM32, and MIPS32 paths emit pseudo-C. These in-tree paths require no external decompiler. Ghidra is available as a separate headless backend.

For symbol recovery, disassembly, and identification see the native guide; for packers and VM protectors see native unpacking.

At a glance

Surface Support
Architectures x86-64 and x86-32 use the shared IR pipeline; AArch64 uses object-context and NIR recovery; ARM32 (A32 and Thumb) and MIPS32 (big- and little-endian) use the p-code/NIR path
Output formats C or Rust for x86-64; C for x86-32; pseudo-C for AArch64, ARM32, and MIPS32
Call resolution Whole-program for validated direct same-image calls in linked AArch64 ELF inputs whose sibling target resolves unambiguously; unresolved, indirect, external, and ambiguous calls abstain. Relocatable AArch64 objects fail before output.
Switch dispatch Dense switch recovered from the binary's own jump table
Type recovery x86-64 structs from fixed-offset access (p->field_8), arrays from scaled indexing (a[i]), unions from conflicting widths, integer width and signedness per frame slot
API types x86-64 resolved imports propagated backward into caller locals from a curated libc, kernel32, and ws2_32 prototype database, each tagged with library!function provenance
Calling convention x86-64 inferred per function, including thiscall and vectorcall; x86-32 cdecl, stdcall, fastcall, and thiscall inferred per function
AArch64 scalar floating point h0 to h31, s0 to s31, and d0 to d31; IEEE binary16, binary32, and binary64 arithmetic, conversion, comparison, rounding, and loads or stores
Vectorized loops x86-64 SSE/AVX reduction and pointer-walk map kernels lowered back to the equivalent scalar loop
Constant division x86-64 magic-multiply division and modulo recovered as / and % in C output and as wrapping_div and wrapping_rem in Rust output; a divisor the range check cannot confirm keeps the multiply and shift
AArch64 devirtualizer Symbolic, on by default in a full build, --no-devirt to disable; transactional, reverts on any proof miss
Grading C output is execution-differentially recompiled with real gcc or clang; x86-64 Rust output with rustc
Sidecars manifest.json (schema disrobe.native.decompile/v1) for both architectures; x86-64 also emits types.json (schema disrobe.native.types/v1)
Optional backend Ghidra headless via --backend ghidra

Commands

disrobe native decompile app.exe --out decompiled/                 # x86-64 -> C, default backend
disrobe native decompile app.exe --format rust --out decompiled/   # x86-64 -> idiomatic Rust
disrobe native decompile app_arm64 --out decompiled/               # aarch64 -> pseudo-C, symbolic devirt on by default
disrobe native decompile app_arm64 --no-devirt --out decompiled/   # aarch64 without the symbolic devirtualizer
disrobe native decompile app.exe --backend ghidra --out decompiled/

Output lands at <out>/<stem>.c or <out>/<stem>.rs alongside a manifest.json (schema disrobe.native.decompile/v1) listing which functions recovered, which did not and why, and the emitted symbol name for each. x86-64 output also carries the types.json sidecar.

Coverage and fidelity

Call resolution and structure

--backend native (the default) uses the in-tree decompiler. For linked AArch64 ELF inputs, a validated direct same-image call resolves its callee's real name and integer arity only when the sibling target is unambiguous. Indirect, external, malformed, unsupported, and ambiguous calls abstain. Relocatable AArch64 objects fail before output because section-qualified function identity is not yet carried through the CLI. Dense switch dispatch is recovered from the binary's own jump table. A function with no validated outgoing calls uses per-function recovery. AArch64 uses object-context recovery first, with the NIR lift and image-backed recovery available as narrower fallbacks.

x86-64 Microsoft x64 calls and frames

Under the Microsoft x64 convention, arguments five and up travel in the caller's outgoing stack area, starting at [rsp+0x20] above the 32-byte home area. When a function stores to that area and never reads the slot back, each direct call in the function passes those slots as its fifth and later arguments, read from the recovered frame at the call. A call to a recovered sibling passes exactly the stack parameters the sibling reads, and a sibling that reads a slot this call site never stores is refused by name. A tail jump that leaves stack arguments behind is still refused, as is a function with more than eight outgoing stack slots. A frameless leaf that reads its own incoming stack parameters or its home area recovers those reads as parameters.

Frames built by pushes recover when the prologue is a run of home-area saves, register pushes and at most one sub rsp, every epilogue is add rsp followed by pops in the reverse order of the pushes, and no other instruction moves the stack pointer. The stack depth is tracked across every path, so a home-area save made before the pushes and the reload made after them name the same frame slot, and incoming stack parameters are found past the pushed registers. A direct jump to another function made after the epilogue, at the entry stack depth, recovers as a tail call when the target is proven to return an integer from its register arguments alone. In a linked image, a conditional jump to such a target recovers as a guarded tail call. A conflicting depth at a join, a push or pop inside the body, or a frame pointer leaves the function on the existing frame-pointer and single-allocation paths.

Calls through memory recover in linked images. A call through an import-table slot reads the slot at the call and calls the value, labelled with the import name (/* import MulDiv */); a slot whose import is a known no-return function, such as exit, ends the path. A call through a function-pointer global or a register calls the current value the same way. An import found in the curated prototype database with at most four integer or pointer parameters passes exactly those registers, so free(p) passes rcx alone. Any other pointer call passes the four argument registers and the outgoing stack slots, as an unresolved direct call does. A Control Flow Guard dispatch slot from the load configuration calls the target in rax. A function that also touches xmm0 to xmm3 (or xmm0 to xmm7 under System V) refuses such a call by name, because a floating-point argument would be lost. A tail jump through an import-table slot is still refused. Rust output makes the same call through an Option<unsafe extern "C" fn> read from the slot or register, and a null pointer aborts.

When the Rust emitter cannot express the C control flow with loops, labelled blocks and a resume loop, for example a goto into a sibling branch, it emits the function as a dispatch loop: each basic block is one arm of a match on a state variable and keeps the C semantics of each branch, loop and switch. Rust output is refused for a struct returned through a hidden pointer, a rep movs or rep stos block move or fill, SIMD vector values, and loop-resume transfers; DISROBE_DEBUG=native names the reason.

In a linked executable or DLL, integer instructions with a RIP-relative memory operand (mov, add, cmp, test, movzx and the other integer forms) read and write the global at its absolute virtual address, written as a hexadecimal constant at the preferred image base. The recovered C behaves like the original when the image is loaded at that base. Relocatable objects keep refusing these operands, since their addresses are only known through relocations. A test of a memory operand against an immediate or a register, and a test of two different registers, sets the same zero and sign conditions as test of one register.

A function that passes the address of one of its own stack locals to a callee, such as fill_pair(&p, value) or a local array, recovers when the address lies inside the bytes the function allocates. The recovered frame then backs the whole allocation, so the bytes the callee writes are the bytes the recovered code reads afterwards. The address may be copied between registers, used as the base of loads and stores, and passed as a call argument, and it must be dead by the next branch or return. An address that is returned, stored as a value, used in arithmetic or kept live across a branch is refused. An address in the home area or among the incoming arguments is refused too, because a va_list walk from it reaches the caller's frame. A 16-byte or 32-byte vector store of a register zeroed by xorps, xorpd, pxor or their VEX forms recovers as a zero store to each 8-byte slot it covers.

The MSVC /GS cookie is recognised from the load-config SecurityCookie address when the cookie is saved in a stack or frame-pointer slot before any branch and every reload of that slot is a check: mov rcx,[slot], xor rcx,rsp, and a call to the checker. Each check on each exit path is dropped as plumbing. A saved cookie slot that is read anywhere else keeps the cookie load, which then refuses as a global read.

x86-32 calls, frames, and conventions

32-bit x86 functions in linked PE and ELF images recover through the same pipeline as x86-64. Each function's calling convention is read from its own code. A function that reads ecx and edx before writing them is fastcall, one that reads only ecx and pops stack arguments is thiscall, one that pops its arguments with ret N and takes no register argument is stdcall with N/4 stack arguments, and the rest are cdecl with the stack slots up to the highest one they read. The recovered C uses uint32_t parameters and declares the convention with __attribute__((stdcall)), fastcall, or thiscall, so it has the original's ABI. A function that writes edx on every path to its returns returns uint64_t built from edx:eax; the others return uint32_t.

The stack depth is tracked on every path from the entry. Pushes and pops anywhere in the body, sub and add esp, ebp frames (mov ebp, esp, leave, mov esp, ebp, lea esp, [ebp-N]), and an and esp realignment under an ebp frame are modelled, and every esp- and ebp-relative access is rebased to the entry stack pointer, so argument pushes, a pre-allocated outgoing area, and locals share one frame. Saves of ebx, esi, edi, and ebp are dropped when a pop, leave, or load restores them. A direct call to a function in the image passes the stack slots at the call. The callee's own ret N gives the bytes it pops, and the registers it writes decide which caller-saved registers survive the call and whether it returns edx:eax. add/adc and sub/sbb pairs, the one-operand mul and imul into edx:eax, and shld/shrd by a constant recover exactly.

Position-independent code is resolved. call $+5; pop reg and calls to __x86.get_pc_thunk.* load the return address as a constant, and a load or store whose base register holds that constant after the add of the GOT offset is written as an absolute address at the image base, for GOTOFF accesses and GOT slots alike. The recovered C behaves like the original when the image is loaded at that base.

Refused by name: x87 and MMX instructions (a float or double result returns in st(0)), a register argument other than ecx and edx (regparm, Borland register), calls and jumps through memory or a register (import-table calls, function pointers, switch tables), jumps out of the function (tail calls), recursive calls, callees whose code is not in the image, a stack depth that differs at a join, a realignment without a frame pointer, double shifts by cl, fs and gs accesses, C++ exception tables, and relocatable objects. Rust output is not produced for x86-32.

AArch64 scalar floating point

The AArch64 lifter recognizes all 32 scalar floating-point registers through their h, s, and d views. Half-precision values use IEEE binary16 bits in the shared register model. The C emitter uses _Float16; the internal Rust emitter uses u16 at function boundaries because stable Rust does not provide an f16 primitive, then converts those bits through bounded binary16 helpers. Arithmetic, fused multiply-add, minimum and maximum, square root, rounding, integer conversion, precision conversion, comparison, selection, base-register scalar loads, scalar stores, and image-backed s and d literal-pool loads use the declared operand width. Literal-pool recovery reads only the encoded width from a mapped image range and refuses missing or truncated data. The scalar path models the default IEEE floating-point environment. A function that reads or writes FPCR refuses recovery because its rounding, flush-to-zero, default-NaN, or alternative-half controls can change scalar results. Ambient FPCR state established outside the function cannot be inferred, so output that depends on an externally selected nondefault state is not claimed as bit-exact. q and v operands remain vector operations and do not enter the scalar path.

x86-64 type recovery

Types are inferred from the access shape rather than left as raw registers. A pointer walked at several fixed offsets recovers as a struct with named fields (p->field_8), a base indexed by a scaled register recovers as an array (a[i]), and offsets read at conflicting widths recover as a union. The calling convention is inferred per function, including x86 thiscall (implicit this in ecx) and vectorcall (SSE/AVX register arguments), so the recovered signature matches how the function is actually called.

Alongside the source, native decompile writes a types.json sidecar (schema disrobe.native.types/v1) recording the recovered integer width and signedness of each frame slot. The disrobe-typerec crate reads those signals straight from instruction semantics, subregister access, movsx/movzx, div/idiv, sar/shr, and signed against unsigned compares, and resolves them over a lattice with union-find. A frame slot the compiler reused for two variables is split back into distinct objects by a region-typed memory-SSA and live-range pass, so a reused slot recovers as two types instead of one blurred type that loses the signedness of both, and the same crate grades the struct, array, and union shapes the decompiler recovers from those access paths.

When a recovered call reaches a known library or OS function, resolved through the PE import table or ELF relocations to a curated libc, kernel32, and ws2_32 prototype database, that function's parameter and return types are propagated backward into the caller's locals through the same region memory-SSA and written into types.json as api_slots, each carrying its library!function provenance so an API-derived type is distinguishable from an inferred one; an unresolved import, an ordinal-only import, or a call whose backpropagated type conflicts abstains rather than guessing, and those API-derived caller-local types are graded on a stripped-versus-unstripped clang corpus against the unstripped DWARF, recovering pointer, integer-width, and sign with zero wrong types.

Graded against an unstripped sibling's DWARF on an -O0 corpus, integer width and struct field offset and per-field width recover at recall 1.0, live-range splitting lifts signedness recall from 0.25 to 1.0 on the slot-reuse cases, and mutation checks confirm the grader rejects seeded-wrong widths, signs, field offsets, and merged or invented fields instead of passing everything.

AArch64 symbolic devirtualizer

On the AArch64 path a symbolic devirtualizer runs before structuring, on by default in a full build and disabled with --no-devirt. It folds conditional arms it can prove dead against the path constraints, then hands the simplified function to the structurer. The fold is transactional: on any proof miss or budget exhaustion it reverts to the original function, so it replaces a construct only with a proven-equivalent one and adds no edges. Per-function fold counts and status land in the decompile manifest.json under devirt.

Auto-vectorized loops

Auto-vectorized loops are recovered to their scalar meaning: the C backend recognizes SSE/AVX reduction and pointer-walk map kernels that gcc and clang emit at -O2/-O3 and lowers them back to the equivalent scalar loop, tracing each argument to its pristine ABI register so a compiler's entry-sequence register swap does not misattribute the length to the output pointer. Reassociation-unsafe floating-point vector loops are rejected rather than lowered to a wrong scalar form.

x86-64 constant division and modulo

A compiler replaces a division by a constant with a multiply by a magic number, a shift, and a sign correction. The x86-64 path recognizes that sequence and emits the division again. C output uses / and %. Rust output uses wrapping_div and wrapping_rem. The recognized forms are a magic multiply whose product fits a single register, a wide multiply through the one-operand mul or imul, the add-form that carries an implicit high bit, the pre-shift form used for an even divisor, and the signed corrections taken from the sign of the dividend, of the product, or of the quotient. A dividend narrower than 64 bits must be zero-extended into an unsigned form or sign-extended into a signed form. An extension that does not match the form refuses the rewrite.

The divisor is not read out of the magic number. A candidate divisor is accepted only when the multiply and shift reproduce the exact quotient for every dividend in range. A multiply-shift pair that computes a fixed-point scale fails that check and stays a multiply and shift, so a scale is never renamed as a division. A perturbed shift amount, a perturbed multiplier, and a 32-bit magic applied to a 64-bit dividend fail the same range check.

The modulo is recovered from the tail that follows the quotient. Inside a bounded window after the matched sequence, the recovery tracks copies, additions, subtractions, multiplication by a constant, left shifts, and lea with a scale, then emits % for the register that ends up holding dividend - quotient * divisor. When the quotient is still live after that tail, the division and the modulo both emit.

The rewrite replaces straight-line code only. A back edge that targets the first instruction of the matched sequence, or any address before it, refuses the rewrite. A division inside a loop therefore keeps its multiply-and-shift form whenever the loop jumps back to or above the start of the matched sequence. Any other control transfer that lands inside the sequence refuses it as well. A store or a read-modify-write inside the sequence refuses it. Every register the sequence writes, other than the quotient and the copies of the dividend that outlive it, must be dead afterward.

The one-operand imul lifts as a signed wide multiply, so a signed 64-by-64 high-half product emits as __int128 in C and as i128 with wrapping_mul in Rust instead of taking the unsigned form.

Set DISROBE_DEBUG=native to print the address and the recovered divisor of each rewritten sequence.

Grading

C output is graded by execution-differential recompilation against real gcc or clang, and x86-64 Rust output against rustc, never against disrobe's own prior output. The MSVC /O2 fixtures that grade C output also grade Rust output: rustc builds the recovered Rust, which is called beside the original in the loaded DLL over 1000 seeded inputs with a null control and a mutation control (msvc_rust_behaviour.rs). Hand-written goto shapes at -O0 through -Os grade the dispatch loop (pseudo_rust_wholeprog_oracle.rs). The AArch64 pseudo-C lift is held to the C bar against real clang machine code at -O0, -O1, -O2, -O3, and -Os. Its scalar floating-point battery compares raw result bits for register and stack parameters across directed IEEE edge patterns and seeded random inputs. It canonicalizes a comparison only when both results are NaNs, and reports how many comparisons passed on that canonicalization. The struct, array, and union recovery is asserted on recompiled-and-executed fixtures rather than by inspection. The vectorized-loop recovery is held to the same C bar: the recovered scalar loop is recompiled and its output compared bit-for-bit against the original compiled kernel across a spread of input lengths, and on Linux at least one gcc -O3 pointer-walk reduction must recover and execute-prove (simd_devirt_oracle.rs).

Constant-division recovery is held to the same C bar. It is measured over divisors 1 through 1024 plus sampled values up to 4294967295, at -O1, -O2, and -Os, for 32-bit and 64-bit dividends, with whichever of gcc, clang, and cc is on PATH, and a recovered divisor that differs from the one in the source is a failure. Recovered functions are recompiled and executed against the compiled original over a fixed spread of inputs, and that execution grading covers the loop bodies and the signed 128-bit high-half products as well as the plain division and modulo cases. Whole-program recovery is graded on its own, so the rewrite is proven reachable through recover_program and not only through a single leaf function (pseudo_c_const_division_oracle.rs).

Microsoft x64 calls and frames are graded on self-authored MSVC /O2 DLLs in corpus/native/decompile/. The recovered C of each graded function is compiled with clang and called beside the original function inside the loaded DLL on 1,000 seeded inputs. An original-against-original run of the same harness is the null control, and a mutation of each recovered body, such as dropping the stack arguments from a call or storing a home-area save at its entry displacement, must turn the grade red (msvc_decompile_behaviour.rs).

x86-32 recovery is graded on self-authored C built with clang for PE and for ELF with -fPIC, gcc -m32, and MSVC x86 at three optimisation levels, an MSVC C++ class with thiscall members, and a hand-written gcc-style PIC shared object (corpus/native/decompile32/). The recovered C of each function is compiled with clang for i686 and called beside the original in a 32-bit process, with the DLL loaded at its preferred base and the ELF image mapped at its link address, on 1,000 generated inputs, comparing the result and every writable byte of the image and of a pointer arena. An original-against-original run is the null control, and adding one to the first returned value or stored field must turn the grade red (x86_32_decompile_behaviour.rs).

Ghidra backend

--backend ghidra runs Ghidra headlessly (install it with disrobe install-deps ghidra) and returns pseudo-C alongside the standardized emits.

Limits

  • AArch64 function discovery is symbol-table-based today (the linear-sweep function finder is x86-only), so a stripped AArch64 binary surfaces fewer functions than its unstripped sibling, which enumerates and decompiles in full.
  • On the AArch64 path, control-flow-flattening deflatten and jump-table edge rewrite are noted as deferred in the devirt manifest section.
  • AArch64 Rust emission is unsupported. --format rust fails before native recovery writes output.
  • On x86-64, where the byte stream carries no sign signal for a frame slot, the types.json sidecar reports it as unknown rather than guessing.
  • An unresolved import, an ordinal-only import, or a call whose backpropagated type conflicts abstains rather than guessing an API-derived type.
  • Reassociation-unsafe floating-point vector loops are rejected rather than lowered to a wrong scalar form.
  • Constant-division recovery is x86-64 only. The AArch64 path does not rewrite a magic multiply back into a division.
  • Constant-division recovery starts from a magic multiply, so a divisor the compiler lowered to a plain shift stays a shift in the output.
  • A magic multiply-shift sequence whose divisor the range check cannot confirm, including a fixed-point scale, is left as a multiply and a shift rather than rewritten.
  • A constant division is left alone when a back edge targets the first instruction of the matched sequence or an earlier address, when another control transfer lands inside the sequence, or when the sequence writes to memory.
  • x86-32 calls through the import table, tail calls, recursive calls, switch tables, and x87 floating point are refused.
  • --backend ghidra runs the optional external decompiler on the input.
⚠️ **GitHub.com Fallback** ⚠️