Deterministic static recovery with disrobe

disrobe

Decompile, deobfuscate, and unpack compiled software through one evidence-tracked command line.

disrobe is a Rust command-line suite for static software recovery. It handles source obfuscation, bytecode, frozen applications, managed assemblies, native binaries, packers, archives, firmware, and compiled webview frontends. The default path never executes the sample. One opt-in PyArmor v6/v7 fallback does execute it and requires --allow-dynamic; use that path only inside an isolated sandbox.

disrobe demo

Try it in your browser: the disrobe playground. Decompile a .pyc, scan a pickle for malicious reduce callables, and summarize a .wasm module, all client-side, with the core passes compiled to WebAssembly. Nothing is uploaded.

The live catalog spans 15 ecosystems: Python, JavaScript and TypeScript, WebAssembly, JVM and Android, .NET, native PE/ELF/Mach-O/NE, Go, Lua, PHP, Ruby, Erlang and Elixir on BEAM, Swift and Objective-C, ActionScript 3, mobile runtimes, and shell languages. The implemented native-packer tier currently lists Donut, sRDI, UPX, ASPack, Petite, MPRESS, FSG, PECompact, Yoda's Crypter, NSPack, MEW, kkrunchy. Run disrobe catalog [ecosystem] for the per-family recovery tier compiled into your binary.

Guarantees and boundaries

  • No model runs in the recovery path. Metadata bundles are deterministic structured data for downstream tools.
  • Output ordering and serialization are deterministic. A committed gate hashes three real fixture recoveries across Linux, macOS, Windows, and the batch runner at one and four workers. This evidence does not claim that three fixtures prove every possible input.
  • The main CLI ships as one Rust binary. In-house paths launch no external program. Commands with optional backends can invoke installed tools when you select a backend or use that command's --backend auto policy.
  • The shared artifact layer can store recovered state in a content-addressed .dr envelope with an rkyv payload, postcard sidecar, and BLAKE3 root. Chain runs record topology and per-stage provenance separately.
  • Python recovery is measured at 95.09% per-code-object equivalence on the full CPython 3.14 stdlib (17378 of 18276), plus 96.53% on the pinned 200-module corpus (6068 of 6286). These figures describe their stated populations, not every Python program.

Who this is for

  • Malware analysts and incident responders who receive a packed, frozen, or obfuscated sample and need to read what it does, without executing it.
  • Security researchers auditing a closed binary for interoperability or vulnerability research.
  • Developers recovering their own lost source from a shipped .pyc, .jar, .dll, or bundled .js.
  • Tooling authors who need the Rust crates, typed Python bindings, daemon protocols, metadata bundles, or browser playground.

Choose the reachable surface

disrobe auto can run only the pass IDs registered in that build. disrobe passes prints those IDs, their ecosystem, and their support tier. Direct commands expose additional operations that are not auto-chain passes, including recon, taint analysis, optional external decompilers, and disrobe webview for Electron, Tauri, and Wails frontend recovery. Use disrobe --help for direct commands and disrobe catalog [ecosystem] for recognized families.

In-house recovery remains available without optional toolchains. JVM, Android, .NET, and native commands can also use installed tools such as CFR, Vineflower, jadx, ILSpy, de4dot, or Ghidra where their command-specific backend policy allows it. disrobe doctor reports what is installed; it does not make an unavailable backend part of an in-house result.

Measured recovery

Every figure below comes from a committed test gate or a local measurement harness. strong figures are graded against an independent oracle; coverage-self-reported figures state the inspected population and count disrobe's own output. The full per-value sourcing lives in xtask/data/recovery.json.

Measured recovery by ecosystem

Colour and tag together state how each bar was checked. A lighter bar means a stronger reference could have rejected the number, a filled mark means a committed gate reproduces it on every run, and a hollow mark means the input stays outside the tree.

EcosystemMeasuredOracle
Python bytecode95.09% per-code-object equivalence on the full CPython 3.14 stdlib (17378 of 18276); 96.53% on the pinned 200-module corpus (6068 of 6286). Whole-module exact, where a module counts only if all of its code objects pass: 123 of 200 modules on the pinned corpusrecompile on CPython 3.14.5, opcode diff
CPython legacy 1.0-3.7150 of 191 proven-correct (CI floor); 166 of 191 measured locallyrecompile-equivalence or structural token-match
WebAssembly1034 of 1034 opcodes lowered across the 38 parseable modules (133 of 133 functions), counted against an inventory wasm-tools produced rather than one disrobe produced; 57 of 57 execution-eligible functions equivalentexternal opcode inventory for coverage, wasmtime differential for execution
JVM classfile131 of 131 methods recompile error-freereal javac
Android (Dalvik)100% of the verifier-presented classes in the committed dex corpus pass the JVM verifier (118 of 118). A further 37 of 155 classes are link-skipped and never reach the verifier, because they reference supertypes the harness does not bundle, so those are ungraded rather than passing-Xverify:all over assembled jar
Ruby YARVgreeter 100%, megafile 98.67% opcode-multiset equivalencerecompile on MRI
PyArmor72 / 72 manifest-named v8/v9 default-trial wrappers decrypt and decode one complete header-anchored root CodeObjectself-reported structural check; no source, emitted .pyc, semantic, execution, or external comparison
Containers102 formats detected, including a bounded raw-volume-key LUKS1 route; 41 generic routes are driven to member bytes by a committed inputextraction over the committed corpus, pinned per format; tracked LUKS1 plaintext comparison

The numbers that are not perfect are labeled SEMANTIC, PARTIAL, or SKELETON. Native-virtualized code, runtime-only keys, and RSA-wrapped capsule keys remain detect-only when the required information is absent from the input.

Refusal is a result

disrobe refuses to emit a recovery it cannot justify from the input. A refusal names its reason and is a normal, expected outcome, not a failure of the run.

The rule behind it: a wrong recovery costs more than no recovery. A reader who receives output assumes it describes the input, so output that merely looks plausible is worse than a refusal that says what was missing. Two consequences follow, and both are deliberate.

Evidence that is only probable does not become a result. Where several readings of the same bytes are equally consistent with the input, disrobe reports the ambiguity instead of choosing the likeliest one. A native function compiled to a single return instruction, for example, cannot be distinguished from several different source signatures on its own bytes, so it is refused unless a caller in the same object proves which one applies.

A refusal is scoped to the evidence, not to the problem. "Not recoverable from this input" is a claim about the bytes supplied; a wider input set can reopen it. A key that exists solely at run time is reported as a static-recovery limit.

Where to start

Installation

disrobe is distributed two ways: prebuilt binaries from the GitHub Releases tab, and build from source with a single Rust toolchain. There is intentionally no PyPI/npm/Homebrew/crates.io/Docker channel for the binary itself; GitHub Releases is the canonical distribution point.

Each tagged release attaches prebuilt, statically-linkable binaries for the common targets, alongside SHA256SUMS, a cosign keyless signature bundle per archive, a GitHub build-provenance attestation, and a CycloneDX SBOM. See Security for the full verification story.

OSArchitectures
Windows 10/11x86-64, ARM64
Linux (glibc + musl)x86-64, ARM64
macOS 13+x86-64, ARM64 (Apple Silicon)
  1. Download the archive for your platform from the Releases page.

  2. Verify the checksum:

    sha256sum -c SHA256SUMS        # Linux / macOS
    
  3. (Optional) verify the cosign signature against the Sigstore transparency log:

    cosign verify-blob \
      --certificate-identity-regexp '^https://github.com/1-3-7/disrobe/' \
      --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
      --bundle    disrobe-<version>-<target>.tar.zst.cosign.bundle \
      disrobe-<version>-<target>.tar.zst
    
  4. Extract and place disrobe (disrobe.exe on Windows) anywhere on your PATH.

Build from source

Building requires Rust 1.95 or newer (stable). That is the only build dependency for the core; the optional external backends are fetched separately (see below).

git clone https://github.com/1-3-7/disrobe
cd disrobe
cargo build --release
./target/release/disrobe --version

A release build takes roughly four to six minutes on commodity hardware. The binary lands at target/release/disrobe; copy it onto your PATH.

Per-OS notes

  • Windows: the binary is disrobe.exe.
  • Linux: the musl build is fully static; the glibc build needs a matching glibc.
  • macOS: x86-64 and ARM64 (Apple silicon) archives are published separately. Gatekeeper may quarantine an unsigned download; clear it with xattr -d com.apple.quarantine disrobe.

The dependency boundary

Building and running disrobe are one dependency set. Grading and reproducing its published numbers are two others, and the three never blur together.

CategoryWhat is in itWhat breaks without it
Core (build and run)Rust 1.95+ stable, nothing elseNothing; cargo build --release produces the full binary
Optional backendGhidra, CFR, jadx, ILSpy, de4dot, and others, selected with --backend <tool>One feature: that pass falls back to the in-house default, which still runs
Grading onlyCPython, javac, the real JVM verifier, wasmtime, lua/luac, MRI, the .NET SDK, and the Go toolchainOne grade: the recovery is unaffected, but that ecosystem's number cannot be regraded locally
Benchmark repro onlyThe pinned competing tools in evidence/competitors/One number: the head-to-head row cannot be reproduced; disrobe's own recovery is unaffected

The per-ecosystem list for the last two rows is in evidence/README.md.

Slim build

cargo build --release produces the full everything-binary: every language and format pass compiled in. For a smaller artifact, opt into a slim build that keeps the always-on core (Python bytecode, native PE / ELF / Mach-O, and the container and format layer) and drops the optional passes:

cargo build -p disrobe-cli --release --no-default-features
# same build, shorter
cargo build-slim

Slim drops the optional language and format passes (JavaScript / TypeScript, WebAssembly, JVM / Android, .NET, Go, Lua, PHP, Ruby, BEAM, Swift, AS3, and more) and the multi-stage auto chain. It also drops every network and asynchronous surface: the HTTP server, the gRPC endpoint, the MCP companion, the prowl harvester, and the HTTP client that install-deps uses to download a third-party tool. A slim binary links none of tokio, axum, tonic, tower-http, tokio-util, tokio-stream, reqwest, hyper, rustls, rmcp or keyring, which a committed CI gate enforces by reading the dependency tree, so the binary cannot open a socket at all. Dropping the passes also drops large dependency trees such as the embedded JavaScript engine and the WebAssembly toolchain. On a Windows release build that trims the binary from 112.8 MB to 44.1 MB, about 61 percent smaller. Both figures are decimal megabytes measured on this repository's release profile, which strips symbols and links with fat LTO; the exact figure varies by platform and toolchain. A slim --help also lists 42 subcommands rather than 67, because a pass that is not compiled in is not declared. The wasm subcommand is one of the surfaces that does stay declared in a slim binary. If you run it, it reports why the pass is missing:

$ disrobe wasm decompile app.wasm
Error: the `wasm` pass is not compiled into this binary (slim build); rebuild with default features (feature `wasm`)

Layer specific passes back onto a slim base with --features, for example --no-default-features --features wasm,jvm.

Verifying the install

disrobe --version          # print the version
disrobe passes             # direct families plus auto-chain pass IDs
disrobe --help             # full subcommand surface
disrobe <pass> --help      # drill into any pass, e.g. `disrobe py --help`

Optional external backends

disrobe's in-house passes run with zero external dependencies. A subset of capabilities, however, wrap mature external tools headlessly: Ghidra for native decompilation; CFR / Vineflower / Procyon / jadx for the JVM and Android; ILSpy / dnSpy / de4dot for .NET; Rizin and friends elsewhere. These are never the product for bytecode languages (disrobe ships its own in-house decompilers there) and are always optional.

Probe what is installed and what is missing:

disrobe doctor                 # probe 46 to 51 external tools depending on the platform
disrobe doctor --auto-install  # install every missing tool that has a known install action

A missing tool with no install action (a commercial tool, a tool that ships bundled with another tool, a platform-exclusive tool, or a tool preinstalled by the operating system) is not silently dropped. --auto-install records it as a skip with a typed reason, in both text and --json output.

Install a single tool through your platform's native package manager (winget / brew / apt / dnf / pacman / apk). disrobe never installs itself this way; it only fetches the optional backends:

disrobe install --list         # list every known tool + per-platform package name
disrobe install ghidra
disrobe install upx

Heavyweight dependencies that ship as upstream release archives rather than OS packages (Ghidra, for instance) have a dedicated installer:

disrobe install-deps ghidra
disrobe install-deps --all

Shell completions and man pages

disrobe completions bash --install        # also: zsh, fish, powershell, elvish
disrobe man --out ./man                   # one .1 page per subcommand

Quickstart

The fastest path is disrobe auto: hand it a file and it fingerprints the input, then chains the right passes end to end.

Auto-detect and chain

disrobe auto suspect.exe --out recovered/ --capture-stages
disrobe context --out recovered/
disrobe catalog native

context summarizes the passes that ran, their confidence tiers, the final verdict, and provenance. It does not replace recovery.json; use that file when another tool needs the complete structured report.

disrobe auto understands chains such as:

  • PE -> UPX -> rust-demangle -> symbol recovery
  • PyInstaller -> PyArmor -> .pyc decompile
  • APK -> DEX -> Java + manifest
  • Electron .asar -> unbundle -> source

Use --capture-stages to mirror the exact bytes written by each executed pass under <out>/NN-<pass>/ and link the terminal stage or stages under <out>/final/. These are exact stage records; a decompiler's source output is not a byte-identical copy of the compiled input. Cap the chain depth with --max-depth (default 8).

Per-language one-liners

These commands cover the main direct workflows. Run disrobe <command> --help before relying on a backend or emit that is not shown here.

# Python
disrobe py decompile module.pyc --out recovered/
disrobe py disasm module.pyc --out trace.txt
disrobe py deob obfuscated.py --out clean.py --cleanup
disrobe pyinstaller extract onefile.exe --out out/
disrobe pyarmor unpack protected.py --out out/             # v8/v9 stay static by default
disrobe nuitka extract app.exe --out out/

# JavaScript / TypeScript / WebAssembly
disrobe js deob bundle.min.js --out clean.js
disrobe js unbundle app.bundle.js --out src/
disrobe wasm decompile module.wasm --target rust --out lifted.rs
disrobe webview desktop.exe --out frontend/

# JVM / Android / .NET
disrobe jvm decompile app.apk --backend jadx --out src/
disrobe dotnet decompile App.dll --backend ilspy --out src/

# Native
disrobe native unpack packed.exe --out unpacked.bin
disrobe native symbols app.exe --out symbols.json
disrobe go recover app --out symbols.json

# Recon and catalog
disrobe frisk recovered/ --format json > frisk.json
disrobe prowl example.com --subs --sources wayback,urlscan,crtsh --format json > prowl.json
disrobe indicators frisk.json prowl.json --targets-only > targets.txt
disrobe catalog python

# Mobile / Lua / others
disrobe hermes decompile index.android.bundle --out surface/
disrobe flutter dump libapp.so --out layout.json
disrobe lua decompile script.luac --out script.lua
disrobe ruby decompile app.rb
disrobe php decode payload.php --out out/payload-php/
disrobe beam parse module.beam

PyArmor v6/v7 may require the dynamic-hook fallback. That path executes the sample and is disabled unless you add --allow-dynamic. Use it only inside an isolated sandbox with no network or sensitive mounts. PyArmor v8/v9 and --allow-bcc remain static.

Structured output

The global --json, --ndjson, and --sarif flags select machine-readable output where a command supports those formats. For example, scan can emit SARIF 2.1.0 for GitHub code scanning:

disrobe scan firmware.bin --sarif > findings.sarif

Inspecting a run

After any chain or pass, inspect what landed:

disrobe status                    # per-stage artifact counts, sizes, manifests in ./out/
disrobe context --out recovered/  # per-pass status, confidence tiers, verdict, provenance
disrobe envelope inspect out/final/module.dr
disrobe verify out/final/module.dr

Generating a metadata sidecar

Commands that implement metadata bundles accept --metadata-pack-1 through --metadata-pack-4. --llm is a compatibility alias for pack 4; it does not run a model.

disrobe py decompile module.pyc --out recovered/ --metadata-pack-4 --llm-briefs

See metadata sidecar and provenance for the full category and pack model.

Reading a result

A run that recovers everything needs no interpretation. This page is for the other case: the chain stopped short, a body came back thin, or a pass declined to answer. Each section below is a symptom you can see in the output, what that symptom means, and the command to run next.

Two things before the symptoms. disrobe does not emit a recovery it cannot justify from the input, so a stated refusal is a result rather than a failed run; the reasoning is in refusal is a result. And "did not recover" and "cannot be recovered" are different claims. The output distinguishes them, and most of the work of reading a result is telling which one you have.

What a run leaves behind

disrobe auto and disrobe chain write three documents into the output directory.

FileContents
chain.jsonThe executed topology, schema disrobe.chain/v1. One node per stage with its pass id, input and output BLAKE3, sizes, the detector pick that selected it, a per-stage verdict, optional registered string metadata, and an error string when a stage failed.
recovery.jsonThe per-run report, schema disrobe.recovery/v1. Each stage's status and confidence tier, a tier histogram, and timings.
anti-analysis.jsonAnti-analysis techniques observed across the run. See anti-analysis defeat.

Read recovery.json through disrobe context instead of by hand:

disrobe auto sample.bin --out ./out/sample --capture-stages
disrobe context --out ./out/sample

That prints the input identity, the overall chain verdict, the tier histogram, and one row per stage:

disrobe context  (./out/sample)
  schema:    disrobe.recovery/v1
  input:     sample.bin (257 bytes, blake3 b6d7bc82...)
  verdict:   Complete
  total_ms:  187
  tiers:     exact=0 semantic=1 partial=0 skeleton=0 (total 1)
  passes:
    py.decompile                 recovered  semantic  0ms

disrobe report ./out/sample renders the same run as text, markdown, JSON, or a self-contained HTML page, and adds a recovery score, the artifact inventory, and the detect-only and skeleton caveats. See run reports.

Pass --capture-stages on any run you expect to debug. It mirrors the exact bytes written by each stage to ./out/sample/NN-<pass>/output.bin and links the terminal stages under ./out/sample/final/, which lets you re-enter the pipeline at the stage that stopped. Exact capture does not mean a decompiler's source output is byte-identical to the compiled input. Without capture you have the verdicts but not the stage bytes.

Two vocabularies, and one word they share

disrobe labels two different things, and partial appears in both.

A support tier describes a family, not a run. It is the standing claim about how far recovery goes for that packer or obfuscator, and it comes from the catalog or disrobe catalog <ecosystem>: Recover, Partial, or Detect-only.

A confidence tier describes one stage of one run. It comes from recovery.json, and the four values, ranked, are exact, semantic, partial, skeleton (ConfidenceTier in crates/disrobe-core/src/recovery.rs). A family at support tier Recover can still produce a skeleton stage on your sample. The first is what the pass can do, the second is what it did here.

Stage status is a third axis, with five values:

StatusSet when
recoveredThe stage completed with output, or extracted children.
advancedThe stage produced output the chain then fed onward.
incompleteThe branch ended at a stall, a cycle, or a cap.
failedThe pass returned an error, which includes a stated refusal.
skippedA plan-only run (disrobe auto --dry-run), where nothing executed.

The chain stopped early

Symptom. Chain verdict Stalled in the text view, "stalled" in JSON. The last disrobe context row is named terminal, with status incomplete and confidence skeleton:

  verdict:   Stalled
  tiers:     exact=0 semantic=0 partial=0 skeleton=1 (total 1)
  passes:
    terminal                     incomplete skeleton  -

Meaning. Every registered detector ran against that stage's bytes and none returned a confidence at or above the selection floor of 0.5, so the runner stopped rather than run a pass it does not believe applies. This is a statement about detection, not about the bytes. It is also why the row has no pass name: no pass executed at that node, so there is nothing to attribute the tier to.

chain.json's stats.rejected_passes counts candidates dropped below the floor across the run. Zero means nothing recognized the bytes at all. Nonzero means something did recognize them, just not confidently enough to act on, and that is the more promising case.

Next. Identify the stalled bytes yourself, then name the pass instead of letting detection choose it.

disrobe detect ./out/sample/final/02-pyinstaller-extract/output.bin
disrobe identify ./out/sample/final/02-pyinstaller-extract/output.bin
disrobe chain sample.bin --chain 'pyinstaller.extract,pyarmor.unpack,py.decompile' --out ./out/sample-explicit

An explicit --chain bypasses detector selection for the passes you name, so a family that scored 0.4 still gets its pass run. Chain pass ids are dotted and comma-separated (pyinstaller.extract, pyarmor.unpack, py.decompile, native.packer-unpack, binfmt.container, and the rest), and a trailing ,* hands the remaining depth back to auto-detection. These are not the command groups disrobe passes prints; an id the registry does not know fails with DR-CLI-0298 and lists every id it does know, which is the quickest way to see them all.

When auto recovers nothing it already prints a note on stderr naming the format it thinks it saw and the command group to try:

note: auto recovered no files and could not identify the format. Run `disrobe detect <file>` to identify it, then the matching subcommand.

The chain hit a cap

Symptom. Verdict CapReached, or "cap-reached" in JSON, on a branch that still had unprocessed output.

Meaning. One of two bounds stopped the branch. Either the depth cap, eight passes by default, or the cumulative-output budget of 512 MiB across the whole run. Both exist so a nested archive or a self-re-emitting packer cannot make a chain run forever; see depth and cycle safety. Nothing about the input is being claimed here. The stages that did run are valid and their outputs are on disk.

Next. Raise the depth cap, or split the work and re-enter on the child you care about.

disrobe auto sample.bin --max-depth 16 --out ./out/sample --capture-stages
disrobe auto ./out/sample/final/03-binfmt-container/output.bin --out ./out/sample-inner

Sixteen is the ceiling for --max-depth; above it the spec is rejected. The cap is also settable per project as max_depth under [execution] in .disrobe.toml. The byte budget is fixed and not exposed as a flag, so a run that exhausts it has to be split.

The chain saw the same bytes twice

Symptom. Verdict Cycle.

Meaning. A stage produced bytes already seen earlier in the chain. Each stage output is content-hashed with BLAKE3, and a repeat stops the branch instead of looping. Usually this is a pass that did not change its input, or an archive that contains itself.

Next. Compare input_blake3 and output_blake3 across the nodes in chain.json to find the stage that repeated, then run that stage's pass directly on the captured bytes to see what it did and did not change.

A body came back as a skeleton

Symptom. Confidence skeleton on a stage that did execute, or a nonzero skeleton count in the histogram.

Meaning. The chain assigns semantic to a stage that completed with source output and partial to one that completed with a byte payload. Everything else is skeleton, so at chain level the tier tells you the stage did not produce a recognized recovery, not how much of each body survived. For that, read the pass's own output. Passes that recover bodies separate the levels explicitly rather than mixing them: disrobe nuitka decompile writes real source lifted from frozen bytecode into frozen/ and typed signatures of native-compiled modules into skeleton/, and labels each in its disrobe.nuitka.recovery-manifest/v1 sidecar (decompiled-from-bytecode against signatures-only-native-compiled), so no signature is ever presented as a recovered body.

The question that matters next is whether the missing part is in the artifact at all. A skeleton because a lifter has a gap is worth pushing on. A skeleton because the body is decrypted by a key that exists only at run time is not. The code the pass emitted is what distinguishes them.

Next. Check the family's standing support tier, then read the code the pass emitted.

disrobe catalog python
disrobe explain DR-PYARM-0013

Where a pass recovers names and signatures but no bodies, that can be the ceiling for a stripped or AOT-compiled artifact rather than a gap to push on. The family's catalog tier tells you which to expect before you spend time on it.

A pass reported a refusal

Symptom. Status failed on a stage, with a DR- code in the message. In chain.json that node's verdict is "error" and the message is in its error field.

Meaning. A pass returns an error both when it malfunctions and when it declines to answer, so failed alone does not tell you which. The code does. A refusal names the evidence it lacked, and it is the intended outcome when the alternative would be a guess: a wrong recovery in malware analysis produces a wrong conclusion, and it produces it in a form that looks exactly like a right one.

Next. Look the code up. Every failure carries a DR-<DOMAIN>-<NNNN> code, and explain prints its description, causes, and fixes:

disrobe explain DR-PYARM-0013
disrobe explain pyarm-13          # short form, zero-padded for you
disrobe explain pyarmor-13 --json # long domain aliases also resolve
DR-PYARM-0013
  title:       PyArmor v3/v4/v5 capsule walled on the RSA-wrapped key
  description: v3-v5 capsule structure and metadata parse, but the bytecode AES key is
               RSA-wrapped with a private key the author never ships, so the plaintext is
               not in the artifact and cannot be recovered statically.
  common fixes:
    - supply the cleartext capsule key if you hold it; structure, version, and metadata
      still parse without it

That entry is the shape to look for. It states what did parse, what is absent, and the one input that would reopen it. Not every code in the binary has a registered entry; the domains that carry entries are CLI, PYARM, PYINST, PYFRZ, NUITKA, SDEF, PYDEOB, JSDEOB, WASMDEOB, MARSHAL, and BINFMT. An unregistered code returns DR-CLI-0102 and asks you to file an issue with the full message you saw.

The absent-data case

Some refusals are scoped to your input and reopen with a wider one. A missing PyArmor runtime extension, a wrapper another obfuscator post-processed, a .pyc without its matching interpreter: supply the missing piece and the recovery proceeds.

Others do not reopen. When the data is not in the artifact, no static tool recovers it, and nothing you can derive from that artifact changes the answer. Three shapes account for almost all of them: a key that exists only at run time, state that exists only in a live process, and a payload fetched from the network on execution. disrobe reports these as detect-only and names which shape applies, rather than emitting a body at a lower confidence tier. This is the case the whitepaper calls a wall, and the catalog marks it per family.

The distinction is worth holding onto when you decide whether to keep pushing. A detect-only row with a named absent-data reason is a finished answer about that artifact. A stall, a cap, or a lifter gap is not.

The recovery did not verify

Symptom. disrobe py decompile prints a roundtrip: line that is not perfect.

Meaning. Every Python decompile is recompiled on the matching interpreter and compared opcode for opcode. The label reports what that check found:

LabelMeaning
perfectThe recompiled code object is byte-identical in code, consts, names, and varnames, nested objects included.
semanticNot byte-identical, but the normalized opcode sequences match op for op.
code-diffThe normalized sequences differ. The detail names the qualified name and the first differing index.
no-interpreterNo matching interpreter was found, so the check did not run.
recompile-failedThe emitted source did not compile. The captured stderr says why.
skippedThe check was disabled with --no-roundtrip.

no-interpreter and skipped are the two to watch, because in both the source is unverified rather than verified-and-different. Install the interpreter the interpreter: line names, drop --no-roundtrip, and run it again.

The emit you asked for is not there

Symptom. An --emit kind wrote a small JSON document instead of an artifact.

Meaning. Commands that implement the standardized emit contract return either the requested artifact or a stub with "applicable": false, "schema": "disrobe.emit.stub/v0", and a reason. The shared parser recognizes fifteen emit labels, but support is command-specific and auto accepts only recovery. See standardized emits.

Next. Run the pass the reason names. disrobe auto --emit accepts only recovery, which echoes recovery.json to stdout under --json; for structured emits, drive the per-language subcommand directly.

disrobe auto sample.bin --emit recovery --json --out ./out/sample
disrobe py decompile ./out/sample/final/02-pyarmor-unpack/output.bin --emit source,disasm,report

Before you conclude anything

Anything you read out of a run is a claim about the bytes you supplied. Confirm the input identity in recovery.json matches the sample you meant to analyze: the blake3 and size fields are there so a report can be tied back to an artifact, and so two runs can be compared. disrobe diff and disrobe guard verify compare chain.json documents stage by stage when you need to prove two runs agree. For handling of the sample itself, read forensics and malware-safety posture.

Architecture overview

disrobe is a workspace of focused Rust crates orchestrated by one CLI. Every recovery flows through the same shape: bytes in, a chain of passes that each transform the artifact up the IR ladder, an optional .dr envelope for content-addressed caching, and a final emit.

For the full design rationale, including the determinism argument and the oracle-grading methodology, read the architecture whitepaper.

The shape of a recovery

                   ┌──────────────────────────────────────────────┐
   input bytes ──> │  detect  ──>  pass 1  ──>  pass 2  ──>  ...  │ ──> recovered artifact
                   └──────────────────────────────────────────────┘
                          │           │            │
                          │           │            └─ each pass: raw -> disasm -> mir -> hir -> surface
                          │           └─ detector confidence + precedence pick what runs next
                          └─ chain runner records chain.json + stage mirrors + recovery.json

Crate map

The workspace splits into shared cores and dedicated ecosystem or recovery-surface crates:

CrateRole
disrobe-coreShared artifacts, error codes (DR-<DOMAIN>-<NNNN>), confidence tiers, the Pass and Detector traits, pass selection, and the chain state machine.
disrobe-prowlAsync URL and IOC harvester over public archives and threat-intel feeds, with bounded paging, per-host rate limits, key resolution, and typed reports.
disrobe-irThe five-rung IR ladder, the .dr envelope (rkyv hot + postcard cold + BLAKE3 root), transcoders, capability descriptors.
disrobe-nir, disrobe-nir-liftNormalized MIR plus bytecode front ends for AVM2, BEAM, CIL, Dalvik, JVM, Lua, Python, WebAssembly, and YARV.
disrobe-binfmtContainer, archive, filesystem, firmware, and encrypted-volume layer (102 formats detected; 101 generic extraction routes plus bounded raw-volume-key LUKS1, with 41 generic routes reached by a committed input, plus recursive carving) with shared path-safety and decompression-quota machinery.
disrobe-passesSingle assembly point for the feature-selected auto-chain registry. The standard CLI enables a specific subset; disrobe passes prints the resulting IDs.
disrobe-llm-metadataThe --llm sidecar: 18 categories, 4 packs, AGENTS.md / SKILL.md brief generation.
disrobe-mcpThe rmcp Model Context Protocol companion wired to disrobe serve --mcp.
disrobe-py-marshalCPython marshal reader: code objects across 1.0-3.15.
disrobe-pass-*One crate per ecosystem or recovery surface, including Python, JavaScript, WebAssembly, JVM, .NET, native, Go, Lua, PHP, Ruby, BEAM, Swift/Objective-C, AS3, mobile, shell, and webview desktop assets. The native pass adds the iced-backed disassembler, symbol-independent function discovery, call graph and basic-block CFG, instruction re-encode/relocate, C++ RTTI/vtable recovery, and emulation-driven string recovery.
disrobe-queryQueryable-IR layer over the disassembled native code: functions, calls-to, xrefs, string-decoders, complexity, capability sites, behind disrobe query.
disrobe-capabilitiesCapability rule engine over the queryable IR, mapping matched behaviors to MITRE ATT&CK and MBC, behind disrobe capabilities.
disrobe-taintSource-to-sink data-flow analysis over normalized native, WebAssembly, JVM, Dalvik, and .dr inputs, behind disrobe taint.
disrobe-cliThe disrobe binary: argument parsing, direct command handlers, output formats, chain integration, and daemon protocols.
disrobe-validatorWalks a corpus and validates every fixture round-trips, used in CI.

The Pass trait

Every chain pass implements one trait (Pass in disrobe-core, re-exported as chain::detector::Pass): it exposes a Detector that scores how confidently it recognizes an input, plus a run method that takes an Artifact at one rung and returns an Artifact one or more rungs higher. Because every chain pass speaks the same detector interface, the chain runner needs no per-pair compatibility table: it re-detects the current bytes after every stage and picks whichever registered pass returns the highest-confidence, highest-precedence verdict. This is what lets PyInstaller -> PyArmor -> .pyc decompile work as a single disrobe auto invocation rather than three hand-wired steps.

The CLI also has a standardized emit vocabulary, but it is not part of the Pass trait and support varies by command. A command wired to the emit helper writes an applicable: false stub when a requested kind does not apply. auto accepts only the recovery emit. See Standardized emits.

The four pillars

  1. The five-rung IR ladder: the common intermediate representation every artifact climbs.
  2. Passes and pass selection: what each pass registers and how the chain runner picks between them.
  3. The chain runner: auto-detection, stage mirrors, depth and cycle caps.
  4. The .dr envelope: the content-addressed wire format that makes caching deterministic.

Determinism is a design constraint, not a feature

The recovery architecture is designed for reproducible output. No model runs in the decompile path, and recovery output does not depend on randomness. Opt-in redaction uses the first 96 bits of the value's unsalted SHA-256 digest, so the same value receives the same sentinel across runs. Values shorter than 16 characters reveal no source characters. Longer values reveal only the first two and last two characters. Timing tokens are scrubbed from golden outputs so that two runs hash identically. The .dr envelope is content-addressed with BLAKE3 rather than timestamp-addressed, so a cache hit identifies the same bytes. This makes disrobe output usable as a forensic baseline and as a disrobe diff input across versions.

The committed cross-platform gate exercises three real fixtures. Each of the test job's Linux, macOS, and Windows legs runs the CLI against those fixtures, and the downstream determinism-cross-platform job hashes the recovered output with BLAKE3 and fails if the operating systems disagree. A companion check runs the same fixtures through the batch path at --jobs 1 and --jobs 4. These checks establish determinism for that population; they do not turn three fixtures into a claim about every input. See crates/disrobe-cli/tests/determinism_cross_platform.rs and the determinism-cross-platform job in .github/workflows/ci.yml.

disrobe: deterministic static recovery of compiled and obfuscated programs

disrobe is a deterministic, non-LLM static reverse-engineering and deobfuscation suite. It recovers source, intermediate language, or original bytes from compiled and protected artifacts by explicit rule, so that the same input always yields the same output and every result is checkable by a third party. No statistical model, no learned prior, and no execution of the analyzed program participates in recovery.

Abstract. This paper documents three recovery subsystems of disrobe and the verification discipline common to all of them. The first is deterministic decompilation of CPython bytecode across the release surface from 1.0 through the 3.15 development line and PyPy, reducing per-release opcode drift and adaptive specialization to a single version-agnostic vocabulary before any structuring begins. The second is a native decompiler that lifts x86-64 machine code into a typed abstract syntax tree with a single precedence authority and emits both C and Rust, removing the parenthesization errors intrinsic to string-concatenation emission. The third is managed-VM devirtualization, reconstructing Common Intermediate Language from two bytecode-virtualizing .NET VM schemes, an in-repo reimplementation of Eazfuscator.NET's EazVM and the real ConfuserEx-lineage KoiVM, and recording information-theoretic walls where the plaintext leaves the static file. The final part is the verification methodology itself, a four-tier taxonomy of non-circular oracles ordered from recompile-equivalence to byte-exact comparison against the original. The central methodological claim is that every capability documented here is graded by an oracle disrobe does not control: a real compiler, interpreter, or virtual machine, or the true pre-transformation input, never disrobe's own output. Where a figure elsewhere in the project is a coverage count that no external reference grades, it is published in a separate self-reported tier and never blended into a graded one. A green measurement is treated as false until it has been shown incapable of flattering the tool that produced it.

Introduction

Scope and conventions

This paper covers exactly the recovery passes whose difficulty, or whose method of evaluation, is not already settled by existing tools. Container parsing, linear and recursive disassembly, and ordinary bytecode reading are standard practice and are excluded; what remains are the passes where the reconstruction is ambiguous, where the emitted output can be silently wrong, or where the correctness of a result is itself hard to establish. Each section keeps one shape: the problem, why it resists a naive solution, the reconstruction method with verbatim code, and an evaluation graded by an independent oracle, with limitations stated beside the results rather than in a footnote.

Every number in this document is read directly from committed sources, a committed test, a committed data file, or a commit in the repository's own history; none is estimated. Figures are per code object, per method, or per function unless stated otherwise, and a percentage is given with the counts behind it wherever those counts exist. Where a measurement depends on a compiler's code generation, the number reported is a CI-enforced floor set below the locally observed value, because a floor has to hold on a slower machine and a narrower toolchain than a development box. Section 4.4.4 lists what each figure needs beyond a clone of the repository, because a floor whose input or toolchain is absent skips rather than measuring.

Two terms recur. Recompile-equivalence always means grading against a real, independent toolchain that disrobe does not control, never against disrobe's re-emission of its own output. A wall means the pass detects and structurally classifies an artifact but declines to fabricate a body for data that provenance analysis proves is absent from that artifact, such as a runtime-derived key or a native-loader-only decryption step; a wall is reported as a distinct, typed outcome, never folded into a success count.

Why these passes

The three recovery subsystems are chosen because each isolates a distinct failure mode that a careless recovery tool hides. Deterministic Python decompilation confronts a moving instruction set and an interpreter that rewrites its own bytecode, where the risk is a decoder correct for one release and silently wrong for the rest. The native lift confronts the precedence and declarator grammar of C, where a printer that concatenates strings produces text that computes a different value from the machine code it claims to represent. Managed-VM devirtualization confronts the circular oracle head on, because a devirtualizer graded against its own output certifies nothing. The verification section then states the discipline the first three depend on, so that the recovery claims and the evidence for them are read together. The account begins with the Python decompiler, the largest and most finely graded of the three recovery subsystems.

Contributions

The paper's specific contributions, each demonstrated by the evidence in its section, are:

  1. A single deterministic CPython bytecode decoder that spans 30 version-specific opcode tables from 1.0 through the 3.15 line plus PyPy, folding per-release renumbering and adaptive specialization into one version-agnostic vocabulary before any structuring (Section 1).
  2. A native lift from x86-64 into a typed abstract syntax tree with one precedence authority that emits both C and Rust, so that parenthesization and declarator grouping are decided once by the grammar rather than per print site (Section 2).
  3. In-crate devirtualization of two managed VMs, EazVM and KoiVM, with information-theoretic walls recorded where the plaintext body leaves the static file (Section 3).
  4. A four-tier non-circular oracle taxonomy applied uniformly across passes, held by conservative CI floors on a three-platform matrix (Section 4).

disrobe sits beside three established bodies of tooling, and the point of this section is to say what it does differently, not to rank it.

Python bytecode decompilers. uncompyle6, decompyle3, and pycdc recover Python source from compiled code objects, each over its own supported range of CPython releases. disrobe's decoder differs in two concrete ways demonstrated in Section 1: one decoder routes 30 version-specific tables plus a PyPy overlay through a single canonical opcode vocabulary, and correctness is graded by recompile-equivalence against a real CPython interpreter rather than by textual resemblance to the original source.

Native decompilers. Ghidra, RetDec, and Hex-Rays lift machine code to C-like pseudocode. The native path in Section 2 differs in emission and in grading: recovered code is built as a typed AST whose parenthesization and C declarator grouping are computed once from the grammar (Section 2.1 sets out why string-concatenation emission is unsound), and it is validated by a recompile-execute-diff oracle that links the recovered function against the original object and refuses any input it cannot soundly lift.

.NET deobfuscation and devirtualization. de4dot cleans many .NET protectors, and separate community work has devirtualized KoiVM and ConfuserEx. Section 3 differs in the oracle rather than the target set: EazVM recovery is graded instruction by instruction against CIL that a C# compiler emitted into a separate clean assembly the recovery code never reads, and KoiVM recovery is graded against a hand-derived ground truth that is independent of the lifter, so neither number can be manufactured by the tool under test.

1. Deterministic decompilation of CPython bytecode across versions

This section documents the disrobe-pass-py-decompile crate, which recovers Python source from compiled CPython code objects without any statistical model, learned prior, or interpreter in the loop at recovery time. The crate spans 89 source files and roughly 49,600 lines. The argument below proceeds from the problem it solves, to why the problem resists a naive solution, to the exact reconstruction algorithms in the code, to the recompile-equivalence oracle that grades the result, to the measured numbers, and finally to the limits the code itself exposes. Every claim is anchored to a named symbol in the reviewed tree, and every code excerpt is reproduced verbatim.

1.1 The problem: deterministic recovery of Python source from bytecode

A CPython .pyc file is a container around a marshalled code object: a byte string of bytecode (code.code), a constants pool (code.consts), interned name and local-variable tables (code.names, code.varnames, code.localsplusnames, code.cellvars, code.freevars), argument counts, flags, and side tables for source lines and exception ranges. Compilation is lossy in the directions that matter to a reverse engineer: comments and formatting are gone, and control flow has been lowered from structured statements into a flat instruction stream threaded by jumps. Decompilation is the inverse map, from that instruction stream back to source that a person can read and that a compiler will accept.

Two families of decompiler exist. One family is probabilistic: a model is trained on source and bytecode pairs and predicts likely source. That approach produces plausible text but offers no guarantee that the text means what the bytecode meant, which is precisely the guarantee a malware analyst needs before acting on a finding. The crate here is the other family. It is deterministic and non-probabilistic: the same input always yields the same output, the output is derived by explicit rules from the bytecode, and the output is checkable. The pass declares itself a pure transform that detects raw pyc/pypy/micropython bytes and always emits formatted Python source:

#![allow(unused)]
fn main() {
impl Pass for PyDecompilePass {
    ...
    fn output_kind(&self, _output: &Artifact) -> OutputKind {
        OutputKind::Source {
            language: Language::Python,
            formatted: true,
        }
}

(src/chain_detector.rs, impl Pass for PyDecompilePass)

Determinism matters for reverse engineering and malware analysis for three concrete reasons. First, static safety: the recovery runs no attacker-controlled code, so analyzing a hostile sample cannot execute it. The entry point parses bytes and never calls the payload:

#![allow(unused)]
fn main() {
pub fn decompile_pyc(bytes: &[u8]) -> Result<NativeDecompile> {
    if bytes.len() < 4 {
        return Err(DecompileError::Marshal(
            disrobe_py_marshal::Error::PycHeaderShort {
                need: 4,
                got: bytes.len(),
            },
        ));
    }
    let magic: u32 = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
}

(src/engine.rs, decompile_pyc)

Second, coverage: a rule-based system can be pushed to a measured ceiling on a real corpus and held there by a regression gate, which a model that guesses cannot promise. Third, reproducibility: a finding is worth citing only if a stranger can rerun the exact transform and get the exact result, and only a deterministic transform admits that.

The crate covers a wide version surface deliberately. Malicious and legacy Python arrives compiled under whatever interpreter its author used, from 1990s 1.x through the 3.15 development line, and under alternative runtimes such as PyPy and MicroPython. The public API exposes one decompile entry per runtime family, all returning the same NativeDecompile record:

#![allow(unused)]
fn main() {
pub use engine::{
    NativeDecompile, decompile_micropython, decompile_pyc, decompile_pypy, pypy_variant_label,
};
}

(src/lib.rs, the engine re-export)

1.2 Why it is hard

Three independent difficulties compound: the opcode numbering is not stable across releases, the modern interpreter carries instructions that hold no source meaning, and the recovery of structured control flow from a stack machine is ambiguous in general.

1.2.1 Per-release opcode reassignment

CPython renumbers its opcode table almost every minor release and periodically reshapes how an instruction packs its argument. The crate therefore treats a version not as a single integer but as a distinct decoding context. The version enumeration carries a variant per supported release plus a recursive PyPy wrapper:

#![allow(unused)]
fn main() {
pub enum PyVersion {
    V1_0,
    V1_1,
    V1_3,
    ...
    V3_14,
    V3_15,
    PyPy(Box<PyVersion>),
}
}

(src/bytecode/version.rs, PyVersion)

Each release is bound to its magic number, so a raw .pyc is routed to the correct context by the four header bytes:

#![allow(unused)]
fn main() {
3495 => Self::V3_11,
3531 => Self::V3_12,
3571 => Self::V3_13,
3627 => Self::V3_14,
3666 => Self::V3_15,
}

(src/bytecode/version.rs, the magic-number map)

The renumbering problem is not only that byte 100 means different things in 3.10 and 3.12. The argument encoding also drifts. In 3.11 LOAD_GLOBAL began using the low argument bit as a flag and shifting the name index left by one; 3.12 did the same for LOAD_ATTR; 3.13 through 3.15 widened the COMPARE_OP operator field; 3.15 shifted the IMPORT_NAME index by two to make room for lazy/eager flags. The decoder computes each of these version-dependent shifts explicitly before it resolves an operand:

#![allow(unused)]
fn main() {
let attr_index: u32 = if (maj, min) >= (3, 12) { arg >> 1 } else { arg };
let global_index: u32 = if (maj, min) >= (3, 11) { arg >> 1 } else { arg };
let import_index: u32 = if (maj, min) >= (3, 15) { arg >> 2 } else { arg };
let compare_index: u32 = match (maj, min) {
    (3, 13..=15) => arg >> 5,
    (3, 12) => arg >> 4,
    _ => arg,
};
}

(src/bytecode/opcode/mod.rs, shared_decode)

The regression suite pins these shifts against the exact byte values a real interpreter emits, which is why a mis-shift is caught rather than silently absorbed:

#![allow(unused)]
fn main() {
#[test]
fn import_name_index_shifted_on_315() {
    let op: CanonicalOp = shared_decode(&PyVersion::V3_15, IMPORT_NAME_315, 8);
    assert_eq!(op, CanonicalOp::ImportName(2));
}
}

(src/bytecode/opcode/mod.rs, import_name_index_shifted_on_315)

1.2.2 Adaptive specialization, superinstructions, and quickening carry no source meaning

Since 3.11 the interpreter rewrites its own bytecode at run time. A generic LOAD_ATTR is replaced in place by a specialized LOAD_ATTR_INSTANCE_VALUE once the interpreter has observed the receiver shape; BINARY_OP becomes BINARY_OP_ADD_INT; hot instructions are followed by CACHE entries that hold inline caches; and pairs of common instructions are fused into superinstructions such as LOAD_FAST_LOAD_FAST. None of these carries information about the original source. A .pyc written after specialization, or a code object captured from a running interpreter, is full of them, and a decompiler that took them at face value would emit nonsense.

The crate neutralizes all three. Specialized forms are demoted back to their generic meaning before any structuring sees them:

#![allow(unused)]
fn main() {
"BINARY_OP_ADD_INT" | "BINARY_OP_ADD_FLOAT" | "BINARY_OP_ADD_UNICODE" => {
    Some(CanonicalOp::BinaryOp(BinOp::Add))
}
...
"LOAD_ATTR_INSTANCE_VALUE"
...
| "LOAD_ATTR_SLOT"
| "LOAD_ATTR_WITH_HINT" => Some(CanonicalOp::LoadAttr(arg >> 1)),
}

(src/bytecode/opcode/mod.rs, shared_decode)

Cache slots are counted and skipped during decoding rather than decoded as instructions. The decoder advances the cursor by the per-opcode cache width so the inline cache bytes never enter the stream:

#![allow(unused)]
fn main() {
cursor += WIDE_STEP;
let caches: usize = usize::from(opmap.cache_size(raw));
if caches > 0 {
    cursor += caches * WIDE_STEP;
}
}

(src/ast/builder/mod.rs, the decode loop)

Superinstructions are split back into their component operations, restoring the two logical pushes the source implies:

#![allow(unused)]
fn main() {
"LOAD_FAST_LOAD_FAST" => CanonicalOp::LoadFastLoadFast(arg >> 4, arg & 0xF),
"STORE_FAST_LOAD_FAST" => CanonicalOp::StoreFastLoadFast(arg >> 4, arg & 0xF),
"STORE_FAST_STORE_FAST" => CanonicalOp::StoreFastStoreFast(arg >> 4, arg & 0xF),
}

(src/bytecode/opcode/mod.rs, shared_decode)

1.2.3 Stack-machine to structured control flow is ambiguous

CPython is a stack machine. Source expressions become sequences of pushes and pops with no explicit tree, and source statements such as if, while, for, with, try, and match become flat regions joined by conditional and unconditional jumps. Recovering the tree and the statement boundaries is the hard center of decompilation because many distinct source shapes lower to jump graphs that look alike locally. A conditional forward jump can be the head of an if, the head of a while, one link of a short-circuit and/or expression, one comparator of a chained comparison, or the guard of a comprehension filter. The same POP_JUMP_IF_FALSE opcode participates in all of these, and the surrounding shape is what disambiguates it. The crate encodes that disambiguation as an ordered cascade of pattern recognizers, described in 1.3.3, rather than a single generic control-flow-graph algorithm, because the idioms the compiler emits are specific and version-bound.

1.3 Reconstruction methodology

The end-to-end data flow is: raw .pyc or code-object bytes, then a version-normalized opcode stream, then a frame tree and a decoded stream with resolved offsets, then a structured abstract syntax tree, then emitted source. build_real_source is the spine:

#![allow(unused)]
fn main() {
pub fn build_real_source(
    code: &CodeObject,
    decompile_version: &DecompileVersion,
    marshal_version: MarshalVersion,
) -> Result<String> {
    let started: Option<Instant> = wall_clock_start();
    let frame_tree: FrameTree = builder_for(marshal_version).build(code, marshal_version)?;
    let module: AstModule = structure_module(code, &frame_tree, decompile_version)?;
    let pipeline: EmitPipeline = EmitPipeline {
        emitter: Box::new(DefaultEmitter {
            unicode_literals: module_has_unicode_literals(&module),
            ..DefaultEmitter::new()
        }),
}

(src/engine.rs, build_real_source)

Structuring runs on a dedicated 256 MB stack, because the structuring recursion tracks deeply nested control flow and the default thread stack is not enough for adversarial inputs:

#![allow(unused)]
fn main() {
#[cfg(not(target_arch = "wasm32"))]
const STRUCTURE_STACK_BYTES: usize = 256 * 1024 * 1024;
}

(src/engine.rs, STRUCTURE_STACK_BYTES)

1.3.1 Opcode normalization: raw byte, to stable mnemonic, to version-agnostic canonical op

The normalization strategy has two stages, and it is the key to spanning the whole version surface without duplicating decoding logic per release. First, a per-version OpcodeMap maps a raw byte to that version's mnemonic. The crate ships 30 CPython version-specific opcode tables spanning v1_0 through v3_15 (there is no v1_2, because CPython 1.2 shares no distinct table), plus dedicated PyPy tables (pypy.rs and pypy_extras.rs). All are reachable through one dispatch, map_for, which routes to the 30 concrete release adapters or wraps any base version in the PyPy overlay.

#![allow(unused)]
fn main() {
pub trait OpcodeMap: Debug + Send + Sync {
    fn version(&self) -> PyVersion;
    fn decode(&self, raw: u8, arg: u32) -> CanonicalOp;
    fn cache_size(&self, op: u8) -> u8;
    fn has_arg(&self) -> u8;
    fn opname(&self, op: u8) -> &'static str;
    fn jump_kind(&self, op: u8) -> JumpKind;
    fn family(&self, op: u8) -> OpcodeFamily;
}
}

(src/bytecode/opcode/mod.rs, OpcodeMap)

#![allow(unused)]
fn main() {
pub fn map_for(version: PyVersion) -> Box<dyn OpcodeMap> {
    match version {
        PyVersion::V1_0 => Box::new(v1_0::V10OpcodeMap),
        ...
        PyVersion::V3_15 => Box::new(v3_15::V315OpcodeMap),
        PyVersion::PyPy(inner) => Box::new(pypy::PyPyOpcodeMap {
            base: map_for(*inner),
        }),
    }
}
}

(src/bytecode/opcode/mod.rs, map_for)

The concrete adapters are intentionally thin. Each one binds its version and forwards to the shared implementation, so the release-specific knowledge is the version tag plus the byte-to-name table it selects, not a hand-copied decoder:

#![allow(unused)]
fn main() {
impl OpcodeMap for V311OpcodeMap {
    fn version(&self) -> PyVersion {
        PyVersion::V3_11
    }

    fn decode(&self, raw: u8, arg: u32) -> CanonicalOp {
        shared_decode(&PyVersion::V3_11, raw, arg)
    }
}

(src/bytecode/opcode/v3_11.rs)

The PyPy overlay is the one map with genuine per-variant bytes, because PyPy adds opcodes above the CPython range. It intercepts those and delegates everything else to the wrapped base:

#![allow(unused)]
fn main() {
fn decode(&self, raw: u8, arg: u32) -> CanonicalOp {
    match raw {
        PYPY_LOOKUP_METHOD => CanonicalOp::LoadAttr(arg),
        PYPY_CALL_METHOD => CanonicalOp::CallFunction(u8::try_from(arg & 0xFF).unwrap_or(0)),
        ...
        _ => self.base.decode(raw, arg),
    }
}
}

(src/bytecode/opcode/pypy.rs, PyPyOpcodeMap::decode)

Second, the shared decoder maps the mnemonic, not the byte, to a version-agnostic CanonicalOp. This is what makes per-release renumbering a non-problem for everything downstream: byte 100 differs across releases, but the name LOAD_GLOBAL is stable, and the name is what carries into the single canonical vocabulary. That vocabulary is a wide enum spanning the whole history of the instruction set, from PRINT_ITEM and EXEC_STMT of the 1.x line to BUILD_TEMPLATE and LOAD_SMALL_INT of the 3.14+ line:

#![allow(unused)]
fn main() {
pub enum CanonicalOp {
    Nop,
    Pop,
    ...
    LoadConst(ConstIndex),
    LoadSmallInt(i32),
    ...
    BuildTemplate,
    ...
    Specialized(u16),
    Other(u8, u8),
}
}

(src/bytecode/opcode/mod.rs, CanonicalOp)

The mnemonic-to-canonical mapping is one large explicit match. It folds families of legacy and modern spellings onto one canonical form, so that later stages never branch on version for meaning. Old and new division, method-call fusions, and renamed jumps all converge:

#![allow(unused)]
fn main() {
"JUMP_ABSOLUTE" | "JUMP" => CanonicalOp::JumpAbsolute(arg),
...
"CALL_FUNCTION" | "CALL" => CanonicalOp::CallFunction(arg_lo),
"CALL_FUNCTION_KW" | "CALL_KW" => CanonicalOp::CallFunctionKw(arg_lo),
}

(src/bytecode/opcode/mod.rs, shared_decode)

Instructions that exist only to drive interpreter mechanics collapse to Nop, including block-setup opcodes from the pre-3.11 exception model, which are reconstructed structurally later rather than represented as operations:

#![allow(unused)]
fn main() {
"SETUP_LOOP"
| "SETUP_EXCEPT"
| "SETUP_FINALLY"
| "POP_BLOCK"
...
| "SET_LINENO" => CanonicalOp::Nop,
}

(src/bytecode/opcode/mod.rs, shared_decode)

Genuinely unknown bytes are preserved rather than dropped, as Specialized(raw) for instrumentation and executor opcodes or Other(raw, arg) for anything unrecognized, so recovery degrades locally instead of desynchronizing the whole stream:

#![allow(unused)]
fn main() {
_ => CanonicalOp::Other(raw, arg_lo),
}

(src/bytecode/opcode/mod.rs, the shared_decode fallthrough)

1.3.2 Decoding, offsets, and jump resolution

Decoding walks the byte string with the version's instruction width. Wordcode (3.6 and later) is two bytes per instruction with EXTENDED_ARG prefixes accumulating a wide argument; classic bytecode is one byte for opcodes below the argument threshold and three bytes otherwise:

#![allow(unused)]
fn main() {
const LEGACY_HAVE_ARGUMENT: u8 = 90;
const WIDE_STEP: usize = 2;
const NARROW_STEP: usize = 1;
}

(src/ast/builder/mod.rs, the instruction-width constants)

The decoder records, for every emitted canonical op, its starting byte offset and its following byte offset, into parallel vectors. Because some decodes expand into more than one canonical op (a fused superinstruction, or a PUSH_NULL slot synthesized ahead of a method-form LOAD_ATTR), each synthetic op inherits the enclosing instruction's offsets so that jump arithmetic stays exact:

#![allow(unused)]
fn main() {
offsets.push(here);
ops.push(opmap.decode(raw, arg));
if crate::bytecode::opcode::shared_method_form_load_attr(version, raw, arg) {
    offsets.push(here);
    ops.push(CanonicalOp::Push(0));
}
}

(src/ast/builder/mod.rs, the decode loop)

The decoded stream carries the offset tables plus everything structuring needs to reason about version-specific flow, including whether jumps are measured in instruction units or bytes and whether conditional jumps are relative:

#![allow(unused)]
fn main() {
struct DecodedStream {
    ops: Vec<CanonicalOp>,
    offsets: Vec<u32>,
    next_offsets: Vec<u32>,
    ...
    wordcode: bool,
    instr_unit_jumps: bool,
    relative_cond_jumps: bool,
    exception_table: Vec<crate::bytecode::flow::ExceptionTableEntry>,
    ...
    version: PyVersion,
}
}

(src/ast/builder/mod.rs, DecodedStream)

Jump targets are resolved from a byte offset to an instruction index by binary search over the recorded offsets, with a ceiling variant for targets that land inside a decoded region:

#![allow(unused)]
fn main() {
fn index_for_offset(&self, byte_offset: u32) -> Option<usize> {
    self.offsets.binary_search(&byte_offset).ok()
}
}

(src/ast/builder/mod.rs, DecodedStream::index_for_offset)

Exception structure is version-split. From 3.11 the compiler emits a zero-cost exception table, a base-128-style varint encoding of protected ranges and handler targets, parsed directly:

#![allow(unused)]
fn main() {
fn read_varint(&mut self) -> Result<u64> {
    let first: u8 = self.read_byte()?;
    let mut value: u64 = u64::from(first & 0x3F);
    let mut more: bool = (first & 0x40) != 0;
    ...
    while more {
        ...
        let next: u8 = self.read_byte()?;
        value = (value << 6) | u64::from(next & 0x3F);
        more = (next & 0x40) != 0;
}

(src/bytecode/flow.rs, read_varint)

Before 3.11 there is no such table, so the crate synthesizes protected ranges from the SETUP_FINALLY and SETUP_EXCEPT block-setup opcodes, computing handler targets from the setup argument and the instruction width:

#![allow(unused)]
fn main() {
if matches!(name, "SETUP_FINALLY" | "SETUP_EXCEPT") {
    let after: u32 = u32::try_from(cursor + WIDE_STEP).unwrap_or(u32::MAX);
    let delta_bytes: u32 = if version.major() == 3 && version.minor() >= 10 {
        arg.saturating_mul(2)
    } else {
        arg
    };
    let target: u32 = after.saturating_add(delta_bytes);
}

(src/ast/builder/mod.rs, the pre-3.11 protected-range synthesis)

Source line numbers, used to place recovered statements, are likewise parsed per era: the PEP 626 linetable for 3.11+, a transitional linetable for 3.10, and classic lnotab before that (src/bytecode/flow.rs).

1.3.3 Frame tree: a coarse skeleton of compound statements

Recovery is two-phase. First a frame tree gives a coarse, nested skeleton of the compound statements; then the stack machine and the recursive structurer fill each region with statements and expressions. A frame is a typed range with children:

#![allow(unused)]
fn main() {
pub struct Frame {
    pub id: FrameId,
    pub kind: FrameKind,
    pub range: Range<u32>,
    pub body_range: Range<u32>,
    pub child_ranges: Vec<Range<u32>>,
    pub handlers: Vec<HandlerRange>,
    pub finally_range: Option<Range<u32>>,
    pub line: Option<u32>,
    pub children: Vec<Frame>,
}
}

(src/frame_tree/mod.rs, Frame)

The builder is version-split at the same 3.11 boundary as exception handling:

#![allow(unused)]
fn main() {
pub fn builder_for(version: PyVersion) -> Box<dyn FrameTreeBuilder> {
    if version.major > 3 || (version.major == 3 && version.minor >= 11) {
        Box::new(builder::Post311Builder::new())
    } else {
        Box::new(builder::Pre311Builder::new())
    }
}
}

(src/frame_tree/mod.rs, builder_for)

The pre-3.11 builder is a straightforward block-stack walk. It pushes a frame on each SETUP_* opcode and closes the top frame on POP_BLOCK, using per-version opcode numbers so the same walk works from 2.x through 3.10:

#![allow(unused)]
fn main() {
if let Some(op) = ops.setup_loop
    && instr.opcode == op
{
    push_block(ctx, &mut stack, instr, FrameKind::WhileLoop)?;
    continue;
}
}

(src/frame_tree/builder.rs, Pre311Builder)

The post-3.11 builder derives try/with frames from the exception table and loop frames from backward jumps, classifying each backward jump by what its target begins with (an async-iterator poll, a FOR_ITER, or neither) to distinguish async for, for, and while:

#![allow(unused)]
fn main() {
let kind: FrameKind = if is_async_for {
    FrameKind::AsyncForLoop
} else if is_for {
    FrameKind::ForLoop
} else {
    FrameKind::WhileLoop
};
}

(src/frame_tree/builder.rs, Post311Builder)

Frames are then nested by containment, with a depth cap that stops a pathological input from building an unbounded tree:

#![allow(unused)]
fn main() {
const MAX_FRAME_NEST_DEPTH: usize = 256;
}

(src/frame_tree/builder.rs, MAX_FRAME_NEST_DEPTH)

1.3.4 The stack machine: expressions and statements from stack effects

The heart of expression recovery is an abstract stack simulator. It holds a vector of reconstructed expressions and replays the canonical stream, applying each op's stack effect on Expr values instead of runtime values:

#![allow(unused)]
fn main() {
pub(super) struct StackSim {
    pub(super) stack: Vec<Expr>,
}
}

(src/ast/builder/exprs.rs, StackSim)

A load pushes a leaf, a binary or subscript op pops its operands and pushes a composite, and a store pops the value and emits an assignment statement. Subscription is representative: the slice and the container are popped in stack order and reassembled into a Subscript expression:

#![allow(unused)]
fn main() {
CanonicalOp::LoadSubscr => {
    let slice: Expr = sim.pop_or_synth(code, idx);
    let value: Expr = sim.pop_or_synth(code, idx);
    sim.push(Expr::Subscript {
        value: Box::new(value),
        slice: Box::new(slice),
        ctx: ExprCtx::Load,
    });
}
}

(src/ast/builder/exprs.rs, the CanonicalOp::LoadSubscr arm)

The simulator is fail-soft by construction. A pop on an empty stack, which happens when a region is entered mid-expression or when an opcode is unrecognized, yields a None constant rather than an error, so a local gap does not abort the whole recovery:

#![allow(unused)]
fn main() {
pub(super) fn pop_or_synth(&mut self, code: &CodeObject, idx: usize) -> Expr {
    let _: (&CodeObject, usize) = (code, idx);
    self.stack.pop().unwrap_or(Expr::Constant {
        value: ConstValue::None,
        line: None,
    })
}
}

(src/ast/builder/exprs.rs, StackSim::pop_or_synth)

The simulator returns both the statements it emitted and any residual expressions left on the stack, which is how the structurer above it obtains, for example, the test expression of an if whose condition was computed but not stored:

#![allow(unused)]
fn main() {
pub(super) fn build_linear_stmts_sim(
    code: &CodeObject,
    ops: &[CanonicalOp],
) -> Result<(Vec<Stmt>, Vec<Expr>)> {
    build_linear_stmts_sim_seed(code, ops, Vec::new())
}
}

(src/ast/builder/exprs.rs, build_linear_stmts_sim)

Beyond the base stack effects, the simulator recognizes several idioms that would otherwise mis-decompile. Boolean short-circuits are accumulated across the branch instructions that implement them into a single BoolOp rather than left as jumps (src/ast/builder/exprs.rs, the short-circuit accumulator). Simultaneous assignment, where the compiler emits a stack rotation followed by stores, is folded back into a tuple assignment:

#![allow(unused)]
fn main() {
let merged: Stmt = Stmt::Assign {
    targets: vec![Expr::Tuple {
        elts: targets,
        ctx: ExprCtx::Store,
    }],
    value: Expr::Tuple {
        elts: values,
        ctx: ExprCtx::Load,
    },
    type_comment: None,
    line: None,
};
}

(src/ast/builder/exprs.rs, the rotation-and-store fold)

Imports, class construction, type aliases, and type parameters are threaded through the stack as encoded marker names (for example DR_IMPORT_MODULE_PREFIX, DR_BUILD_CLASS_MARKER, DR_TYPE_ALIAS_MARKER in src/ast/builder/exprs.rs) that a later stage resolves into the proper statement, because their bytecode spans several instructions whose meaning is only clear once assembled.

1.3.5 Recursive structuring: statements from regions

The recursive structurer, structure_stmts, turns a range of the canonical stream into a list of statements. It is the disambiguation engine described in 1.2.3. Its body is an ordered cascade: each recognizer inspects the region and, if the region matches its idiom, consumes it and returns; if no compound idiom matches, the region is a leaf and goes to the stack machine. The order is load-bearing, because the recognizers overlap and the earlier ones are the more specific:

#![allow(unused)]
fn main() {
if let Some(stmts) = try_structure_inline_comprehension(code, stream, lo, hi)? {
    return Ok(stmts);
}
if let Some(stmts) = try_structure_inline_comprehension_noclear(code, stream, lo, hi)? {
    return Ok(stmts);
}
if let Some(stmts) = structure_fallthrough_continue_and_chain(code, stream, lo, hi)? {
    return Ok(stmts);
}
}

(src/ast/builder/stmts.rs, structure_stmts)

Loops, try regions, and match statements are recognized by dedicated detectors that first confirm the region is not enclosed by a larger construct, so a nested loop is attributed to its enclosing try or guard rather than lifted out:

#![allow(unused)]
fn main() {
if let Some(loop_region) = find_loop(stream, lo, hi)
    && !leading_guard_if_encloses_loop(stream, lo, hi, &loop_region)
    && !loop_enclosed_by_guard(stream, lo, &loop_region)
    && !loop_is_else_arm_of_leading_if(stream, lo, hi, &loop_region)
    && !leading_cond_arm_holds_loop(stream, lo, &loop_region)
    && !loop_inside_unpeeled_pre311_try(stream, hi, &loop_region)
{
    return structure_loop(code, stream, lo, hi, &loop_region);
}
}

(src/ast/builder/stmts.rs, structure_stmts)

When a plain conditional is found, the structurer splits the region into head, then-arm, and else-arm, recursively structures each arm, and reassembles an If, taking care to negate the test when the jump polarity requires it and to detect the else-arm by a trailing forward jump over it:

#![allow(unused)]
fn main() {
if body_end > jump_idx + 1
    && let Some(last) = then_terminating_jump(stream, jump_idx + 1, body_end)
    && let CanonicalOp::JumpForward(_) | CanonicalOp::JumpAbsolute(_) = stream.ops[last]
    && let Some(j) = resolve_jump_target(stream, last, &stream.ops[last])
    && j > target
    && (j <= hi || else_jump_exits_to_shared_join(stream, last, target, hi))
{
    join = j.min(hi);
    orelse_start = Some(last + 1);
    then_jump_at = Some(last);
}
}

(src/ast/builder/stmts.rs, structure_stmts)

Chained and compound conditions are reconstructed before the simple case. try_recover_compound_if walks the run of conditional jumps that a single source if a and b or c: compiles to, recovers each operand by running the stack machine over the slice up to its jump, and folds them into one boolean test:

#![allow(unused)]
fn main() {
for (n, &jump) in jumps.iter().enumerate() {
    let (stmts, residual): (Vec<Stmt>, Vec<Expr>) =
        build_linear_stmts_sim(code, &stream.ops[value_lo..jump])?;
    let Some(value): Option<Expr> = residual.into_iter().next_back() else {
        return Ok(None);
    };
}

(src/ast/builder/branches.rs, try_recover_compound_if, called from structure_stmts in src/ast/builder/stmts.rs)

Exception recovery reads the parsed table to locate the protected body, the handler start, and the region end, then distinguishes try/except, try/finally, and with/async with by the shape of the handler prologue:

#![allow(unused)]
fn main() {
let is_with: bool = is_modern
    && matches!(
        stream.ops.get(handler_start + 1),
        Some(CanonicalOp::WithExceptStart)
    );
}

(src/ast/builder/try_with.rs, find_try_region)

Recursion is bounded on three axes so no input can hang the structurer: a per-region reentry limit of four, a structuring depth limit of 600, and a nested-code-object depth limit of 200 (src/ast/builder/mod.rs, STRUCTURE_DEPTH_LIMIT, STRUCTURE_REENTRY_LIMIT and CODEOBJ_DEPTH_LIMIT). When a region reenters too many times it falls back to a linear recovery rather than recursing again (src/ast/builder/stmts.rs, structure_stmts).

1.3.6 The AST and emission

The structured output is a typed Python AST that mirrors CPython's own ast module, including modern constructs: Match/MatchCase/Pattern for structural pattern matching, TypeParam and TypeAlias for PEP 695 generics, TStr/TStrItem for 3.14 template strings, NamedExpr for the walrus operator, and TryStar for exception groups (src/ast/node.rs). The emitter walks this tree to a source string. Recovery is fail-soft at the top level as well: if structuring or emission of a code object fails, the pass falls back to an annotated disassembly listing and records that the object was not directly recovered, rather than failing the whole file:

#![allow(unused)]
fn main() {
Err(real_err) => {
    let reason: String = format!("{real_err}");
    let fallback: String = disasm_fallback_source(&code, &decompile_version, &reason);
    Ok(NativeDecompile {
        source: fallback,
        ...
        recovered_directly: false,
        fallback_reason: Some(reason),
    })
}
}

(src/engine.rs, the fallback arm of decompile_pyc)

1.4 The recompile-equivalence oracle

The correctness claim rests on an oracle that is non-circular, meaning it never grades disrobe's output against disrobe's own machinery. The oracle recompiles the recovered source with a real CPython interpreter and compares the resulting code object to the original, per code object, on normalized opcodes.

The in-crate oracle drives a real interpreter. It locates the matching interpreter on PATH, writes the recovered source to a temporary file, and asks CPython itself to compile it via py_compile:

#![allow(unused)]
fn main() {
let script: String = format!(
    "import py_compile,sys\n\
try:\n    py_compile.compile({src_lit}, cfile={pyc_lit}, doraise=True)\n\
except Exception as e:\n    sys.stderr.write(str(e));sys.exit(2)\n"
);
}

(src/recompile.rs, recompile_via_interpreter)

It then reads back the interpreter-produced .pyc and grades it against the original with semantic_equiv. That function returns one of three verdicts, of which two count as recovered:

#![allow(unused)]
fn main() {
pub enum Verdict {
    Perfect,
    Semantic,
    CodeDiff(DiffDetail),
}
}

(src/roundtrip/mod.rs, Verdict)

Perfect is reserved for a byte-identical code object, checked directly on the raw fields before any normalization:

#![allow(unused)]
fn main() {
let byte_identical: bool =
    a.code == b.code && a.consts == b.consts && a.names == b.names && a.varnames == b.varnames;
if byte_identical {
    return match compare_nested(a, b, version) {
        Verdict::CodeDiff(d) => Verdict::CodeDiff(d),
        _ => Verdict::Perfect,
    };
}
}

(src/roundtrip/mod.rs, semantic_equiv)

Semantic is the more important verdict in practice, because two source spellings can compile to the same behavior with different byte layout. It normalizes both instruction sequences and compares them operation by operation:

#![allow(unused)]
fn main() {
let norm_a: NormalizedSequence = normalize_sequence(a, version);
let norm_b: NormalizedSequence = normalize_sequence(b, version);
if let Some(detail) = compare_normalized(&norm_a, &norm_b, qualname_of(a)) {
    return Verdict::CodeDiff(detail);
}
if let Verdict::CodeDiff(d) = compare_nested(a, b, version) {
    return Verdict::CodeDiff(d);
}
Verdict::Semantic
}

(src/roundtrip/mod.rs, semantic_equiv)

The normalization is what makes Semantic meaningful without being permissive. The Rust oracle and the Python harness apply the same normalization tables independently: the harness defines the NOOP set of padding mnemonics and the SPLIT2, RENAME, and JUMPS maps that expand superinstructions, rename specialized loads, and collapse jump spellings (tests/harness/py_arbitrary_measure.py, the NOOP, SPLIT2, RENAME and JUMPS tables), mirroring the Rust side. The Rust normalizer removes interpreter padding (NOP, CACHE, RESUME, EXTENDED_ARG, MAKE_CELL, and so on at PADDING_NAMES in src/roundtrip/normalize.rs), expands superinstructions and RETURN_CONST back to primitives (src/roundtrip/normalize.rs, normalize_sequence), resolves each jump to a target instruction index so that byte-offset drift does not register as a difference (src/roundtrip/normalize.rs, jump-target resolution), and canonicalizes operand identity by resolving const, name, and local operands to their values rather than their indices (src/roundtrip/normalize.rs, operand resolution). Two operations are equal only when opcode, resolved const value, resolved name, resolved jump target, and compare-operator id all match:

#![allow(unused)]
fn main() {
fn ops_semantically_equal(a: &NormalizedOp, b: &NormalizedOp) -> bool {
    a.token == b.token
        && a.const_value == b.const_value
        && a.name_value == b.name_value
        && a.jump_target_index == b.jump_target_index
        && a.operator_id == b.operator_id
        && raw_arg_semantically_equal(a, b)
}
}

(src/roundtrip/mod.rs, ops_semantically_equal)

The comparison also recurses into nested code objects, so a function whose module compiles equal but whose inner comprehension does not is still charged as a difference (src/roundtrip/mod.rs, compare_nested).

Two points establish non-circularity. First, the graded artifact is a code object produced by CPython from disrobe's text, not disrobe's own re-emission; disrobe never grades itself. Second, the corpus harness reimplements the normalization independently in Python and grades the same way, against a genuine CPython recompile:

Per-code-object recompile-to-equivalent-bytecode oracle ... compare EVERY nested code object ... individually via an opcode-normalized diff. The oracle is non-circular: disrobe's output is graded against a real CPython recompile, never against disrobe's own re-emission. (tests/harness/py_arbitrary_measure.py, module header)

The harness carries an explicit anti-masking guard against a subtle way a per-name comparison could lie. When several code objects share a qualified name (multiple lambdas or comprehensions under one parent), positional pairing could let a real miss on one sibling be hidden by a match on another. The harness refuses that pairing and charges the whole group as failures when the counts differ, and counts the group as a sibling collision whenever either side actually held more than one object under that name:

def sibling_group_charges(alist, blist):
    is_sibling_collision = max(len(alist), len(blist)) > 1
    charges = [(i, "MISSING" if i >= len(blist) else "COLLISION") for i in range(len(alist))]
    return is_sibling_collision, charges


if len(blist) != len(alist):
    is_sibling_collision, charges = sibling_group_charges(alist, blist)
    if is_sibling_collision:
        sibling_collisions += 1
    for i, verdict in charges:
        reasons[verdict] = reasons.get(verdict, 0) + 1

(tests/harness/py_arbitrary_measure.py, sibling_group_charges and the sibling-pairing guard in main)

A code object counts as recovered only when its normalized instructions match and its argument counts match:

def own_equiv(a, b):
    if norm_instrs(a) != norm_instrs(b):
        return False, "code"
    for attr in ("co_argcount", "co_posonlyargcount", "co_kwonlyargcount"):
        if getattr(a, attr) != getattr(b, attr):
            return False, "sig"
    return True, ""

(tests/harness/py_arbitrary_measure.py, own_equiv)

1.5 Evaluation

Each figure below is stated with its exact corpus, because the corpora differ and must not be conflated. The representative headline is per-code-object recompile-equivalence on the full 574-module CPython 3.14 standard library: 95.09% (17,378 of 18,276 code objects), locked at HEAD 7adfad10. A separate 200-module pinned corpus, a curated subset used as the CI regression sample, runs higher at

96.53% (6,068 of 6,286 code objects), precisely because it over-represents recoverable modules; the

crate's own provenance record labels the full-stdlib number as "the honest representative number (the 200-module pinned corpus over-represents recoverable modules)".

The whole-module exact figure, where a module counts only if every one of its code objects is equivalent, is 123 of the 200 modules in the pinned corpus, 61.5%, and it is measured only on that corpus. That figure is weaker evidence than the per-object one in one way rather than two. It carries its own numerator: MODULES_EXACT_FLOOR = 123 in tests/arbitrary_recompile_gate.rs floors the count of modules whose every code object came back equivalent, in the same CI gate that floors the per-object rate, so a whole-module regression fails the run instead of being printed and forgotten. What stays weaker is the population: it is measured on the pinned 200 rather than on the full stdlib. There is no full-stdlib whole-module figure; since the pinned corpus over-represents recoverable modules, the full-stdlib whole-module rate would be lower still, not higher. The gap between the 95.09% per-object rate and the 61.5% per-module rate is the honest center of the evaluation, not a footnote, and the two numbers are not even on the same corpus: a module passes only when all of its typically dozens of code objects pass, so a small per-object miss rate compounds into a large per-module miss rate. A module with fifty functions and a 92% per-object rate carries under a 2% chance that all fifty pass, so it is all but certain to contain at least one imperfect object, which fails the whole module. Even at the measured pinned per-object rate, fifty independent objects clear together well under a quarter of the time. The per-object figure is the metric that guides improvement because it is granular and monotonic; the per-module figure is the end goal and is deliberately reported as the harder, lower number. These figures are not re-measured here.

The per-object measurement is enforced as a regression gate, not asserted. The CI gate runs the same harness over the 200-module pinned corpus behind both the 96.51% and the 61.5% figures used as regression floors, parses its JSON, and holds the per-object rate above a floor of 96.51%; the full-stdlib 95.09% comes from running that harness over the entire Lib rather than the pinned list:

#![allow(unused)]
fn main() {
const OBJECT_PCT_FLOOR: f64 = 96.51;
}

(tests/arbitrary_recompile_gate.rs, OBJECT_PCT_FLOOR)

#![allow(unused)]
fn main() {
    assert!(
        m.object_pct >= OBJECT_PCT_FLOOR,
        "per-code-object recompile-equivalence regressed: {:.2}% < floor {OBJECT_PCT_FLOOR}% \
         ({}/{} objects on {} modules, CPython {}). The floor is pinned at the exact figure this \
         corpus measures, so any drop is a real regression unless the stdlib sources themselves \
         moved: if this run is on a different 3.14 patch release than the one the floor was pinned \
}

(tests/arbitrary_recompile_gate.rs, arbitrary_recompile_equivalence_gate)

The floor is pinned at the measured figure rather than a round number below it, so the gate has no slack to absorb a regression in silence.

Reproducing the pinned figure takes two commands and one interpreter. Build the CLI the harness drives, then run the gate, which prints the full per-object and per-module breakdown on the way to its assertion:

cargo build --release -p disrobe-cli --bin disrobe
cargo test -p disrobe-pass-py-decompile --test arbitrary_recompile_gate -- --nocapture

CPython 3.14 must be resolvable, because the gate recompiles through the real interpreter and refuses to run without it; CI provisions it with uv python install 3.14 (.github/workflows/ci.yml, the py-recompile-gate job, which is where these two commands are taken from). The full-stdlib figure comes from the same harness pointed at tests/harness/full_modules_314.txt instead of the pinned list.

The legacy line has its own gate over a corpus of 191 vendored fixtures spanning 1.x through 3.x. It grades by a two-verdict union: recompile-equivalence for versions with an available interpreter, and structural token-match otherwise. The proven-correct count measured locally with the full period interpreter set installed is 166 of 191, and it is labeled local because it depends on which 1.0 through 3.7 interpreters are present on the machine; no gate asserts it. The two halves are not equally strong: 67 of those are recompile-equivalent (the strong, behavioral guarantee) and 99 rest on structural token-match (a strictly weaker guarantee that the recovered token stream matches a reference, the verdict recorded wherever the period interpreter cannot recompile the fixture). The CI floor of 150 holds on token-match alone, with a separate token-match floor of 86:

#![allow(unused)]
fn main() {
const PROVEN_CORRECT_FLOOR: usize = 150;
const SOURCE_TOKEN_FLOOR: usize = 86;
}

(tests/legacy_recompile.rs, PROVEN_CORRECT_FLOOR and SOURCE_TOKEN_FLOOR)

#![allow(unused)]
fn main() {
let proven_correct: usize = recompile_equiv + source_match;
assert!(
    proven_correct >= PROVEN_CORRECT_FLOOR,
    "proven-correct regressed: {proven_correct} < floor {PROVEN_CORRECT_FLOOR} \
     (platform-stable: recompile-equiv union token-match, minimum is the pure token-match count)"
);
}

(tests/legacy_recompile.rs, legacy_recompile_correctness_oracle)

The breadth of the version surface is not asserted rhetorically; it is the 30 CPython version-specific opcode tables spanning v1_0 through v3_15, plus the dedicated PyPy tables, all enumerated in map_for (src/bytecode/opcode/mod.rs) as 30 concrete release adapters plus the PyPy overlay, each exercised by the corpus that resolves through them.

The corpus is pinned and version-stable, passed as an explicit module list so the same code objects are measured on every machine (tests/harness/py_arbitrary_measure.py, the --modules argument). The gate measures the real built CLI binary, not an in-process shortcut, and refuses to run without it (tests/arbitrary_recompile_gate.rs, arbitrary_recompile_equivalence_gate). And the grader is the independent Python reimplementation of the normalization, so agreement between the Rust oracle and the Python harness is itself corroboration rather than a single point of trust.

1.6 Limitations

Recovery is not total; it degrades to disassembly. When structuring or emission of a code object throws, the pass emits an annotated disassembly listing and flags recovered_directly = false (src/engine.rs, the fallback arm of decompile_pyc and disasm_fallback_source). Such an object is legible but is not recovered source and would not pass the oracle. The per-object percentages in 1.5 are exactly the fraction that avoids this fallback and recompiles equivalent.

Structural pattern matching is only partially reconstructed on one of the two structuring paths. The frame-dispatch builder for a match frame emits wildcard cases with placeholder patterns and a Pass body rather than recovered patterns:

#![allow(unused)]
fn main() {
cases.push(MatchCase {
    pattern: Pattern::MatchAs {
        pattern: None,
        name: None,
    },
    guard: None,
    body: case_body,
});
}

(src/ast/builder/stmts.rs, the match-frame arm of the frame-dispatch builder)

Full pattern recovery exists on the simulation path (structure_match in branches.rs), so which result a given match receives depends on the route its enclosing region takes; the frame-dispatch path is a skeleton.

Several statement attributes are not recovered on the frame-dispatch path. build_function_def sets decorators to empty, the return annotation to None, and type parameters to empty (src/ast/builder/stmts.rs, build_function_def); these are recovered elsewhere through the marker mechanism, but the plain path does not carry them. Lambdas recovered through build_lambda reduce the body to the first returned or expression value and default to None when neither is present (src/ast/builder/stmts.rs, build_lambda).

Deeply nested or adversarial control flow hits hard caps and errors rather than recursing without bound: structuring depth is limited to 600, nested-code-object depth to 200, frame nesting to 256, and per-region reentry to 4 (src/ast/builder/mod.rs, STRUCTURE_DEPTH_LIMIT, STRUCTURE_REENTRY_LIMIT and CODEOBJ_DEPTH_LIMIT; src/frame_tree/builder.rs, MAX_FRAME_NEST_DEPTH). Input past these limits fails to a StructuringDepthExceeded error and therefore to the disassembly fallback.

Unknown or future opcodes are preserved but not lifted. A byte with no canonical meaning becomes Other(raw, arg) and instrumentation or executor opcodes become Specialized(raw) (src/bytecode/opcode/mod.rs, shared_decode); such an op has no expression semantics and will break recovery of the region that contains it, which is the correct fail-soft behavior but is not recovery.

Finally, the oracle grades bytecode equivalence, not textual identity. A Semantic verdict certifies that the recovered source compiles to the same normalized instructions, not that it is spelled as the original author spelled it. This is the right guarantee for reverse engineering, where behavior is what matters, but it means the numbers in 1.5 measure behavioral fidelity at the granularity of individual code objects, and should be cited as such rather than as a claim of source reproduction.

Where the Python decompiler recovers structured intent from a stack machine, the native path faces the inverse hazard on the way out: even a correctly recovered expression tree becomes wrong source if it is printed without regard to the target grammar.

2. The native decompiler: a typed-AST lift from x86-64 to C and Rust

The native path recovers compilable source from raw machine code. It disassembles an x86-64 function, lifts each instruction into a small typed intermediate representation, reconstructs control flow, and emits two independent surface languages: C and Rust. The design principle that separates this path from a string-concatenating printer is that no target-language text is ever produced by concatenating strings whose meaning depends on the reader guessing operator binding. Every emitted construct passes through a typed abstract syntax tree with a single precedence authority, and the recovered source is graded against the original binary by a recompile-execute-diff oracle that treats a non-recoverable input as an honest skip rather than a silent pass.

This section documents the emission substrate (disrobe-emit), the lift that feeds it (disrobe-pass-native/src/pseudo_c.rs), and the oracle that validates it (disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs).

2.1 Why string-concatenation emission is unsound

The naive way to print a recovered expression is to recurse over a tree and glue operator text together: format!("{lhs} {op} {rhs}"). This is unsound because textual concatenation discards the structural information that decides whether a subexpression needs parentheses. C and Rust are infix languages with roughly fifteen precedence levels and per-level associativity; the meaning of a printed string is a function of that grammar, not of the tree that produced it.

Consider a recovered multiplication whose left operand is itself a recovered addition. The tree is Mul(Add(a, b), c) and denotes (a + b) * c. A concatenating printer that emits each node without consulting the grammar produces a + b * c, which the C parser reads as a + (b * c) because multiplication binds tighter than addition. The recovered text now computes a different value from the machine code it claims to represent. The bug is not a formatting blemish; it is a semantic corruption that a recompile oracle would surface only if that exact operand shape happened to appear in a test. The symmetric failure mode is over-parenthesization, where a printer that wraps every operand in parentheses to be "safe" produces unreadable output and, for associativity-sensitive operators, can still be wrong about which grouping the source expressed.

The precedence and associativity decision is not local to one node. It depends on the pair (child precedence, parent precedence), on the parent's associativity, and on which side (left or right operand) the child occupies. a - (b - c) requires parentheses on the right operand of a left-associative subtraction; (a - b) - c does not require them on the left. A string printer has no place to make this decision correctly and consistently, so in practice it either omits parentheses and produces wrong groupings or adds them everywhere and produces noise.

The typed-AST design removes the failure mode by construction. The recovered program is built as a tree of typed nodes (CExpr, CStmt, CDecl, and their Rust counterparts). Parenthesization is not a property the caller chooses per call site; it is computed once, in one function, from the grammar. Because every operand goes through that one function, the "forgot to parenthesize here" and "parenthesized inconsistently there" classes of bug cannot occur: there is no per-site choice to get wrong.

2.2 The precedence model and its totality

The entire parenthesization policy lives in crates/disrobe-emit/src/precedence.rs, which is 43 lines. Precedence is a single-byte newtype and an atom sentinel:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Precedence(pub u8);

impl Precedence {
    pub const ATOM: Self = Self(u8::MAX);

    #[must_use]
    pub const fn tighter_than(self, other: Self) -> bool {
        self.0 > other.0
    }
}
}

(crates/disrobe-emit/src/precedence.rs, Precedence)

The single authority is parenthesize_operand. It is a const fn and is reproduced here verbatim:

#![allow(unused)]
fn main() {
#[must_use]
pub const fn parenthesize_operand(
    child: Precedence,
    parent: Precedence,
    parent_assoc: Assoc,
    side: Side,
) -> bool {
    if child.0 > parent.0 {
        false
    } else if child.0 < parent.0 {
        true
    } else {
        match (parent_assoc, side) {
            (Assoc::Left, Side::Left) | (Assoc::Right, Side::Right) => false,
            (Assoc::Left, Side::Right) | (Assoc::Right, Side::Left) | (Assoc::None, _) => true,
        }
    }
}
}

(crates/disrobe-emit/src/precedence.rs, parenthesize_operand)

The signature is exactly parenthesize_operand(child, parent, parent_assoc, side). The function is total, and the totality is what makes the design trustworthy. child and parent are Precedence(u8) values, so child.0 and parent.0 are two u8s; they are either strictly ordered or equal. The first branch (child.0 > parent.0) handles the case where the child binds tighter than the parent and therefore never needs parentheses. The second branch (child.0 < parent.0) handles the child binding looser, which always needs parentheses. The else handles equal precedence, where the answer depends on associativity and side. That final match is exhaustive over (Assoc, Side): Assoc has the three variants Left, Right, None and Side has the two variants Left, Right, so the pair ranges over six combinations, and the two match arms partition all six. There is no wildcard hiding an unhandled case and no fallthrough default; the Rust compiler's exhaustiveness check is itself the proof that every operand pair has a defined rule. A left operand of a left-associative parent needs no parentheses; a right operand does. The mirror holds for right-associative operators. A non-associative parent parenthesizes either side. This is precisely the C and Rust grouping rule, encoded once.

The concrete precedence levels are assigned in the C printer, crates/disrobe-emit/src/c/print.rs, as fifteen constants from P_COMMA = Precedence(0) through P_POSTFIX = Precedence(14), matching the C operator-precedence table. The atom level Precedence::ATOM = Precedence(u8::MAX) sits above all of them, so literals and identifiers are never parenthesized. The typed C AST carries 18 binary operators (BinaryOp, crates/disrobe-emit/src/c/ast.rs), 8 unary operators (UnaryOp, ast.rs), and 11 compound-assignment operators (AssignOp, ast.rs); every one of these maps to a precedence and associativity through binary_precedence (c/print.rs) and is routed through parenthesize_operand by the operand_doc helper (c/print.rs) before it is printed. The worked (a + b) * c example resolves as follows: the additive child has precedence P_ADDITIVE = 11, the multiplicative parent has P_MULTIPLICATIVE = 12, so child.0 < parent.0 fires the second branch and the operand is parenthesized, yielding (a + b) * c rather than the corrupted a + b * c.

The rule is validated by a property test rather than by the tool grading its own output. In crates/disrobe-emit/tests/c_precedence.rs, check_reparse renders a randomly generated CExpr, parses the rendered text back with an independent C-expression parser, and asserts the parsed tree is structurally equal to the original (c_precedence.rs, check_reparse), across 2048 generated cases in both minimal and full parenthesization modes. A precedence bug that dropped or misplaced a parenthesis would change the reparsed tree and fail the round trip. A companion invariant asserts that minimal-mode output is never longer than full-mode output, so the minimal policy cannot secretly over-parenthesize.

The limit of this guarantee is the boundary of the typed AST. It holds for every operand the emitter routes through parenthesize_operand, which is every operand of every node the emitter constructs. It says nothing about text that reaches the printer already rendered: the native lift splices some composite subexpressions in as pre-rendered fragments, and those are covered by a different and cruder mechanism, unconditional parentheses at the splice point, described in Section 2.5.

2.3 The C AST and the inside-out declarator

C declarator syntax is the classic difficulty of any C emitter. A declaration reads "inside out" and mixes prefix (*) and postfix ([], ()) constructors with parenthesis grouping whose placement follows the spiral rule, so a pointer to an array is written int (*x)[10] while an array of pointers is int *x[10]. Getting the parentheses wrong changes the declared type.

The recovered type is not a string; it is a linked constructor chain, crates/disrobe-emit/src/c/ast.rs, DeclaratorChain:

#![allow(unused)]
fn main() {
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum DeclaratorChain {
    Terminal,
    Pointer {
        quals: CQuals,
        to: Box<Self>,
    },
    Array {
        of: Box<Self>,
        size: Option<Box<CExpr>>,
    },
    Function {
        returns: Box<Self>,
        params: Vec<CParam>,
        variadic: bool,
    },
}
}

A TypeName and a CDecl each carry a CBaseType (the leaf specifier, for example int) plus a DeclaratorChain. The chain is built with the fluent constructors pointer_to, array_of, and returning (ast.rs), so the type "pointer to function returning pointer to array of int" is assembled by chaining those calls, and the enum's Box<Self> recursion mirrors the inside-out nesting directly.

Printing is a recursion that threads an accumulator representing "the declarator built so far" from the innermost constructor outward, and inserts grouping parentheses exactly where the spiral rule demands them, crates/disrobe-emit/src/c/print.rs, declarator_doc:

#![allow(unused)]
fn main() {
fn declarator_doc<'a>(ctx: &Ctx<'a>, chain: &'a DeclaratorChain, acc: Doc<'a>) -> Doc<'a> {
    let arena: &'a Arena<'a> = ctx.arena;
    match chain {
        DeclaratorChain::Terminal => acc,
        DeclaratorChain::Pointer { quals, to } => {
            let mut head: Doc<'a> = arena.text("*");
            if !quals.is_empty() {
                head = head
                    .append(qualifier_suffix_doc(ctx, *quals))
                    .append(arena.space());
            }
            let inner: Doc<'a> = head.append(acc);
            let wrapped: Doc<'a> = if matches!(
                to.as_ref(),
                DeclaratorChain::Array { .. } | DeclaratorChain::Function { .. }
            ) {
                inner.parens()
            } else {
                inner
            };
            declarator_doc(ctx, to, wrapped)
        }
        DeclaratorChain::Array { of, size } => {
            let bracket: Doc<'a> = size.as_deref().map_or_else(
                || arena.text("[]"),
                |expr: &CExpr| operand_min_doc(ctx, expr, P_ASSIGN).brackets(),
            );
            declarator_doc(ctx, of, acc.append(bracket))
        }
        DeclaratorChain::Function {
            returns,
            params,
            variadic,
        } => {
            let list: Doc<'a> = params_doc(ctx, params, *variadic);
            declarator_doc(ctx, returns, acc.append(list.parens()))
        }
    }
}
}

The grouping decision is the single if matches!(...) guard: a pointer whose target is an array or a function must wrap the accumulated declarator in parentheses, because otherwise the postfix [] or () of the target would bind tighter than the prefix * and change the type. Array element size expressions are themselves printed through the precedence machinery (operand_min_doc(ctx, expr, P_ASSIGN)), so a computed array bound cannot be miswritten.

The output layer is a Wadler-Leijen document algebra (the pretty crate), and the printer lowers the whole file to a Doc before rendering (c/print.rs). Operator operands are parenthesized through operand_doc and operand_min_doc, which call parenthesize_operand from Section 2.2, so the expression printer and the declarator printer share the one precedence authority. The declarator logic is checked by a golden oracle, crates/disrobe-emit/tests/c_cc_oracle.rs, declarator_spiral_golden, whose eight cases include the adversarial spiral int (*(*x)(int))[5]; (a pointer to a function returning a pointer to an array of int), and by a proptest that generates random valid declarator chains and requires a real C compiler to accept every one of them under ProptestConfig::with_cases(64) (c_cc_oracle.rs). The compiler, not the tool, is the judge.

2.4 The Rust path via syn and prettyplease

The Rust emitter does not print Rust text at all. It constructs syn AST nodes (the same data model the wider Rust ecosystem uses to represent parsed Rust) and hands the assembled syn::File to prettyplease to unparse. The delegation is the whole of the render layer in crates/disrobe-emit/src/rust/render.rs, which carries nothing beyond these two functions and a parse_expr helper:

#![allow(unused)]
fn main() {
use syn::Expr;

use crate::rust::builder::{file, function, trailing_expr};

#[must_use]
pub fn render(file: &syn::File) -> String {
    prettyplease::unparse(file)
}

#[must_use]
pub fn render_expr(expr: &Expr) -> String {
    let wrapped: syn::File = file(vec![function(
        "__disrobe_emit_expr",
        Vec::new(),
        None,
        vec![trailing_expr(expr.clone())],
    )]);
    let rendered: String = render(&wrapped);
    let open: usize = rendered.find('{').map_or(0, |idx: usize| idx + 1);
    let close: usize = rendered.rfind('}').unwrap_or(rendered.len());
    rendered[open..close]
        .split_whitespace()
        .collect::<Vec<&str>>()
        .join(" ")
}
}

(crates/disrobe-emit/src/rust/render.rs)

syn syn and prettyplease prettyplease are widely used across the Rust ecosystem, not a bespoke component of this project. syn is the de facto Rust parser used by procedural macros; prettyplease is dtolnay's unparser that formats a syn AST in a rustfmt-style layout. (The rustc compiler itself uses its own internal AST pretty-printer, not prettyplease; the claim here is ecosystem ubiquity, not that this is what the compiler emits.) The correctness argument is the same one that motivates the typed C AST: reusing a battle-tested parser and formatter guarantees that any expression the builder can assemble is valid, canonically formatted Rust, because prettyplease inserts grouping according to the Rust grammar it was written against. There is no hand-rolled Rust precedence table to keep in sync with the language, and there is no possibility of the recovered Rust failing to parse because of a printer bug. render_expr reuses that guarantee even for a bare expression by wrapping it in a throwaway function, unparsing, and slicing the body back out, so a lone recovered expression is still formatted by the same trusted path.

The builder in crates/disrobe-emit/src/rust/builder.rs is a set of thin, fully typed constructors over syn node types (binary, unary, cast, method_call, if_else, let_stmt, function, and so on), each returning a syn::Expr, syn::Stmt, syn::Type, or syn::Item. Because the builder produces syn nodes rather than text, the emitted Rust is validated the moment prettyplease accepts it, and the round trip is itself property tested: crates/disrobe-emit/tests/rust_roundtrip.rs contains two proptests (render_reparse_is_a_fixpoint and render_reparse_preserves_tree) that render a generated expression, reparse it with syn, and require the tree to survive.

2.5 The lift: x86-64 into the typed AST

The lift lives in crates/disrobe-pass-native/src/pseudo_c.rs and is entered through recover_leaf_function_calls_impl (pseudo_c.rs). The pipeline has four stages: disassemble, lift per instruction into a private IR, structure the control flow, then emit C and Rust from the structured tree.

Disassembly is delegated to the crate's iced-x86-backed decoder, disassemble(Arch::X86_64, base, machine_code) (pseudo_c.rs, build_leaf_items), which yields textual mnemonic and operand fields per instruction. The lift then walks the instruction stream and folds each instruction into a small typed IR: registers are modeled as a Reg enum with a separate Width (W8/W16/W32/W64), memory operands as a MemRef with base, scaled index, displacement, and access width, and each semantic effect as a Stmt variant (pseudo_c.rs, enum Stmt). Register operands are parsed by name into a (Reg, Width) pair by parse_reg (pseudo_c.rs), so eax and rax resolve to the same Reg::Rax with widths W32 and W64; this is what lets the emitter model sub-register writes as masked updates of a single 64-bit variable.

Instruction selection is a cascade of guarded lifters. The core arithmetic and data-movement opcodes are handled by lift_one (pseudo_c.rs), which recognizes mov, lea, the add/sub/imul/and/or/xor/shl/sal/shr/sar family, inc/dec, neg/not, mul, and the shld/shrd and three-operand imul forms, dispatching each to a Stmt. One idiom: xor reg, reg and sub reg, reg are recognized as a zeroing and lowered to Assign { dest, src: Imm(0) } (pseudo_c.rs, lift_one) rather than to a literal self-subtraction, matching what the compiler meant.

Width extension is handled by lift_width_extension (pseudo_c.rs):

#![allow(unused)]
fn main() {
fn lift_width_extension(mnemonic: &str, operands: &str) -> Option<Stmt> {
    if mnemonic == "cdqe" {
        if !operands.trim().is_empty() {
            return None;
        }
        return Some(Stmt::Extend {
            dest: RegRef {
                reg: Reg::Rax,
                width: Width::W64,
            },
            src: ExtSource::Reg(RegRef {
                reg: Reg::Rax,
                width: Width::W32,
            }),
            signed: true,
        });
    }
    let signed: bool = match mnemonic {
        "movzx" => false,
        "movsx" | "movsxd" => true,
        _ => return None,
    };
    let (lhs, rhs): (&str, &str) = operands.split_once(',')?;
    let dest: RegRef = parse_reg(lhs.trim())?;
    let rhs_tok: &str = rhs.trim();
    if is_mem_token(rhs_tok) {
        let implied: Option<Width> = (mnemonic == "movsxd").then_some(Width::W32);
        let mem: MemRef = parse_mem_access(rhs_tok, implied)?;
        if mem.width >= dest.width {
            return None;
        }
        return Some(Stmt::Extend {
            dest,
            src: ExtSource::Mem(mem),
            signed,
        });
    }
    let src: RegRef = parse_reg(rhs_tok)?;
    if src.width >= dest.width {
        return None;
    }
    Some(Stmt::Extend {
        dest,
        src: ExtSource::Reg(src),
        signed,
    })
}
}

Three width-changing forms are captured here. cdqe sign-extends eax (32-bit) into rax (64-bit) and is lowered to a signed Stmt::Extend from W32 to W64. movzx is a zero-extend, so signed = false. movsx and movsxd are sign-extends, so signed = true. The guard if src.width >= dest.width { return None; } (and its memory-operand twin) is a soundness check, not an optimization: a widening move whose source is not strictly narrower than its destination is not the extension idiom being modeled, so the lifter declines to lift it rather than emit a guess. The signed flag is carried into Stmt::Extend and later realized by extend_expr (pseudo_c.rs), which builds the mask-and-cast chain in the typed C AST. Both chains start by masking the source to its own width, and they differ after that, because a zero-extend needs no signed step. Unsigned is two casts, uint{src}_t then uint{dest}_t. Signed is three, int{src}_t to carry the sign, then int{dest}_t to widen it, then uint{dest}_t as the storage type. The final cast is to the unsigned type of the destination width, which is 64-bit only when the destination is, so a movzx eax, bl ends at uint32_t. Sign correctness of a recovered movsx versus movzx is therefore a single boolean threaded from decode to the typed cast, and the oracle in Section 2.6 has a dedicated teeth test that flips it.

After the linear lift, structure_items (pseudo_c.rs) reconstructs structured control flow from the branch and jump items: it builds a basic-block CFG, computes dominators and post-dominators, detects natural loops and reducible regions, and rebuilds if/else, do/while, top-guarded while, and dense switch constructs as a tree of Node values (pseudo_c.rs, enum Node). Conditions carry the originating comparison flags, and the lifter tracks flag liveness so that a conditional branch, cmov, or setcc with no live preceding comparison is rejected rather than lifted against stale flags (pseudo_c.rs, build_leaf_items).

Emission then lowers the structured Node tree into the typed AST. node_to_cstmt (pseudo_c.rs) maps each control node to a CStmt (CStmt::If, CStmt::DoWhile, CStmt::While, CStmt::Switch), and stmt_to_cstmt (pseudo_c.rs) maps each IR Stmt to a CStmt, constructing CExpr nodes such as CExpr::Binary, CExpr::Cast, CExpr::Ternary, and CExpr::Unary. Every statement is rendered through render_stmt, which is the typed printer of Sections 2.2 and 2.3. The Rust emitter (emit_rust, pseudo_c.rs) walks the same structured tree and produces the pure-safe Rust subset, returning None for constructs it does not model as safe Rust (struct returns, block string operations, and any vector operand or vector-typed signature), which is how the two targets stay independent while sharing one lift.

The emitter is a hybrid: statement and top-level expression nodes are typed AST values, but some composite subexpression fragments are assembled as rendered strings and reinserted as opaque operands through c_opaque (pseudo_c.rs), which wraps the fragment in explicit parentheses (({text})) before interning it as an identifier atom. The consequence is that the precedence authority governs every AST-node boundary it constructs, and any pre-rendered string fragment is defensively fully parenthesized at its splice point, so neither path can produce a wrong grouping. The masked sub-register write helper reg_write_rhs (pseudo_c.rs), the binary-operator helper bin_expr (pseudo_c.rs), and the address helper addr_expr (pseudo_c.rs) all build CExpr trees and thread their results through this parenthesized-fragment convention.

2.6 The oracle: recompile, execute, differential, and the honesty property

Recovery is graded against the original binary, never against the tool's own output. The oracle is crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs. The base flow, in process_case and leaf_functions_recompile_to_behavioral_equivalence, is:

  1. Compile a hand-authored C battery to a real object file with a real compiler (gcc/clang/cc) at -O1 -fno-stack-protector.
  2. Locate each battery function's machine code in the object by symbol, using the object crate to slice the exact byte range (function_code).
  3. Lift the bytes with recover_leaf_function_abi.
  4. Emit a driver that calls both the original function (linked from the compiled battery object) and the recovered function rec_* over a fixed vector of adversarial inputs, masks both results to the recovered return width, and prints MISMATCH ... return 1 on any disagreement and OK only if every input agrees (build_driver).
  5. Compile and link that driver against the original battery object, run it, and assert the process exits successfully with OK on stdout.

The ground truth is the executed behavior of the compiler's own output for the same source, so the oracle cannot be satisfied by a recovered function that merely looks plausible; it must compute the same values as the original machine code on every probe input.

The honesty property is in step 3. When the lifter cannot soundly recover an input, it returns Err, and process_case converts that into a skip, not a pass:

#![allow(unused)]
fn main() {
    let recovery: LeafRecovery = match recover_leaf_function_abi(&code, base, abi) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("skip {} ({abi:?}): not in leaf class ({e})", case.name);
            return None;
        }
    };
}

(pseudo_c_leaf_oracle.rs, process_case)

A skipped case contributes nothing to the differential; it is neither counted as recovered nor asserted to be correct. If a compiler build happens to lower none of the battery into the leaf class, the whole test skips with an explanatory message rather than passing vacuously. The lifter reaches Err on any input it cannot model soundly: an absent ret ("no ret found; not a single-exit leaf"), a division without a modeled high-half dividend setup ("division at {:#x} without a tracked high-half dividend setup"), a conditional set or branch with no live compare ("setcc without preceding flags"), a width move that is not the extension idiom (Section 2.5), a backward string compare or an unbounded single string op ("unbounded single string op ... has no rep count"), and an ordering cmov that selects a compared operand against their own difference ("ordering cmov selecting a compared operand against their difference is not soundly recoverable"), all in pseudo_c.rs, build_leaf_items. Each of these is a refusal to emit a guess, and each is what turns a green oracle into an honest one.

The differential is proven non-vacuous by 23 companion "teeth" tests. Each takes a genuinely recovered function, mutates one recovered constant, and asserts the harness now reports MISMATCH rather than OK. The read-modify-write teeth test is representative (read_modify_write_oracle_has_teeth_perturbing_the_or_mask_diverges): it recovers a fused or [mem], imm, confirms the recovered OR mask literal 23205LL is present, replaces it with 10837LL, and requires the perturbed harness to diverge:

#![allow(unused)]
fn main() {
    let corrupted: String = recovered_decls.replacen("23205LL", "10837LL", 1);
    ...
    assert!(
        !stdout.contains("OK") && stdout.contains("MISMATCH"),
        "teeth check FAILED: perturbing the OR mask must diverge, got: {stdout}\nstderr: {}",
        String::from_utf8_lossy(&run.stderr)
    );
}

(pseudo_c_leaf_oracle.rs, read_modify_write_oracle_has_teeth_perturbing_the_or_mask_diverges)

Analogous teeth tests flip movsx sign-extension to zero-extension, swap division signedness, perturb a floating-point constant, relabel a switch case, corrupt a bitcast, and negate a setcc predicate; each confirms that the corresponding recovered detail is load bearing and that the oracle would catch a regression in it. Loop and control-flow classes run their harness under a wall-clock watchdog (run_bounded) that kills a non-terminating process and fails the test rather than hanging, so a recovered loop with a wrong exit condition surfaces as a bounded failure.

2.7 Scope and boundaries

The claims above are bounded to what the code actually does, which is recovery of single-exit leaf functions graded by a hand-authored battery.

The oracle corpus is a battery of small functions written by hand, not a sweep over arbitrary stripped binaries. The base integer battery is 22 cases (BATTERY, pseudo_c_leaf_oracle.rs), covering arithmetic, mixed-width expressions, min/max and clamp idioms, sign selection, and eight operator-precedence shapes; the file defines many further batteries (memory access, read-modify-write, control flow, split returns, natural and nested loops, guarded while, width extension, same-object and precise calls, closed-form multiply-shift, divide, scalar float arithmetic, min/max, square root, rounding, bitcast, dense and floating-point switch tables, block move and fill, setcc, stack spills, and struct returns). The C oracle file contains 102 #[test] functions and the Rust oracle file (crates/disrobe-pass-native/tests/pseudo_rust_leaf_oracle.rs) contains 12; these are backed in the emission crate by 7 precedence and declarator tests, 3 in c_precedence.rs (the two reparse round trips and the minimal-never-wider invariant) and 4 in c_cc_oracle.rs (the 8-case declarator spiral golden, a type-name and storage-class golden, a declarations-and-items case that a real C compiler must accept, and the with_cases(64) declarator proptest that the same compiler judges), and by the 2 proptests in rust_roundtrip.rs, one asserting render-reparse is a fixpoint and one asserting it preserves the tree. Both the MS x64 and the System V ABIs are exercised, with the host-native class gated to Windows and the cross-platform System V floor carried by clang cross-compilation guards on Linux.

What the native leaf path does not do is equally on the record. It recovers single-exit leaf functions: the lifter requires a ret and treats a function it cannot reduce to a single structured exit as out of class. It targets x86-64 only. The Rust emitter is a strict subset of the C emitter; it declines struct-returning functions, block string operations, and anything carrying a vector operand by returning None, so a function can be C-recoverable without being in the pure-safe Rust class. Recovery of instructions outside the modeled set, of irreducible control flow, and of anything the soundness guards reject is reported as Err, which the harness records as a skip. The path makes no claim to recover whole stripped programs, obfuscated or virtualized code, or functions whose behavior is not present in the static instruction stream. Its claim is narrow and verified: for the modeled class, the recovered C and Rust recompile and execute to the same behavior as the original machine code, and every input outside that class is refused rather than faked.

The native path grades a recovered function against the behavior of the compiler's own output; managed-VM devirtualization raises the same non-circularity demand against a harder adversary, a protector that deletes the original code entirely and ships a randomized interpreter in its place.

3. Managed-VM devirtualization: recovering CIL from bytecode-virtualized .NET assemblies

A bytecode-virtualizing protector does not encrypt a method and decrypt it at runtime. It deletes the method's Common Intermediate Language (CIL) entirely and replaces the body with a stub that hands a stream of custom virtual-machine (VM) bytecode to an interpreter shipped inside the same assembly. The interpreter's opcode table, register layout, and dispatch structure are randomized per build. Recovering the original method therefore means reconstructing three separate objects from static bytes alone: the per-build instruction encoding, the virtual program for each protected body, and a lifting from the VM's stack or register semantics back to CIL. disrobe implements this end to end for two managed VM schemes: an in-repo reimplementation of Eazfuscator.NET's EazVM (the graded sample is encoded by our own virtualizer, not the shipping Eazfuscator.NET product) and the real ConfuserEx-lineage KoiVM (whose sample is the genuine tool's output). It also records honest information-theoretic walls for the protectors whose bodies leave the static file altogether.

The .NET pass classifies twenty-three protector families. The canonical enumeration is fixed in the detector:

#![allow(unused)]
fn main() {
pub const ALL: [Self; 23] = [
    Self::ConfuserEx,
    Self::ConfuserEx2,
    ...
    Self::KoiVm,
    Self::BitMono,
];
}

(crates/disrobe-pass-dotnet/src/protectors.rs, Protector::ALL)

Each family carries a static handling verdict, drawn from Handling: De4dotDelegate, NativeStrip, GatedDe4dotDelegate, Devirtualize, or DetectOnly (protectors.rs, enum Handling). Only KoiVM is tagged Handling::Devirtualize (protectors.rs, Protector::handling); ConfuserEx2's non-VM layers delegate to the de4dot-class cleaner while its constant protection is recovered in-crate; ILProtector, MaxToCode, and the Themida .NET wrapper are DetectOnly, for reasons Section 3.4 grounds in the location of the plaintext.

3.1 The circularity hazard and the independent-baseline discipline

A common failure mode in devirtualization work is a circular oracle. If a devirtualizer is graded by feeding its own recovered IL back through its own lifter, or by comparing against a "ground truth" that was itself produced by the tool under test, the measurement asserts nothing: a lifter that drops half the program will still agree with itself. A green number obtained this way is not evidence of recovery; it is evidence that a function equals itself.

disrobe's EazVM oracle refuses that shortcut. The virtualized sample and its expected answer come from two physically distinct assemblies. EazSample.eazvm.dll is the protected image the devirtualizer consumes, encoded by an in-repo reimplementation of the EazVM scheme (corpus/dotnet/eazvm/virtualizer/) rather than the shipping Eazfuscator.NET product; the answer key it is graded against is independent of disrobe's lifter, so the reimplemented encoder does not make the measurement circular. EazSample.clean.dll is the same source compiled without the protector; it still contains real, compiler-emitted CIL. The test parses the clean DLL's method bodies directly and treats that CIL, in program order, as the answer key:

#![allow(unused)]
fn main() {
let vm: Vec<u8> = corpus("EazSample.eazvm.dll");
let clean: Vec<u8> = corpus("EazSample.clean.dll");
let recovery: EazVmRecovery = devirtualize(&vm).expect("devirtualize");
...
let known: BTreeMap<String, Vec<OrderedInstr>> = known_method_ordered(&clean, "Compute");
}

(crates/disrobe-pass-dotnet/tests/real_eazvm.rs, devirtualizes_every_method_to_ordered_cil)

known_method_ordered runs disrobe's ordinary CIL parser over the clean image; it never touches the devirtualizer. It builds each expected instruction from the clean method body and resolves branch targets against the clean method's own offset map:

#![allow(unused)]
fn main() {
let body: MethodBody = parse_method_body(image.get(off..)?).ok()?;
let offset_to_index: BTreeMap<u32, usize> = body
    .instructions
    .iter()
    .enumerate()
    .map(|(i, ins): (usize, &Instruction)| (ins.offset, i))
    .collect();
}

(crates/disrobe-pass-dotnet/src/peel/eazvm/grade.rs, known_method_ordered)

Because the answer key is emitted by the C# compiler into a separate file that the recovery code has no access to, agreement between the recovered body and the clean body cannot be manufactured by the recovery code. The comparison is also ordered, not a bag of mnemonics: instruction i of the recovered body must equal instruction i of the clean body, so a lifter that recovers the right multiset in the wrong order fails.

The discipline has a second, negative half: the unobfuscated baseline must not be mistaken for a VM. The same test asserts that the clean DLL exposes no dispatch table and that attempting to devirtualize it is an error, which guards against a detector that fires on ordinary managed code:

#![allow(unused)]
fn main() {
assert!(
    !d.dispatch_table_present,
    "the unobfuscated baseline must not expose a VM dispatch table"
);
assert_eq!(d.stub_count, 0);
assert!(devirtualize(&image).is_err());
}

(real_eazvm.rs, clean_assembly_is_not_seen_as_eazvm)

3.2 Devirtualization approach per protector

disrobe devirtualizes two managed VMs, and their machine models and encodings differ enough that each gets its own decode-and-lift pipeline: EazVM is a stack machine whose bodies live in an encrypted embedded resource, and KoiVM is a register machine whose bodies live in a #Koi metadata stream permuted by a per-build seed. ConfuserEx2's remaining non-VM protections are recovered by a third, static path.

3.2.1 Eazfuscator.NET (EazVM)

EazVM's protected assembly carries an embedded resource named EazVirtualizedStream holding every virtualized body as a position-keyed, encrypted virtual-instruction stream (RESOURCE_NAME in crates/disrobe-pass-dotnet/src/peel/eazvm/mod.rs, read by read_embedded_resource). Recovery proceeds in four stages: build the opcode map, locate the stubs, decrypt each body's region, and lift.

Opcode map. The per-build map from a virtual code to a CIL operation is not guessed. disrobe locates the interpreter's dispatch-table constructor by name, reads the (virtual-code, handler-method-token) pairs it installs, and then identifies each handler:

#![allow(unused)]
fn main() {
let dispatch: MethodModel =
    find_dispatch_table(&model).ok_or(DispatchError::NoDispatchTable)?;
let dispatch_body: MethodBody =
    read_body(image, pe, dispatch.rva).ok_or(DispatchError::NoDispatchTable)?;
let pairs: Vec<(i32, u32)> = dispatch_pairs(&dispatch_body);
}

(crates/disrobe-pass-dotnet/src/peel/eazvm/dispatch.rs, recover_opcode_map)

dispatch_pairs pattern-matches the constructor's own CIL: an ldc.i4 that supplies the virtual code, immediately followed by an ldftn that supplies the handler method token (dispatch.rs, dispatch_pairs). Each handler is then classified by a fingerprint constant embedded in its body: disrobe finds the tagged constant and matches it against a fingerprint computed from the CIL mnemonic:

#![allow(unused)]
fn main() {
fn identify_handler(body: &MethodBody) -> Option<CilOp> {
    let target: i32 = body
        .instructions
        .iter()
        .filter_map(ldc_i4_value)
        .find(|v: &i32| (*v & 0x1000_0000) != 0)?;
    HANDLED_OPS
        .into_iter()
        .find(|op: &CilOp| handler_fingerprint(op.handler_key()) == target)
}
}

(dispatch.rs, identify_handler)

where handler_fingerprint is a 28-bit-masked FNV hash of HANDLER:{mnemonic} with a high tag bit (dispatch.rs, handler_fingerprint). The identified table spans the 48 operations in HANDLED_OPS (dispatch.rs, HANDLED_OPS), and the oracle confirms all 48 are resolved (real_eazvm.rs, detect_reports_full_vm_structure). This fingerprint-keyed identification is how disrobe's corpus sample encodes handler identity; the surrounding pipeline (dispatch discovery, pair extraction, stream decode, lift, name resolution) is the general mechanism.

Stub location and body decryption. A virtualized method is recognized structurally: it carries an ldstr of an encrypted position string, at least two ldc.i4 constants (the resource and position keys), and at least three pop operations (is_vm_stub, dispatch.rs). The position string is decrypted with candidate keys harvested from the stub itself, and the resulting offset selects the body's region inside the decrypted resource. The full per-body decode chain is:

#![allow(unused)]
fn main() {
fn decode_one(
    encrypted_resource: &[u8],
    resource_key: i32,
    position: i64,
    map: &OpcodeMap,
) -> Option<(EazMethodInfo, LiftedBody)> {
    let start: u64 = u64::try_from(position).ok()?;
    let remaining: usize = encrypted_resource
        .len()
        .checked_sub(usize::try_from(start).ok()?)?;
    let region: Vec<u8> = decrypt_region(encrypted_resource, resource_key, start, remaining)?;
    let info: EazMethodInfo = parse_method_info(&region).ok()?;
    let virtuals: Vec<VirtualInstr> = decode_stream(&info.code, map).ok()?;
    let lifted: LiftedBody = lift(&virtuals).ok()?;
    Some((info, lifted))
}
}

(mod.rs, decode_one)

Stream decode. The virtual instruction stream is not little-endian. Each virtual opcode is a 32-bit word in a byte permutation disrobe reads with read_int32_special, then the operand is decoded per the opcode's operand class:

#![allow(unused)]
fn main() {
let virtual_code: i32 =
    read_int32_special(code, pos).ok_or(DecodeError::Truncated(virtual_offset))?;
pos += 4;
let op: CilOp = map
    .get(virtual_code)
    .ok_or(DecodeError::UnknownVirtualCode(virtual_code, virtual_offset))?;
}

(crates/disrobe-pass-dotnet/src/peel/eazvm/disasm.rs, decode_stream)

#![allow(unused)]
fn main() {
let value: u32 = (u32::from(b[3]) << 24)
    | u32::from(b[2])
    | (u32::from(b[1]) << 8)
    | (u32::from(b[0]) << 16);
}

(crates/disrobe-pass-dotnet/src/peel/eazvm/opcodes.rs, read_int32_special)

Operand classes cover inline i8/i32 immediates, byte/word variable indices, a short branch (stored as a full i32 stream offset), and inline member and string tokens (opcodes.rs, the operand-class table; disasm.rs, decode_stream).

Lift. Lifting resolves each decoded branch offset to a target index in the recovered instruction list, converting stream offsets into structured control flow:

#![allow(unused)]
fn main() {
DecodedOperand::Branch(target) => {
    let dest: usize = *index_by_offset
        .get(target)
        .ok_or(LiftError::UnresolvedBranch(*target))?;
    LiftedOperand::BranchTo(dest)
}
}

(crates/disrobe-pass-dotnet/src/peel/eazvm/lift.rs, lift)

An unresolved branch is a hard error, not a silent drop, so a partially decoded body cannot masquerade as complete.

3.2.2 KoiVM (the ConfuserEx / ConfuserEx2 VM)

KoiVM is a register-machine VM originally distributed as a ConfuserEx protection. Its virtualized bodies live in a metadata stream named #Koi, and its opcode, register, and virtual-call tables are permuted by a per-build seed fed to .NET's System.Random. disrobe reconstructs all three.

Faithful RNG. The descriptor tables can only be regenerated if the pseudo-random shuffle exactly matches .NET's. disrobe reimplements the framework's subtractive Random generator (Knuth) and pins it against real System.Random(0) output:

#![allow(unused)]
fn main() {
let mut r: NetRandom = NetRandom::new(0);
let got: [i32; 10] = core::array::from_fn(|_| r.next_bounded(1000));
let want: [i32; 10] = [726, 817, 768, 558, 206, 558, 906, 442, 977, 273];
assert_eq!(got, want, "NetRandom must match real System.Random(0)");
}

(crates/disrobe-pass-dotnet/src/peel/koivm/random.rs, next_bounded_matches_net_framework_seed0)

The descriptors are then derived by shuffling identity arrays with that generator, exactly as the protector does at build time:

#![allow(unused)]
fn main() {
let mut opcode_order: [u8; 256] = core::array::from_fn(|i: usize| i as u8);
rng.shuffle(&mut opcode_order);
...
let mut opcode_decode: [Option<KoiOp>; 256] = [None; 256];
for ordinal in 0u8..KOI_OP_MAX {
    let encoded: u8 = opcode_order[usize::from(ordinal)];
    opcode_decode[usize::from(encoded)] = KoiOp::from_ordinal(ordinal);
}
}

(crates/disrobe-pass-dotnet/src/peel/koivm/descriptors.rs, KoiDescriptors::from_seed)

The generated map is anchored against the real KoiVM's seed-0 tables (descriptors.rs and random.rs, the seed-0 table tests), so the encoding disrobe decodes is the encoding the protector emitted, not a plausible-looking guess.

Encrypted stream decode and CFG recovery. Each #Koi byte is XOR-decrypted with a rolling key that mutates after every byte, seeded per basic block by the block's entry key:

#![allow(unused)]
fn main() {
const fn decrypt(&mut self, cipher_byte: u8) -> u8 {
    let plain: u8 = cipher_byte ^ self.key;
    self.key = self.key.wrapping_mul(7).wrapping_add(plain);
    plain
}
}

(crates/disrobe-pass-dotnet/src/peel/koivm/disasm.rs, decrypt)

disassemble_method walks the control-flow graph from the export's entry offset with a worklist, decoding one block at a time and following the exit key into each successor, so the per-block cipher state stays correct across jumps (disasm.rs, disassemble_method). Terminators (Jmp, Jz, Jnz, Ret, Leave, Swt) end a block and enumerate its successors with their inherited keys (disasm.rs, the terminator classification).

Lift. KoiVM is a register machine over a stack discipline. disrobe interprets the block abstractly, tracking a value stack and register file, recognizing the BP-relative address arithmetic that encodes frame slots, and classifying each slot as an argument or local:

#![allow(unused)]
fn main() {
const fn classify(self, slot: i32) -> Value {
    if slot < 0 {
        let arg_base: i32 = -(self.arg_count.cast_signed() + 1);
        let index: i32 = slot - arg_base;
        if index >= 0 && index.cast_unsigned() < self.arg_count {
            return Value::Arg(index.cast_unsigned());
        }
    } else if slot > 0 {
        let local_index: i32 = slot - 1;
        if local_index >= 0 {
            return Value::Local(local_index.cast_unsigned());
        }
    }
    Value::FrameAddr(slot)
}
}

(crates/disrobe-pass-dotnet/src/peel/koivm/lift.rs, classify)

Indirect loads and stores against a recovered frame address emit LoadArg/LoadLocal/StoreArg/StoreLocal; arithmetic emits typed binary operations; Vcall codes resolve through the seed-derived virtual-call table to LoadField, StoreField, LoadToken, LoadString, Throw, or a named runtime service, and member operands resolve through the #Koi coded-token map back to real metadata tokens (lift.rs, lift_block). A virtual-call code with no table entry surfaces as Unknown and is counted, never dropped; the lifter asserts zero unknown ops on every real body (lift.rs, all_real_methods_report_zero_unknown_ops).

3.2.3 ConfuserEx2 non-VM layers

ConfuserEx2 proper virtualizes through the KoiVM path above; its remaining protections are a constant-encryption pool and control-flow flattening. disrobe recovers the constant pool statically rather than delegating it. The encrypted blob is located via the class-layout and FieldRVA metadata, candidate seeds are harvested from the assembly's own ldc.i4 immediates and by emulating the decoder, and each seed is validated by decrypting the blob and requiring a well-formed LZMA header before the pool is decompressed:

#![allow(unused)]
fn main() {
if plaintext.first() != Some(&CONSTANTS_LZMA_PROPS) || plaintext.len() < 9 {
    continue;
}
...
let Ok(pool): Result<Vec<u8>> = lzma_decompress(&plaintext) else {
    continue;
};
if pool.len() == uncompressed {
    return Some((*seed, pool));
}
}

(crates/disrobe-pass-dotnet/src/peel/confuserex_constants.rs, recover_pool)

The block cipher is the protector's own key-evolving XOR over 64-byte blocks with an XorShift-derived key schedule (decrypt_constants_blob and derive_constants_key, confuserex_constants.rs), and recovered strings are re-associated to their call sites through the mutate_id transform (confuserex_constants.rs, mutate_id). The seed is only accepted when the decompressed length matches the header's declared length, an internal consistency check that rejects wrong seeds without any external oracle.

3.3 The oracle: ordered CIL against a separately compiled baseline

The EazVM grade is the strongest in the .NET pass. grade_ordered compares the recovered and expected instruction lists position by position and reports both matched count and the maximum length, so a short or long recovery is penalized:

#![allow(unused)]
fn main() {
let length: usize = expected.len().max(recovered.len());
let common: usize = expected.len().min(recovered.len());
let mut matched: u32 = 0;
for i in 0..common {
    if expected.get(i) == recovered.get(i) {
        matched += 1;
    }
}
}

(crates/disrobe-pass-dotnet/src/peel/eazvm/grade.rs, grade_ordered)

The corpus's six virtualized bodies hold exactly 67 instructions in the clean baseline, and every one of them must match in order. The test fixes both the count and the percentage:

#![allow(unused)]
fn main() {
assert_eq!(
    total_length, 67,
    "the six Compute bodies hold 67 instructions in the clean baseline"
);
assert!(
    (pct - 100.0).abs() < f64::EPSILON,
    "ordered CIL recovery against the known original must be 100%; got {pct:.2}% \
     ({total_matched}/{total_length})"
);
}

(real_eazvm.rs, devirtualizes_every_method_to_ordered_cil)

The result is 67 of 67 instructions recovered in order, a 100% ordered match against compiler-emitted CIL the recovery code never saw. One body contains the W32 identity (x ^ y) + 2 * (x & y) and the recovery path emits the shorter equivalent x + y CIL only after an exact-width proof. A separate test closes the loop dynamically when a .NET runtime is present: the recovered CIL is rendered, re-injected into a rebuilt assembly, executed, and its standard output compared byte-for-byte against the clean program's output "5\n69\n2147483647\n55\n-1\n9\n" (real_eazvm.rs, recovered_cil_reinjects_and_runs_identically, expected constant EXPECTED_STDOUT). When no runtime is on PATH the dynamic half is skipped explicitly and the in-process ordered-CIL equivalence still gates the run (real_eazvm.rs, recovered_cil_reinjects_and_runs_identically); the numeric claim never silently depends on a tool that was absent.

KoiVM is graded against an independent, hand-specified projection of the same six methods rather than compiler-emitted CIL. The ground_truth table declares the expected operation sequence for each method (koivm/grade.rs, ground_truth), project collapses the lifted body into the same coarse vocabulary, and the aggregate must clear a 75% floor:

#![allow(unused)]
fn main() {
let pct: f64 = f64::from(total_matched) / f64::from(total_expected) * 100.0;
println!("AGGREGATE: {total_matched}/{total_expected} = {pct:.1}%");
assert!(
    pct >= 75.0,
    "aggregate structural recovery against known originals must be >= 75%; got {pct:.1}%"
);
}

(koivm/grade.rs, the aggregate-recovery test)

All six bodies are decoded and lifted to CIL with zero unknown ops (detect reports virtualized_method_count == 6, and devirtualize returns six methods with no undecoded ids, koivm/mod.rs, detects_koivm_in_real_sample and devirtualizes_all_six_methods); of these, two (Add, Square) are proven to recover fully, matching their hand-derived ground-truth ops in full, and the remaining four (SumTo, Classify, Factorial, Max3) are bounded only by the >=75% aggregate floor. This oracle is weaker than the EazVM one: the answer key is authored by hand from knowledge of the source, not parsed from a separately compiled clean assembly, and the comparison is over a projected op vocabulary rather than exact ordered CIL. It is still non-circular, because the ground truth is independent of the recovery code, and it is anchored by the RNG and descriptor tests that validate the decode against real System.Random output.

3.4 Static-recovery walls: ILProtector, MaxToCode, and Themida-class native VMs

Three families are DetectOnly (protectors.rs, Protector::handling), and their plan_execution verdict is DetectOnly, not devirtualization (protectors.rs, plan_execution). This is a proven information-theoretic wall, in the sense the introduction defines, not an unfinished feature, because in each case the plaintext CIL is absent from the static file by construction.

ILProtector replaces each body with an Invoke-stub and stores the ciphertext in a managed resource, but the decryption key and logic live in a native runtime delegate. The plaintext exists only after the assembly runs and calls its own decrypt delegate:

#![allow(unused)]
fn main() {
const BASE_RATIONALE: &str = "ILProtector replaces every protected method body with an Invoke-stub (ldsfld <delegate>; \
     ldc.i4 <method-id>; call Invoke; ret) and stores the ciphertext for each body in an embedded \
     managed resource reached through the CLI resources directory. The plaintext IL is produced only \
     by invoking the assembly's own runtime decrypt delegate ...";
}

(crates/disrobe-pass-dotnet/src/peel/ilprotector.rs, BASE_RATIONALE)

MaxToCode zeroes every protected method's RVA and restores bodies at JIT time through an unmanaged loader hooked into the execution engine. The per-method key is computed inside that native DLL, so it is not in the static metadata:

#![allow(unused)]
fn main() {
const BASE_RATIONALE: &str = "MaxToCode sets every protected MethodDef RVA to 0 and stores the ciphertext for each body in \
     an added native-loaded section, restoring the bodies at JIT time through an unmanaged loader \
     hooked into the EE/JIT layer ... The per-method key is computed inside that native DLL, so the \
     original CIL is not present in the static metadata ...";
}

(crates/disrobe-pass-dotnet/src/peel/maxtocode.rs, BASE_RATIONALE)

Themida .NET wraps the managed assembly inside Oreans' native VM, translating protected bodies into native VM bytecode that is decrypted into RWX memory only at runtime:

#![allow(unused)]
fn main() {
const BASE_RATIONALE: &str = "Themida-.NET wraps the managed assembly inside the Oreans native VM. Protected method bodies \
     are translated into native VM bytecode and decrypted into RWX memory only at runtime. This is \
     genuine native virtualization; per project policy disrobe does not ship a native-VM \
     devirtualizer (VMP/Themida class). The native-VM-protected methods are walled, not fabricated.";
}

(crates/disrobe-pass-dotnet/src/peel/themida_dotnet.rs, BASE_RATIONALE)

The wall is not silence. Each DetectOnly path still performs every static recovery the wall permits, and reports it honestly. ILProtector enumerates the Invoke-stubs and locates the encrypted-body resource (offset, size, hash) before declaring the RUNTIME-DELEGATE WALL (ilprotector.rs, the RUNTIME-DELEGATE WALL report); MaxToCode enumerates the zero-RVA methods and the encrypted section before declaring the NATIVE-KEY WALL (maxtocode.rs, the NATIVE-KEY WALL report); and all three disassemble the native loader or VM section as machine code through surface_native_stub, surfacing the unmanaged support code without claiming to have devirtualized it (the surface_native_stub call in each of ilprotector.rs, maxtocode.rs and themida_dotnet.rs). The distinction the pass draws is exact: what can be recovered statically is recovered and measured against an independent baseline; what genuinely leaves the static file (a runtime-produced key, a JIT-restored body, a native-VM translation) is walled with a stated reason and the residual static evidence, never fabricated.

Each preceding section asserted a recovery result and named the oracle that certified it; this section states the oracle discipline in full, ordered from the weakest external check to the strongest, so that every earlier number can be traced to the class of evidence behind it.

4. Verification methodology: grading recovery against non-circular oracles

Every graded capability claim disrobe makes rests on a single discipline: a recovery is credited only when an oracle that disrobe does not control confirms it. The exception is named rather than hidden. Where a number is a coverage count with no external reference behind it, the project publishes it under a self-reported tier defined as graded against nothing external, kept at lower confidence and never folded into a graded figure. This section states the discipline precisely, enumerates the oracle forms disrobe uses from weakest to strongest, and shows the code that implements each. The organizing principle is adversarial.

4.1 The central failure mode: circular oracles

A circular oracle grades a tool against an artifact the tool itself produced, or against a synthetic fixture constructed so that it can only agree with the tool. Both are epistemically empty. If a deobfuscator emits an output and then a test asserts that the output equals what the deobfuscator emitted, the test asserts nothing about correctness; it asserts determinism. If a fixture is hand-written to match the exact opcode sequence a decoder happens to produce, the fixture certifies the decoder against its own assumptions rather than against ground truth. The history of the project records concrete instances of this trap: a bytecode decoder once passed a suite of synthetic tests that had been written to agree with it, and the decoder was wrong; the failure was invisible until the same code was run against a real interpreter and a real specification.

The discipline against circularity has three rules, and they bind every pass.

First, the oracle must be external. The reference answer is produced by a real compiler, a real interpreter, a real virtual machine, or the true pre-transformation input, never by disrobe. Second, the fixture must be real. Test inputs are produced by the genuine obfuscator, packer, or compiler under study, not authored by hand to agree with the recovery. Third, the comparison must be over ground truth, not over the tool's own intermediate representation. Where a synthetic helper is unavoidable, its provenance is auditable; the codebase is periodically swept for synth_* helpers precisely so that a synthetic fixture cannot silently become the grader of the code that generated it.

The consequence is that a passing test in disrobe is a claim about the world, not a claim about disrobe. Sections 4.2 through 4.4 show how that claim is made, and made portable.

4.2 The non-circular oracle taxonomy, weakest to strongest

disrobe grades recovery with four oracle forms of increasing strength. Each form is stronger than the one before it because it depends on a wider and more independent body of external truth. For the two strongest signals a pass can report, the signal is drawn directly from which oracle form certified it, so those two are a record of which external check passed rather than a self-assessment. The remaining five signals are the pass's own report on what it managed to reconstruct, and StructuredNoVerify says so in its name.

The signal enum is the spine of this mapping:

#![allow(unused)]
fn main() {
pub enum RecoverySignal {
    ByteRoundtripVerified,
    RecompilesEquivalent,
    FullBodyLifted,
    SomeBodiesLifted,
    StructuredNoVerify,
    SignaturesOnly,
    NoRecovery,
}
}

crates/disrobe-core/src/recovery.rs, RecoverySignal

The two strongest variants, ByteRoundtripVerified and RecompilesEquivalent, correspond to the two strongest oracle forms below. A pass that cannot reach either reports a lower signal honestly rather than inflating the higher one.

The confidence tier a caller sees is coarser than the signal, and it blurs the verified boundary in one place. assign_tier (crates/disrobe-core/src/recovery.rs) sends ByteRoundtripVerified to ConfidenceTier::Exact, RecompilesEquivalent and FullBodyLifted both to Semantic, SomeBodiesLifted and StructuredNoVerify to Partial, and SignaturesOnly and NoRecovery to Skeleton. Four of the five self-reports therefore sit strictly below both verified signals, but FullBodyLifted is a self-report sharing the Semantic tier with the externally verified RecompilesEquivalent, and Exact is the only tier no self-report reaches. Read the signal, not the tier, when the question is whether an external check passed: Semantic alone does not separate a recompile-verified body from a fully lifted but unverified one.

4.2.1 Recompile-equivalence (weakest of the strong forms)

The recovered artifact is fed back through the real compiler or interpreter for its language, and the resulting object is compared, after normalization, against the object produced from the original. The oracle here is the language toolchain itself. disrobe does not decide whether its recovered Python is correct; it recompiles the recovered source and asks whether the recompiled code object matches the original code object.

The Python decompiler's equivalence judge is the reference implementation of this form. It is the semantic_equiv judge shown in section 1.4: byte identity is checked first as the strongest possible outcome, and when the bytes differ the two code objects are normalized and compared operation by operation, descending into nested code objects, before any equivalence is granted (crates/disrobe-pass-py-decompile/src/roundtrip/mod.rs, semantic_equiv). What section 1.4 did not show is how a verdict becomes a recovery signal. The mapping is without editorializing: a byte-identical recompile is ByteRoundtripVerified, a normalized-equal recompile is RecompilesEquivalent, and any residual difference is NoRecovery:

#![allow(unused)]
fn main() {
impl From<&Verdict> for disrobe_core::RecoverySignal {
    #[inline]
    fn from(verdict: &Verdict) -> Self {
        match verdict {
            Verdict::Perfect => Self::ByteRoundtripVerified,
            Verdict::Semantic => Self::RecompilesEquivalent,
            Verdict::CodeDiff(_) => Self::NoRecovery,
        }
    }
}
}

crates/disrobe-pass-py-decompile/src/roundtrip/mod.rs, the From<&Verdict> impl

The normalization is deliberately narrow. It collapses only differences that are semantically irrelevant, and it is itself defended against a subtle circularity: floating-point constants are canonicalized so that a NaN produced on one architecture compares equal to a NaN produced on another, while signed infinities and negative zero remain distinct because they are semantically distinct.

#![allow(unused)]
fn main() {
#[must_use]
fn canonical_float_bits(f: f64) -> u64 {
    if f.is_nan() {
        f64::NAN.to_bits()
    } else {
        f.to_bits()
    }
}
}

crates/disrobe-pass-py-decompile/src/roundtrip/mod.rs, canonical_float_bits

This is the weakest of the strong forms because it certifies that the recovered source recompiles to the same code, not that the same code behaves identically on inputs. For most languages the two are equivalent, but the stronger forms below remove even that assumption.

4.2.2 Recompile-execute-diff

Two binaries are produced independently, one from the recovered source and one from the ground-truth source, and both are executed over a shared battery of inputs. Equivalence is granted only when every input yields identical output. The oracle here is not a static comparison of code but the observable behavior of two separately compiled programs. This is stronger than recompile-equivalence because it survives any difference in code that does not change behavior, and it fails loudly on any difference that does.

The native pseudo-C leaf decompiler is graded this way. A battery of C functions is compiled to an object by the real system compiler, disrobe recovers C source from the machine code, and the recovered source is compiled and linked against the original object into a single harness that calls both and compares results:

#![allow(unused)]
fn main() {
const BATTERY: &[Case] = &[
    Case {
        name: "f_add",
        arity: 2,
        c_source: "long long f_add(long long a, long long b){ return a + b; }",
    },
}

crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs, BATTERY

The differential loop is the heart of the oracle. For each input triple, the harness calls the original function and the recovered function and aborts on the first mismatch:

#![allow(unused)]
fn main() {
    let _ = write!(
        driver_snippet,
        "    for (size_t k = 0; k < n_inputs; k++) {{\n\
         \x20       long long in[3] = {{ inputs[k][0], inputs[k][1], inputs[k][2] }};\n\
         \x20       unsigned long long want = (unsigned long long){}({}) & {return_mask};\n\
         \x20       unsigned long long got = {recovered_name}({}) & {return_mask};\n\
         \x20       if (want != got) {{ printf(\"MISMATCH {} in=%lld,%lld,%lld want=%llu got=%llu\\n\", in[0], in[1], in[2], want, got); return 1; }}\n\
         \x20   }}\n",
}

crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs, build_driver

The pass is credited only if the linked harness runs and prints OK:

#![allow(unused)]
fn main() {
    let run: std::process::Output = Command::new(&harness_exe).output().expect("run harness");
    let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&run.stdout);
    assert!(
        run.status.success() && stdout.contains("OK"),
        "behavioral differential FAILED ({lifted_count} cases): {stdout}\nstderr: {}",
        String::from_utf8_lossy(&run.stderr)
    );
}

crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs, leaf_functions_recompile_to_behavioral_equivalence

Because the recovered function is linked against the same object that produced the reference, there is no shared code path between the recovery and the truth: the truth is native machine code compiled by a third-party compiler, the recovery is disrobe's reconstructed source, and the only thing they have in common is the input battery. A mismatch cannot be papered over.

4.2.3 Independent-baseline grade

The recovered artifact is graded against a separately produced clean artifact that was never packed, virtualized, or obfuscated. The oracle is a second, independently built version of the same program. This is the form used when the recovery target is a transformation that has an inverse only in the presence of a known-good reference, such as a commercial virtualizing protector.

The Eazfuscator VM devirtualizer is graded against a clean assembly compiled from the same source without the protector. First, the clean baseline must not be mistaken for the protected one, which guards against a detector that fires on everything: the negative-baseline test shown in section 3.1 asserts the clean DLL exposes no dispatch table, carries zero stubs, and cannot be devirtualized (crates/disrobe-pass-dotnet/tests/real_eazvm.rs, clean_assembly_is_not_seen_as_eazvm). Then the devirtualized CIL is graded instruction by instruction, in order, against the CIL of the clean baseline, and the match must be exact, using the grade_ordered comparison and the 67-of-67 assertion shown in section 3.3 (crates/disrobe-pass-dotnet/tests/real_eazvm.rs, devirtualizes_every_method_to_ordered_cil).

The strongest step in this oracle removes even the assumption that matching CIL implies matching behavior. The recovered CIL is re-injected into an assembly, rebuilt with the real .NET toolchain, executed, and its standard output is compared byte for byte against the known output of the clean program:

#![allow(unused)]
fn main() {
const EXPECTED_STDOUT: &str = "5\n69\n2147483647\n55\n-1\n9\n";
}

crates/disrobe-pass-dotnet/tests/real_eazvm.rs, EXPECTED_STDOUT

#![allow(unused)]
fn main() {
    assert_eq!(
        stdout, EXPECTED_STDOUT,
        "the assembly rebuilt from the devirtualized CIL must print the clean baseline output \
         byte-for-byte; got {stdout:?}"
    );
}

crates/disrobe-pass-dotnet/tests/real_eazvm.rs, recovered_cil_reinjects_and_runs_identically

The independent baseline is what makes this non-circular. disrobe never sees the clean CIL during devirtualization; it recovers from the virtualized bytecode alone, and the clean assembly exists only in the test to grade the result.

4.2.4 Byte-exact-vs-original (strongest)

The recovered image is compared byte for byte against the true pre-transformation input. The oracle is the original file itself, the one artifact that cannot be argued with. There is no normalization, no behavioral tolerance, no recompilation step that might absorb a defect: the recovered bytes either equal the original bytes or they do not.

The UPX unpacker is graded this way against a committed original that was compressed by the genuine UPX packer. The recovered executable section and the recovered exception-unwind section must be byte-identical to the original:

#![allow(unused)]
fn main() {
#[test]
fn nrv2b_recovered_text_is_byte_identical_to_committed_original() {
    let out: UpxUnpackOutput = unpack_upx(PACKED_NRV2B).expect("unpack committed UPX fixture");
    let sections: Vec<OriginalSection> = parse_original_sections(ORIGINAL);

    let text: &OriginalSection = section_by_name(&sections, ".text");
    let recovered_text: &[u8] = recovered_section(&out.recovered_image, text);
    let original_text: &[u8] = original_section_disk(text);
    let diffs: usize = byte_diff_count(recovered_text, original_text);
    assert_eq!(
        diffs, 0,
        "recovered .text must be BYTE-IDENTICAL to the committed original ({} bytes); the CT \
         call filter (0x49) reversal recovers the executable code exactly. measured diffs={diffs}",
        text.content_len
    );
}

crates/disrobe-pass-native/tests/upx_unpack_all.rs, nrv2b_recovered_text_is_byte_identical_to_committed_original

The same byte-exact standard is applied to the LZMA-compressed variant, where the .text and .pdata sections must again show zero differences:

#![allow(unused)]
fn main() {
    assert_eq!(
        byte_diff_count(recovered_text, original_text),
        0,
        "UPX-LZMA recovered .text ({} bytes) must be byte-identical to the committed original",
        text.content_len
    );
}

crates/disrobe-pass-native/tests/upx_unpack_all.rs, the LZMA fixture test

For the whole image, where a portion of the file is legitimately rebuilt by the operating-system loader at run time and is therefore not present in the packed stream, disrobe does not claim byte identity it cannot honestly achieve. It measures content-section byte recovery against a stated floor and, separately, proves that every residual difference falls only in loader-rebuilt zones:

#![allow(unused)]
fn main() {
    assert!(
        recovery_pct >= FLOOR_PCT,
        "UPX content-section byte recovery {recovery_pct:.2}% fell below the {FLOOR_PCT:.2}% floor"
    );
}

crates/disrobe-pass-native/tests/upx_unpack_all.rs, FLOOR_PCT

#![allow(unused)]
fn main() {
    assert_eq!(
        diffs_outside_loader_zones, 0,
        "every UPX recovery residual must fall in a loader-rebuilt section (.reloc relocations, \
         or the import/IAT-patched .rdata/.data). the executable code (.text) and exception data \
         (.pdata) carry zero residual. these zones are reconstructed by the OS loader at run time \
         and are not byte-present in the packed stream, so they are not a depacker defect"
    );
}

crates/disrobe-pass-native/tests/upx_unpack_all.rs, the loader-zone residual assertion

The span behind that percentage has to be stated, because "content" could mean either the executable sections alone or the whole image, and the two give different correct numbers for the same fixture. Here it is the whole image: the test walks every entry in the original PE's section table and counts min(virtual size, raw size) bytes for each (parse_original_sections and the content_len field of OriginalSection, same file), so .rsrc, .reloc, and every other named section sits inside the denominator, not only .text, .rdata, and .data. A percentage computed over the narrower executable-and-data span is a different measurement and is not what these floors assert.

The locked assertions are as follows, and they do not all carry the same weight, so each is marked. For the nrv2b and LZMA fixtures the .text and .pdata sections recover byte-identically, at zero differences [CI], and that is the whole of what the LZMA fixture asserts: it carries no whole-image content floor. The nrv2b whole-image content floor is 96.0% [CI] (FLOOR_PCT in crates/disrobe-pass-native/tests/upx_unpack_all.rs). The large nrv2e fixtures set floors of 96% for the rg binary and 98% for the git binary, both [local], because neither input is committed:

#![allow(unused)]
fn main() {
    assert!(
        pct >= 96.0,
        "rg content-section byte recovery {pct:.2}% fell below the 96.0% floor"
    );
}

crates/disrobe-pass-native/tests/upx_unpack_all.rs, the nrv2e rg fixture

#![allow(unused)]
fn main() {
    assert!(
        pct >= 98.0,
        "git content-section byte recovery {pct:.2}% fell below the 98.0% floor"
    );
}

crates/disrobe-pass-native/tests/upx_unpack_all.rs, the nrv2e git fixture

Those three floors are not one range and must not be read as one. Only the 96.0% nrv2b floor is [CI]; the 96% rg and 98% git floors are [local], and quoting a 96 to 98 percent band would blend a figure every reader reproduces with two that no reader does. Each is a lower bound rather than a measurement: every test computes and prints its own content-recovery percentage but no constant records it, so the published figure is the floor the gate enforces and the measured value sits above it. The executable code itself is exact rather than floored.

Which of the three reproduces from a clean checkout is a question about the fixture, not the code. The nrv2b pair and the LZMA pair are committed (corpus/native/packers/upx/hello.original.exe, hello.packed.nrv2b.exe, hello.packed.lzma.exe, and the matching entries in COMMITTED_FIXTURES, crates/disrobe-pass-native/tests/support/packer_fixture.rs), so the two byte-identical .text and .pdata assertions and the 96.0% nrv2b whole-image floor run on any checkout. The rg and git nrv2e inputs are multi-megabyte real binaries that are not committed and are not listed as committed fixtures, so load_fixture returns nothing and both tests print skip: rg.packed.upx.exe missing and return without asserting their 96% and 98% floors. Setting DISROBE_REQUIRE_PACKER_FIXTURES to all turns an absent fixture into a failure instead of a skip (packer_fixture.rs, requirement_from_value), which is how a local run with the large binaries present is prevented from silently degrading to the committed subset.

The two [local] floors are not equally recoverable either, and corpus/native/packers/MANIFEST.toml now records which is which. The rg input is the ripgrep 15.1.0 win64 release, pinned by version and hash, so a reader can fetch it and re-derive the 96% floor; the manifest marks it local. The git input is a git.exe whose Git for Windows release was never recorded, only its hash, so nobody can obtain it and nobody can re-derive the 98% floor; the manifest marks it local-unobtainable and states that reason on the row. Read the 98% as the weakest figure on this page: a floor that held once, on this machine, against an input that cannot be handed to anyone.

4.3 Recover-or-sound-reject

A pass has exactly two honest outcomes: it recovers correctly, or it rejects the input soundly. It must never emit a confident wrong answer. A wrong answer that is presented as a recovery is worse than no answer, because it poisons every downstream decision and cannot be distinguished from a real one by inspection.

The discipline is enforced at the type level. Every recovery entry point returns a Result, and the pass returns an error whenever the input falls outside the class it can prove it handles. The leaf decompiler refuses code with no structured body or no terminal return rather than guessing:

#![allow(unused)]
fn main() {
fn structure_items(items: &[Item]) -> Result<Structured> {
    if items.is_empty() {
        return Err(Error::LlvmIr("no structured body".to_owned()));
    }
    let Some(ret_pos): Option<usize> = items
        .iter()
        .position(|it: &Item| matches!(it.kind, ItemKind::Ret))
    else {
        return Err(Error::LlvmIr("missing terminal ret".to_owned()));
    };
}

crates/disrobe-pass-native/src/pseudo_c.rs, structure_items

The oracle harnesses honor these rejections instead of forcing an answer. When a battery case is not in the leaf class, the harness records a skip and moves on, the skip-not-pass conversion shown in section 2.6 (crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs, process_case); the differential is run only over the cases that were genuinely lifted, and the reported count reflects that.

Sound rejection is tested as a first-class capability, not left implicit. The UPX unpacker must reject a non-UPX buffer:

#![allow(unused)]
fn main() {
#[test]
fn non_upx_input_is_rejected() {
    let buf: Vec<u8> = vec![0x55u8; 4096];
    assert!(unpack_upx(&buf).is_err());
}
}

crates/disrobe-pass-native/tests/upx_unpack_all.rs, non_upx_input_is_rejected

and the Eazfuscator devirtualizer must reject a clean assembly (devirtualize(&image).is_err() in crates/disrobe-pass-dotnet/tests/real_eazvm.rs). These negative oracles close the gap that a purely positive test suite leaves open, where a pass could score well on real samples while also happily producing garbage on anything else. The recovery signal makes the same distinction visible in output: NoRecovery and SignaturesOnly map to the skeleton tier, so a caller can tell a proven recovery from a sound refusal to guess.

4.4 CI portability: a green number that stays true across machines

An oracle that depends on compiler code generation is only as portable as the compiler. The recompile-execute-diff and recompile-equivalence forms both run real toolchains, and real toolchains differ across operating systems, versions, and vendors. A number measured green on one developer's machine can be a lie on another if the oracle silently depends on that machine's compiler. disrobe treats this as a correctness problem, not a convenience one, and defends against it three ways.

4.4.1 A multi-platform matrix

The oracles are run under a three-operating-system matrix so that a platform-specific artifact of code generation cannot masquerade as a recovery result. The check and test jobs both fan out across Linux, macOS, and Windows:

    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]

.github/workflows/ci.yml, the check job matrix

The full test job runs on the same three-way matrix (.github/workflows/ci.yml, the test job matrix) with the toolchains the differentials need provisioned in the environment: CPython 3.8 through 3.14 via uv, Temurin JDK 25, Ruby 3.4, the .NET 9 SDK the .NET IL-equivalence floors are measured with, and the z3 solver the MBA corpus is graded against (.github/workflows/ci.yml, the test job provisioning steps).

Not every oracle is on all three legs, and the exceptions matter when reading a floor. Erlang/OTP 27.3.4 is provisioned only on the Linux leg, so the BEAM recompile-execution figure cited below is enforced there and reports itself as unmeasured elsewhere when the toolchain is absent. The execution-differentials job is a single Linux job rather than a matrix leg (runs-on: ubuntu-latest), and it is what installs Lua 5.4, LuaJIT, luau, PHP 8.3 with opcache, and Node 24 so the re-execution oracles run against genuine interpreters rather than stubs (.github/workflows/ci.yml, the execution-differentials job). Those interpreter differentials therefore carry a one-platform guarantee, not a three-platform one; the three-platform claim covers the check and test jobs. Lint runs under -D warnings with unreachable_pub, missing_debug_implementations, and unused promoted to errors (.github/workflows/ci.yml, the clippy job), and a minimum-supported-Rust job pins the toolchain to 1.95.0 so that a portability regression in the language edition is caught as well (.github/workflows/ci.yml, the msrv job).

4.4.2 Platform gates instead of platform lies

Where a host genuinely cannot run an oracle, disrobe skips it explicitly and states why, rather than weakening the assertion to something that passes everywhere. The native host oracle runs only where the host ABI matches, and says so:

#![allow(unused)]
fn main() {
#[test]
fn leaf_functions_recompile_to_behavioral_equivalence() {
    if !cfg!(windows) {
        eprintln!(
            "skipping host-native oracle class on non-windows: host cc is arm64 on macos and gcc codegen differs on linux; cross-platform x86-64 sysv coverage is the sysv_* clang guards"
        );
        return;
    }
}

crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs, leaf_functions_recompile_to_behavioral_equivalence

The cross-platform floor is not abandoned when the host cannot run it. It is carried by a separate SysV oracle that uses clang to emit a Linux x86-64 object regardless of host, so that the x86-64 System V ABI is exercised even on a Windows or macOS developer machine:

#![allow(unused)]
fn main() {
    let compile_sysv: std::process::Output = Command::new(&clang_cc)
        .args([
            "--target=x86_64-unknown-linux-gnu",
            "-O1",
            "-fno-stack-protector",
            "-fcf-protection=none",
            "-c",
            "-o",
        ])
}

crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs, the SysV cross-compilation step

macOS is gated out of the SysV execution differential with an explicit rationale, because its gcc is an apple-clang alias and its arm64 core cannot execute an x86-64 battery; the note records that Ubuntu carries the cross-platform floor instead:

#![allow(unused)]
fn main() {
fn sysv_host_can_run() -> bool {
    if cfg!(target_os = "macos") {
        eprintln!(
            "skipping x86-64 sysv recompile-differential on macos: the host gcc is an apple-clang alias that rejects the gcc-only codegen flags, and arm64 cannot execute an x86-64 sysv battery; ubuntu carries the cross-platform sysv floor"
        );
        return false;
    }
    true
}
}

crates/disrobe-pass-native/tests/pseudo_c_leaf_oracle.rs, sysv_host_can_run

A skip that names its reason is honest; a silently relaxed assertion is not. disrobe uses the first and forbids the second, with one exception worth naming rather than glossing: in crates/disrobe-emit/tests/c_cc_oracle.rs the declarations golden prints skipping cc syntax oracle: no host c compiler found when no cc, gcc, or clang is on PATH, but the companion declarator proptest returns without a message in the same situation. On a host with no C compiler that one proptest silently contributes nothing. It is a narrow case, because every CI leg that runs the emit crate has a C compiler, but a silent skip is still a silent skip.

4.4.3 Conservative floors for codegen-sensitive measures

Where a measurement varies with compiler version or optimization but has a provable lower bound, disrobe asserts the floor, not a point estimate that only one machine produces. The floors are named constants, each traceable to the corpus and date on which it was measured, and each set below the locally observed value so that a legitimate codegen difference does not turn into a false failure while a real regression still trips the assertion. These citations name the file and the constant, not a line number, because a line number rots every time the file above it changes while the constant it points at stays put. Each entry below reads floor over denominator, then where the denominator comes from, because a floor with an unstated denominator is not a claim.

  • Python, per code object: 96.51% regression floor on the pinned 200-module CPython 3.14 corpus. OBJECT_PCT_FLOOR = 96.51 in crates/disrobe-pass-py-decompile/tests/arbitrary_recompile_gate.rs; the current CPython 3.14.5 measurement is 96.53% (6,068 of 6,286 code objects). The denominator is counted at run time rather than pinned, and the gate guards it in two ways instead: it requires at least 180 of the 200 modules to resolve in the host interpreter's Lib and at least 5,000 code objects to be measured, so a corpus that has drifted or an interpreter missing most of it fails rather than producing a high percentage over a thin population. The 3.12 gate sits at 95.45 over its own smaller population (arbitrary_recompile_gate_312.rs).
  • JVM, per method recompiled: 131 of 131 methods. crates/disrobe-pass-jvm/tests/decompile_recompile_rate.rs sets PER_METHOD_JAVAC_OK_FLOOR = 131 against the PER_METHOD_JAVAC_TOTAL = 131 the same file pins, so the floor is every top-level method in the corpus, not a fraction of them. This grades whether real javac accepts the recovered source.
  • JVM, per method executed: 117 of the same 131 methods. crates/disrobe-pass-jvm/tests/edgecases_execution_differential.rs sets EXECUTION_EQUIVALENT_FLOOR = 117 against the PER_METHOD_TOTAL = 131 in the same file. This is the figure to read for behavior, and it is strictly stronger than the 131 above it: original and recovered source are each compiled by real javac, both run under a real JVM, and every observable per-method result is compared. The two numbers share one denominator and must never be merged, because a method can compile cleanly and still compute the wrong answer, which is exactly what this crate once shipped. Read the residual 14 as the two populations it is: 8 methods are javac-clean and measurably divergent (BEHAVIOUR_DIVERGENT), and 6 are javac-clean but not executable in isolation, 5 that only measure a nested-type stub (STUB_BLOCKED) and 1 whose own original output is not self-reproducible across JVM runs (NOT_DRIVEN). Those 6 are ungraded rather than passing. Each residual set is a pinned membership list of method names rather than a count, the test fails if a pinned method starts matching (the lists only ever shrink), it fails on any divergent method in no pinned bucket, and it asserts the four-way partition sums to 131, so neither the numerator nor the denominator can move quietly.
  • JVM, head-to-head recompile: 131 of a pinned METHOD_TOTAL = 131. RECOMPILE_FLOOR = 131 in crates/disrobe-pass-jvm/tests/jadx_head_to_head.rs, and the test fails if that denominator drifts.
  • .NET, whole-type IL equivalence: 66 methods, denominator computed at run time. crates/disrobe-pass-dotnet/tests/whole_type_il_equivalence_oracle.rs sets IL_EQUIVALENCE_FLOOR = 66. The denominator is not a pinned constant: the test counts the methods it found equivalent, mismatched, and missing across the graded Sample and EdgeCases types and reports the floor against that run-time total, so the 66 is a count and not a ratio. The test recompiles with the .NET SDK and compares both assemblies through pinned ILSpyCmd 9.1.0.7988, so neither prerequisite can turn the floor green by skipping. The same file sets IL_BRANCHING_FLOOR = 45, requiring at least 45 of the equivalent methods to have compared a real branch or switch destination rather than straight-line code, which is what stops the count being satisfied by trivially linear bodies. RECOMPILE_FLOOR = 6 in crates/disrobe-pass-dotnet/tests/recompile_oracle.rs is likewise a count of user methods against a run-time total.
  • Go, function names: 99% of the symbols go tool nm reports, per binary. RECOVERY_FLOOR is the ratio 99 over 100 in crates/disrobe-pass-go/tests/go_crossformat_recovery.rs, a 99% threshold rather than a count of 99 items, and the test refuses a binary yielding under 1,000 text symbols so the ratio cannot be met on a thin population. The same file holds TYPE_EQ_RECOVERY_FLOOR and ITAB_RECOVERY_FLOOR at a full 1.0, so type:.eq and go:itab recovery must be complete against that same go tool nm reference, not merely above a threshold.
  • PyArmor structural marshal coverage: 72 of 72 manifest-named v8/v9 default-trial wrappers. STRUCTURAL_CODE_OBJECT_FLOOR = 72 in crates/disrobe-pass-pyarmor/tests/static_unpack_corpus.rs binds the typed manifest roster to the discovered wrapper population, verifies each wrapper/runtime identity, statically decrypts every wrapper, computes the header-declared marshal start, and requires the parser to decode one complete root CodeObject. This is self-reported structural coverage, not source recovery, emitted .pyc identity, semantic or execution equivalence, or external validation.
  • BEAM, committed test/0 recompile-execution: 18 of 19 corpus entries under OTP 27.3.4. crates/disrobe-pass-beam/tests/erlc_recompile_equivalence.rs names every entry in a fixed roster and rejects any addition, removal, or substitution before measuring. On Linux the cited test makes absent erlc or erl fatal, compiles each original, strips both Dbgi and Docs, recovers through the Core Erlang path, recompiles, checks export parity, and compares test/0 exit status and stdout. It reads the raw numerator and denominator from xtask/data/recovery.json and requires the live measurement to equal both. The scope is the committed test/0 observation in each entry, not every possible call to every export. A single mutation control inserts a raising recovered test/0; it must still compile with matching exports and must fail the real-erl runtime comparison.
  • Lua, re-execution: every fixture the lane measures. crates/disrobe-pass-lua/tests/reexec_diff_oracle.rs sets REEXEC_FLOOR_NUM = 29, which is the numerator of a ratio whose denominator is the 29-entry CORPUS, so the floor is 29 over 29, a full 1.0. The assertion compares that ratio against the fixtures the lane measured rather than against 29 directly, so what it requires is that every fixture it measured re-executes identically, on the 5.1 and the 5.4 lane alike.
  • Python, whole module on a different corpus: 57.1% of 42 graded fixtures. WHOLE_MODULE_FLOOR_PCT = 57.1 against GRADED_FIXTURE_COUNT = 42 in crates/disrobe-pass-py-decompile/tests/roundtrip_metric.rs, so the floor is 24 of 42 fixtures round-tripping whole. The denominator is pinned by equality rather than counted, and the test fails outright if a graded fixture has no recompiler available, because a missing interpreter would otherwise shrink the denominator and inflate the percentage. This corpus is the edge_cases monolith over CPython 3.8 through 3.14, a different population from the 200-module pinned stdlib behind the 61.5% whole-module figure; neither is a floor on the other and the two must not be conflated. The UPX rg and git content floors of 96% and 98% from section 4.2.4 belong to this class as well, with the availability caveat stated there; the committed nrv2b floor is also 96.0%, so those two must be named rather than quoted as a bare number.

Each floor is a promise of the form "at least this much recovery is reproducible wherever the input and the toolchain the floor names are present." It is deliberately weaker than the best local number and deliberately stronger than zero, because the honest claim is a guaranteed lower bound, not a lucky maximum. That qualifier matters: several of the floors above need a runtime or a fixture that is not on every leg of the matrix, and 4.4.4 states which. The recompile-execute-diff oracles that do run, such as the leaf behavioral differential and the Eazfuscator re-injection, are exact rather than floored, because behavioral equivalence over a shared input battery either holds or does not; there is no honest partial credit for a program that computes the wrong answer.

4.4.4 What a clean checkout reproduces, and what each figure needs beyond one

A floor is only as reproducible as its inputs and its toolchain. When an oracle has an optional external precondition, it skips rather than weakens, and all but the one case named in 4.4.2 say so on the way out. That keeps a number from being faked, but it also means a bare cargo test on a bare machine measures less than CI does, and a reader who does not know which figures went unmeasured will read a green run as a full one. What each figure needs follows.

Reproducible with a Rust toolchain alone, because the input and the reference answer are both committed:

  • The EazVM ordered-CIL grade, 67 of 67 (Section 3.3). Both EazSample.eazvm.dll and the EazSample.clean.dll answer key are committed; the grade is computed in process. Only the dynamic re-injection half needs a .NET runtime, and it says so when one is absent.
  • The KoiVM grade against its hand-specified ground truth, including the seed-0 System.Random and descriptor-table anchors.
  • The UPX .text and .pdata byte-identity assertions and the 96.0% nrv2b whole-image content floor (Section 4.2.4).
  • The Go floors. The reference is a committed capture of real go tool nm output alongside the committed binaries (crates/disrobe-pass-go/tests/fixtures/), so no Go toolchain is needed at test time; the reference was produced by the real tool, not simulated. A missing or unreadable tracked fixture fails the gate.
  • The disrobe-emit precedence and Rust round-trip proptests, which need only syn and prettyplease.

Also reproducible from a Git checkout with a Rust toolchain, but self-reported rather than independently graded:

  • The PyArmor 72-of-72 structural marshal check over manifest-named v8/v9 default-trial wrappers. It requires a Git checkout to bind the roster, verifies static decryption and complete header-anchored root-CodeObject decoding, and does not use an external reference.

Needing one more thing, with explicit handling when it is absent:

  • Python per code object, 96.51% floor: a resolvable CPython 3.14, because the gate recompiles through the real interpreter. Also the release CLI binary, which the gate drives rather than calling in process, and which it refuses to run without.
  • Python legacy: the 150-of-191 floor holds on structural token-match alone, so it runs anywhere. The higher 166-of-191 local figure needs the 1.0 through 3.7 interpreter set installed, which is why it is published as a local measurement and no gate asserts it.
  • Native leaf C and Rust differentials: a real C compiler, plus a host whose ABI matches for the host-native class and clang for the cross-compiled System V class (Section 4.4.2).
  • .NET IL-equivalence: the .NET SDK plus the pinned ILSpyCmd local tool, both provisioned in CI on all three legs. The cited floor fails if either prerequisite is unavailable.
  • .NET re-injection: the .NET SDK, provisioned in CI on all three legs.
  • BEAM 18-of-19 test/0 differential: erlc and erl from OTP 27.3.4 on PATH, provisioned and mandatory in CI on Linux only.
  • Lua, LuaJIT, luau, PHP, and Node re-execution differentials: those interpreters, provisioned in the single-platform execution-differentials job.
  • The MBA corpus: an external SMT solver. DISROBE_REQUIRE_SOLVER makes an absent one fatal so the differential cannot quietly fall back to a weaker check.

Needing an input that is not committed at all:

  • The UPX rg and git nrv2e floors, 96% and 98%. Their multi-megabyte inputs are not in the repository, so both tests skip on any checkout (Section 4.2.4).

Three environment variables convert an absent input from a skip into a failure, which is how a local run is stopped from silently grading less than it claims: DISROBE_REQUIRE_PACKER_FIXTURES for the packer corpus, at 1 for the committed fixtures or all to demand every fixture including the uncommitted large ones; DISROBE_REQUIRE_SOLVER for the solver; and DISROBE_REQUIRE_ERLANG for erlc and erl. Where a figure in this paper depends on a precondition, the precondition is named beside it rather than assumed.

4.5 Summary

disrobe's verification stack is a ladder of external truth. Recompile-equivalence borrows the language toolchain as judge. Recompile-execute-diff replaces static comparison with the observable behavior of two independently compiled programs over a shared battery. Independent-baseline grading measures recovery against a separately built clean artifact the recovery never sees. Byte-exact-vs-original compares recovered bytes to the true pre-transformation input, the one reference that admits no argument. Beneath all four sits the recover-or-sound-reject invariant, which forbids a confident wrong answer, and above all four sits a CI matrix with named, dated, conservative floors and explicit platform gates, three-platform for the oracles whose toolchain exists everywhere and single-platform where only one leg carries the runtime, so that a number that is green in one place is not a lie in another. The result is a body of claims a stranger can re-run, with the precondition for each one stated in 4.4.4, and a measurement discipline built so that a green number cannot vouch for the tool that produced it.

What this whitepaper does not claim

The boundaries below are stated so that the results above are not read as more than they are. Each is a consequence of where the recoverable data actually resides, not an unfinished feature.

disrobe does not devirtualize native VM-protected code. Themida- and VMProtect-class protection, including the Themida wrapper applied to managed assemblies, translates a body into native VM bytecode that is decrypted into memory only at run time; disrobe detects and classifies it and disassembles the surrounding native stub, but does not reconstruct the original body (Section 3.4).

disrobe does not recover bodies whose plaintext leaves the static file. Where a per-method key is produced by a runtime delegate (ILProtector) or computed inside a native loader at JIT time (MaxToCode), the original instructions are not present in the bytes under analysis; disrobe walls these with a stated reason and the residual static evidence rather than fabricating a body (Section 3.4).

Whole-module Python recovery is far below the per-object figure. The representative per-code-object recompile-equivalence on the CPython 3.14 standard library is 95.09%, but the whole-module exact rate, where a module counts only if every one of its code objects is equivalent, is 123 of the 200 modules in the pinned corpus, 61.5%, a count the same CI gate floors at 123 rather than printing; a module passes only when all of its typically dozens of code objects pass, so a small per-object miss rate compounds into a large per-module one (Section 1.5). The per-object number is the granular headline and the whole-module number is the harder truth reported beside it.

Recompiling is not executing, and where both are measured the paper reports both separately. The JVM corpus is the clearest case: 131 of 131 top-level methods recompile cleanly through real javac, and 117 of those same 131 are execution-equivalent under a real JVM. The 117 is the behavioral claim, the 131 is only a compile claim, and merging them into one figure would overstate the result by 14 methods (Section 4.4.3).

Two published figures need an input that is not in the repository. The UPX 96% and 98% content floors for the rg and git binaries are asserted against multi-megabyte real executables that are not committed, so those two tests skip on a clean checkout rather than measuring anything. What does reproduce from a clone is the byte-identical .text and .pdata recovery and the 96.0% nrv2b floor (Sections 4.2.4 and 4.4.4).

The KoiVM oracle is weaker than the Eazfuscator oracle. EazVM recovery is graded against ordered CIL that the C# compiler emitted into a separate clean assembly the recovery code never sees, at 67 of 67 instructions in order. KoiVM is graded against a hand-specified projection of the same six methods over a coarser operation vocabulary: two bodies (Add, Square) are proven to recover fully and the aggregate clears a 75% floor, so the other four are bounded only by that floor and are not claimed as full recoveries. The oracle is non-circular but is not the same strength of evidence, and the difference is stated rather than smoothed over (Section 3.3).

The native leaf results are for a hand-authored battery, not arbitrary binaries. The x86-64 to C and Rust path recovers single-exit leaf functions and is graded by a battery of small functions written by hand, exercised under both the MS x64 and the System V ABIs. It makes no claim to recover whole stripped programs, obfuscated or virtualized code, irreducible control flow, or any input its soundness guards reject; every such input is refused rather than faked (Section 2.7).

Conclusion

The three recovery subsystems address different targets but share one shape. The Python decompiler folds a moving instruction set into a single canonical vocabulary and recovers structured source; the native path lifts x86-64 into a typed AST and emits C and Rust with one precedence authority; the .NET path reconstructs CIL from two managed VMs and walls the cases whose plaintext leaves the static file. What binds them is the verification methodology of Section 4: each result is credited only by an oracle disrobe does not control, from recompile-equivalence through recompile-execute-diff and independent-baseline grading to byte-exact comparison, and each codegen-sensitive number is held by a conservative CI floor, on a three-platform matrix for the passes whose oracle runs everywhere and on a named single platform for the interpreter differentials that need a runtime only Linux CI provisions. The combined claim is therefore narrow and checkable: within the class each pass proves it handles, disrobe recovers source, IL, or bytes that an independent toolchain confirms, and outside that class it rejects the input rather than fabricating an answer. Every figure in this paper cites the committed source, test, or data file behind it, and Section 4.4.4 states, for each one, what a reader needs beyond a clone to reach the same result. Two are weaker than that standard: the two large UPX floors need an input the repository does not carry. Both are labeled as such where they appear rather than folded in with the rest.

References

The externally cited prior art, with resolvable identifiers:

  • PEP 626, precise line numbers for debugging and other tools, Python Enhancement Proposal 626.
  • PEP 695, type parameter syntax, Python Enhancement Proposal 695.
  • Wadler-Leijen, Philip Wadler, "A prettier printer" (2003), extended by Daan Leijen in the wl-pprint library, the basis for the pretty document algebra.
  • Knuth, Donald E. Knuth, The Art of Computer Programming, Volume 2: Seminumerical Algorithms, section 3.2.2, the subtractive random number generator (ISBN 0-201-89684-2).
  • FNV, Glenn Fowler, Landon Curt Noll, and Kiem-Phong Vo, the FNV non-cryptographic hash function.
  • syn, David Tolnay, syn, a Rust source-code parser.
  • prettyplease, David Tolnay, prettyplease, a syn abstract-syntax-tree unparser.

The five-rung IR ladder

Every artifact disrobe recovers climbs the same five-rung intermediate-representation ladder. A pass never jumps arbitrarily; it lifts an artifact from one rung to the next, which is what lets passes from completely different ecosystems compose through a shared envelope.

   Raw  ──>  Disasm  ──>  MIR  ──>  HIR  ──>  Surface
   bytes     opcodes      mid       high      source

The rungs are defined once in disrobe-ir as Rung::{Raw, Disasm, Mir, Hir, Surface} and are the same for Python bytecode, JVM classfiles, .NET CIL, Lua chunks, WebAssembly, and native binaries.

Rung 1: Raw

The original bytes, exactly as received, wrapped in an envelope with its detected format and BLAKE3 root recorded. Unpacking and decryption passes (UPX unpack, PyArmor decrypt, PyInstaller extract, container extraction) operate at this rung: they take raw bytes and emit raw bytes that are structurally cleaner but still pre-disassembly. This is where byte-exact recovery lives: a UPX-unpacked image at the Raw rung carries the original executable code (.text) and exception data (.pdata) bit-for-bit, with only the loader-rebuilt relocations and IAT (resolved by the OS loader at run time, never stored in the packed file) left as residual.

Rung 2: Disasm

The artifact decoded into a per-instruction listing: CPython opcodes, JVM bytecode, CIL, Lua VM instructions, Wasm operators, or native machine instructions via Capstone/iced-x86/yaxpeax. Disassembly is faithful and lossless: a 1:1 decode of the bytes, with offsets preserved. disrobe py disasm, disrobe beam disasm, disrobe as3 disasm, and disrobe pickle disasm all stop here deliberately.

Rung 3: MIR (mid-level IR)

A normalized, control-flow-aware representation: basic blocks, an explicit CFG, stack effects resolved, super-instructions un-fused, jump targets relocated. This is the rung where decompilers do the structural work that separates a faithful disassembly from readable source. For Python specifically, the frame-tree pre-pass runs here: the nested source-construct tree is reconstructed from the 3.11+ exception table before the instruction walk, which eliminates the single-pass stack-walker desync that other Python decompilers suffer from.

Rung 4: HIR (high-level IR)

Structured control flow recovered: loops, conditionals, try/except/finally, with-blocks, comprehensions, pattern-match arms, generator state machines. Names, types, and idioms are recovered where the source language has them (Kotlin idiom recovery from JVM bytecode, C# vs F# vs VB shapes from CIL). The HIR is language-shaped but not yet rendered as text.

Rung 5: Surface

The final rendered source: Python, Java, Kotlin, C#, F#, VB, Rust, TypeScript, WAT, C, Lua, Erlang, or whatever the target dictates. For Python this is where the round-trip metric runs: the emitted file is recompiled on the matching interpreter and compared opcode-for-opcode against the original. The verdict is recorded as PERFECT (byte-identical), SEMANTIC (same program, different layout), or CODE_DIFF (a real bug, fixed before ship).

Why the ladder matters

  • Composition. Because a pass only declares "I take rung N, I produce rung N+1," the chain runner can stitch passes from different crates without any of them knowing about each other.
  • Honest partial recovery. If a decompiler can climb to HIR but not cleanly render Surface, it can stop and emit the HIR/Disasm artifact with a PARTIAL or SKELETON confidence tier rather than fabricating source.
  • Transcoding. disrobe-ir carries a transcode registry keyed on (from_version, from_rung, to_version, to_rung), so an envelope can be migrated across schema versions while staying at the same rung. disrobe envelope migrate-check <from.dr> <to.dr> validates that such a path exists and that every required capability stays satisfiable.

Confidence tiers

Surface output is tagged with one of four tiers, defined in disrobe-core:

TierMeaning
exactReserved for byte-roundtrip-verified output.
semanticSame program, different but equivalent layout.
partialSome bodies recovered, some left as disasm or stubs.
skeletonStructure recovered, bodies emitted as pass/placeholder.

These tiers propagate into the recovery.json sidecar and the --llm bundle, so a downstream consumer always knows how much to trust each recovered symbol.

Passes and pass selection

A pass is the unit of work in disrobe. Chain passes implement a shared trait and register a detector that scores how confidently each one recognizes a given input. Ecosystem crates may expose one or more passes plus direct APIs. disrobe auto picks the next pass by comparing detector verdicts, not by matching capability descriptors between passes; see Pass selection below.

Commands and auto-chain passes

The CLI has two related surfaces:

  • Direct commands are the operations shown by disrobe --help. They include ecosystem command families such as py, native, and jvm, plus analysis commands such as scan, frisk, query, taint, and webview.
  • Chain passes implement the shared Pass trait and can be selected by disrobe auto. Their IDs are not necessarily CLI subcommand names.

disrobe passes prints both layers. Its first block summarizes direct recovery families. Its second block is the authoritative list of chain pass IDs reachable from disrobe auto in that binary, with an ecosystem and full, partial, or detect-only tier. The standard CLI build at this revision reports:

GroupAuto-chain pass IDs
Pythonnuitka.extract, pickle.classify, py.decompile, py.deob, py.disasm, pyarmor.unpack, pyfreeze.extract, pyinstaller.extract, sourcedefender.decrypt
Managed and mobiledotnet.classify, jvm.classify, mobile.classify, swift-objc.classify
Source and bytecodeas3.classify, beam.classify, js.deob, lua.deob, php.peel, ruby.classify, scriptlang.classify, shell.deob, wasm.deob
Native and containersbinfmt.container, go.classify, native.ne-structure, native.packer-unpack, nativelang.classify

This distinction matters for reachability. For example, disrobe webview directly recovers Electron, Tauri, and Wails frontend assets, but the standard CLI's chain feature does not enable webview.carve, so disrobe auto does not advertise that pass. Use the direct command for that surface. Build feature selection can change the chain registry; inspect the binary you are running instead of relying on a copied count.

The direct JavaScript catalog currently carries 11 bundler families. The WebAssembly catalog carries 4 direct-helper families; three transformations run through wasm deob, while Tigress-via-Emscripten and wasm-name-obfuscator are classification-only. These catalog counts describe direct command capability and do not add chain pass IDs.

Pass selection

Rather than hard-coding which pass follows which, every pass registers a Detector (chain::detector::Detector in disrobe-core) that inspects the current bytes and, if it recognizes them, returns a DetectVerdict: a pass ID, a format tag, a family (obfuscator-wrapper, packer-archive, interpreter-bytecode, source, container, native-format, or unknown), a confidence score, and a specificity rank.

PassRegistry::run_all (chain/registry.rs) runs every registered detector against the bytes. Six extraction-first passes (nuitka.extract, pyinstaller.extract, pyfreeze.extract, pyarmor.unpack, binfmt.container, sourcedefender.decrypt) are tried before the rest, and the sweep stops early the moment one of them returns a High-band verdict (confidence >= 0.90) with specificity <= 30. A raw confidence buckets into ConfidenceBand::Low (< 0.70), Medium (0.70-0.89), or High (>= 0.90).

A SelectionPolicy then picks the winner among whatever verdicts came back: candidates below its minimum confidence (0.5 by default) are dropped, and the survivors are ranked by precedence::compare (chain/precedence.rs), which breaks ties in order: confidence band, then raw confidence, then the lower specificity value, then a fixed family-precedence table (obfuscator-wrapper beats packer-archive beats interpreter-bytecode beats source beats container beats native-format beats unknown), then the lexically smaller pass ID.

The chain driver (chain/state_machine.rs) runs this selection once per queued artifact, executes the winning pass, and re-runs detection on its output to decide what happens next. A branch ends when no verdict clears the minimum confidence (Stalled), when the same output bytes reappear (Cycle), or when the depth cap or cumulative-output budget is exceeded (CapReached). This is why disrobe auto can detect that a PyInstaller archive contains a PyArmor-protected module and route it through the unpack-then-decompile chain without any per-combination glue code.

Standardized emits

The CLI's shared emit parser recognizes these fifteen labels:

source  disasm  ast  cfg  ir  manifest  sourcemap  symbols  strings
imports  signatures  fingerprints  report  recovery  provenancemap

Commands opt into this contract individually. Where a command accepts --emit, pass a comma-separated subset such as --emit source,disasm,report. A supporting command writes an explicit stub when the requested kind does not apply:

{
  "schema": "disrobe.emit.stub/v0",
  "pass": "beam-lift",
  "emit_kind": "fingerprints",
  "applicable": false,
  "reason": "not implemented for the beam pass in this build"
}

Support is not universal. auto accepts only --emit recovery, and commands that do not expose --emit use their own output contract. --all-emits is also command-specific. Check disrobe <command> --help before building a consumer around an emit.

Error codes

Every failure carries a DR-<DOMAIN>-<NNNN> code rendered through miette diagnostics. Look any code up with:

disrobe explain DR-PYARM-0050
disrobe explain CLI-1            # short form also works

The chain runner

The chain runner is what turns a pile of single-purpose passes into a one-command recovery. It backs both disrobe auto (detect and chain automatically) and disrobe chain (drive an explicit pipeline).

Auto-detection

disrobe auto suspect.exe --out recovered/
disrobe auto suspect.exe --out shareable/ --redact

disrobe auto fingerprints the input, picks the highest-confidence pass, runs it, then re-fingerprints the output and repeats until no further pass clears the confidence threshold or the depth cap is hit. disrobe passes prints the auto-chain pass IDs compiled into the current binary, with each pass ecosystem and support tier. Use that output instead of a copied count. Direct commands such as scan, frisk, taint, and webview are separate surfaces and do not become auto-chain passes merely because the CLI can run them. See Pass selection for exactly how the next pass is chosen.

--redact replaces detected secret values in machine output and the generated JSON reports. It does not alter recovered source or extracted binary artifacts. Raw report values remain the default.

Representative chains:

  • PE -> UPX -> rust-demangle
  • PyInstaller -> PyArmor -> .pyc decompile
  • APK -> DEX -> Java + manifest
  • Electron .asar -> unbundle -> source

Explicit chains

When you want to pin the pipeline rather than auto-detect:

disrobe chain input.bin --chain 'pyarmor+py-decompile' --out recovered/
disrobe chain input.bin --chain 'auto:8' --out recovered/        # auto-detect, depth 8
disrobe chain input.bin --chain 'pyarmor+py-decompile' --chain-pin pyarmor@0.10.0,py-decompile@0.10.0

--chain-pin locks each pass to a specific version so a recovery is reproducible against an exact pass build.

Layered payload recovery

disrobe unwraps obfuscated and packed payloads recursively. A structural check gates every step (compression magic, a loadable marshal object, a valid parse, a validated crib), so a decode never advances on garbage, and every decompression is bomb-bounded.

LayerWhat it reverses
Recursive peelStacked encoding and compression down to the real payload. The Python engine unwinds base64/85/32/16, zlib/gzip/bz2/xz/lzma, pyc-strip, marshal, and cipher layers (depth-capped, bomb-bounded); PHP, JavaScript (atob chains), and shell have their own recursive peelers; and the chain driver re-detects and re-routes every carved child, so stacked containers across any ecosystem peel end-to-end
Marshaled Python code objectsA raw CPython marshal blob (1.0 through 3.15) is loaded, its nested code objects (up to 64 deep) recovered, and each layer decompiled to source
Encoding and cipher reversalbase64/85/32/16, base58/62/45/91/92/122, ascii85/Z85, uuencode/xxencode/yEnc, percent-URL, HTML entity, and Punycode, plus gzip/zlib/xz/lzma/bz2 and rot-N. Keyed layers (XOR single and repeating-key, RC4, TEA/XTEA/XXTEA, ChaCha20, Salsa20) are recovered when the key is a literal, a crib, or brute-forceable; custom and shuffled base64 alphabets are sniffed from cribs. A blind cascade keeps only decodes a structural validator accepts; runtime-only-key crypto is stated as a wall, not guessed
Per-language loader unwrapPython exec/eval/compile, PHP eval/assert/preg_replace-e/create_function, JavaScript eval/Function indirection plus esoteric encoders (JSFuck, the Dean Edwards packer, JJEncode, AAEncode) and V8 bytenode/SEA/asar carving, Lua per-obfuscator string and VM recovery, and PowerShell and bash Invoke-Obfuscation families

Depth and cycle safety

Adversarial input can try to make a chain recurse forever (an archive nested inside itself, a packer that re-emits its own signature). The chain runner defends against this:

  • Depth cap. --max-depth (default 8) bounds how many passes can run in one chain.
  • Cycle detection. Each stage's output is content-hashed (BLAKE3); if a stage produces bytes already seen earlier in the chain, the runner stops rather than looping.

Stage mirrors

Pass --capture-stages to materialize the exact bytes written by every executed pass. This records each stage faithfully; it does not mean decompiled source is byte-identical to the compiled input.

recovered/
├── 01-pyinstaller/        # exact recorded output of pass 1
├── 02-pyarmor/            # exact recorded output of pass 2
├── 03-py-decompile/       # exact recorded output of pass 3
├── final/                 # terminal stage(s), linked
│   └── 03-py-decompile/   # symlink -> NTFS junction -> recursive copy fallback (Windows)
├── chain.json             # the chain topology descriptor
└── recovery.json          # per-pass status, confidence histogram, timings

The final/ link prefers a symlink, falls back to an NTFS junction on Windows, and finally to a recursive copy, so final/ always resolves to the terminal artifact regardless of platform and privilege.

chain.json: the topology descriptor

chain.json records the executed pipeline: each pass, its version, the input and output BLAKE3 hashes, the rung transition, byte sizes, and the per-stage verdict. It is the document disrobe diff and disrobe guard verify operate on (see Diff and guard tooling).

Each node includes a metadata object whose keys and values are strings. The object is empty when the stage has no metadata. Registered keys use lowercase dotted namespaces. Serialized keys remain in deterministic lexical order.

anti.recovered_techniques is a published metadata key. Its value is a nonempty comma-separated list of technique labels with no empty elements, embedded commas, or whitespace around delimiters. The encoded value is limited to 4,096 UTF-8 bytes. An absent key means that the stage reported no recovered technique metadata. An empty or malformed value is an error, not an absent value. The CLI reports malformed metadata as DR-CLI-0299 before it derives anti-analysis evidence or writes the final chain output.

recovery.json: the provenance sidecar

recovery.json is the per-run report: each pass's status, a confidence-tier histogram, and timings. Summarize it without reading raw JSON:

disrobe context --out recovered/

This prints per-pass status, confidence tiers, the overall verdict, and provenance, which is the human-facing view of what the chain actually managed to recover and how much to trust it.

The .dr envelope

The .dr envelope is disrobe's content-addressed IR wire format. A caller can persist a supported artifact as one, inspect or verify it without running a recovery pass, and pass it to consumers that accept its rung. The chain runner uses disrobe-core::Artifact between stages and writes separate chain.json and recovery.json records; it does not require every stage to become a .dr file.

Anatomy

A .dr envelope has three parts:

  1. Hot payload (rkyv). The primary typed IR data, serialized with rkyv 0.8. The mmap reader validates the header, declared lengths, and root hash before exposing the archived payload for zero-copy access.
  2. Cold sidecar (postcard). Secondary metadata serialized with postcard, kept out of the hot path so the common case stays fast.
  3. BLAKE3 root hash. A content hash over both the hot and cold bytes. Equal roots identify equal payload and sidecar bytes. The root does not include the header fields, so it is not by itself a claim that two complete envelope files are byte-identical.

The fixed header also carries the schema version, IR rung (see the ladder), flags, hot and cold lengths, and root hash. The postcard sidecar stores the producer, producer version, capability set, and string provenance map. MBA outputs that use the peephole table record its deterministic rule-pack content ID in provenance and in the corresponding chain artifact record; this identifies the exact table and audit data used for that result.

Why content-addressed, not timestamp-addressed

The root binds the typed payload and sidecar to their bytes rather than to a path or timestamp. disrobe envelope verify recomputes that root and rejects a mismatch. Cache entries use a separate key derived from the operation, input bytes, and effective configuration, and the cache reader validates its own stored checksum before returning a hit.

Working with envelopes

# Create an envelope from a source file
disrobe envelope create source.bin --out source.dr

# Inspect: version, rung, capabilities, provenance, root hash
disrobe envelope inspect source.dr

# Verify the BLAKE3 root against the payload
disrobe envelope verify source.dr
disrobe verify source.dr               # convenience alias

# Structurally diff two envelopes
disrobe envelope diff a.dr b.dr        # version, rung, flags, root hash, producer, capabilities, provenance

# Validate one envelope against another envelope's version and rung
disrobe envelope migrate-check source.dr target.dr

migrate-check answers a precise question: can the source envelope be transcoded to the target envelope's (version, rung) through a registered path while every Requires capability remains satisfiable? It validates the proposed transition; it does not rewrite either input.

Transcoding across schema versions

disrobe-ir carries a transcode registry keyed on (from_version, from_rung, to_version, to_rung). Identity transcodes are registered for every current rung. A non-identity transition succeeds only when the caller requests a registered path and its capability requirements remain satisfiable; no implicit rung change occurs.

Hardening

The envelope decoder parses a content-addressed binary format and is treated as a security-sensitive surface. Adversarial envelopes that attempt read-past-end, integer overflow, or BLAKE3-mismatch acceptance are in scope for the security policy. The decoder lives in crates/disrobe-ir/src/envelope.rs and is fuzzed.

Metadata sidecar and provenance

disrobe can emit a structured metadata bundle beside recovered output. The bundle carries call graph, types, control flow, capability surface, decompile provenance, and round-trip verdicts in one schema-conforming sidecar. It is deterministic data derived from the same pass results as the human output; no model runs in the recovery path.

Turning it on

disrobe py decompile module.pyc --out recovered/ --metadata-pack-4
disrobe py decompile module.pyc --out recovered/ --metadata-pack-4 --llm-briefs

--llm remains as a compatibility alias for the full Pack-4 selection. Prefer --metadata-pack-4 in new scripts. --llm-briefs additionally renders AGENTS.md and SKILL.md reconstruction briefs next to the bundle.

By default the bundle is written next to the primary output as <stem>.disrobe.llm.json. Override with --metadata-out <path> and choose the format with --metadata-format json|jsonl|cbor|msgpack.

The four packs

Packs are cumulative presets over the 18 categories:

PackFlagAdds
Pack-1--metadata-pack-1ast + disasm + symbols + strings
Pack-2--metadata-pack-2Pack-1 + cfg + types + imports + provenance
Pack-3--metadata-pack-3Pack-2 + dfg + signatures + constants + roundtrip + sourcemap + manifest
Pack-4--metadata-pack-4 / --llmPack-3 + confidence + opcode-coverage + pii-map + decryption-keys. Only decryption-keys is auth-gated.

The 18 categories

Each category can also be toggled individually:

ast  disasm  cfg  dfg  symbols  strings  types  imports  constants  signatures
provenance  roundtrip-verdict  source-map  manifest-cat  decryption-keys
confidence  opcode-coverage  pii-map

Fine-tune any pack:

disrobe py decompile m.pyc --metadata-pack-3 --metadata-exclude ast,symbols
disrobe py decompile m.pyc --metadata-include cfg,types,provenance

Which commands write a bundle

The metadata flags are declared on the root parser, so every subcommand accepts them, but only these commands act on them. Every other subcommand ignores the flags and writes no bundle, and disrobe auto rejects them with DR-CLI-0843 because the chain engine writes a single chain.json instead.

CommandContributes
disrobe py decompileast, disasm, symbols, strings, imports, constants, signatures, provenance, roundtrip-verdict, source-map, manifest, and cfg + dfg when the input reaches Mir
disrobe py deobsymbols, strings, provenance, confidence, source-map, and cfg + dfg when the input reaches Mir
disrobe py disasmdisasm, symbols, strings, constants, opcode-coverage, provenance, and cfg + dfg when the input reaches Mir
disrobe taintcfg + dfg

Every command in this table also contributes pii-map when requested. A dedicated pass (disrobe-llm-metadata-pii) scans the raw input bytes for PII-bearing indicators and secret findings, independent of which language command ran, so pii-map behaves like cfg/dfg rather than like the per-language categories above: one shared scan wired into the bundle writer, not a per-command implementation. It reports applicable: false with a reason when it finds nothing, distinct from an unimplemented category.

Control flow and data flow

The cfg and dfg categories are summaries of the normalized IR, so a command produces them only for an input that reaches the Mir rung. These input families reach it:

  • Native PE, ELF and Mach-O binaries, through the disassembler.
  • WebAssembly modules, JVM class files, Dalvik .dex, managed .NET PE, SWF and raw ABC, Ruby YARB bytecode, Lua chunks, BEAM modules, and CPython .pyc, each through its own lifter.
  • Any Disasm-rung or Mir-rung .dr envelope.

Both categories are emitted as one entry describing the whole module. function names the unit, blocks carries every basic block with the label of the function it belongs to, and edges carries from and to block indices with a kind of fallthrough, branch_true, branch_false or jump. A functions array carries the per-function address, export flag, cyclomatic complexity and block count. The dfg value reports memory defs, the uses each def reaches, and unreached_reads for reads no write reaches.

An input that never reaches Mir still gets an entry, reported as applicable: false with a reason naming the rung it did not reach. That is a different fact from a module that does reach Mir and genuinely has nothing to report, which is applicable: true with an empty array. A consumer must not treat the two as the same.

Auth-gated categories

The decryption-keys category exposes recovered keys and IVs and is gated: passing --decryption-keys without --i-have-authorization fails with DR-CLI-0420. Other legally sensitive recovery paths document their own authorization gate where the CLI exposes one. The pii-map category itself carries no such gate: it emits only a placeholder and a location for each finding, never the matched value, so it adds no secret material of its own. Other categories such as strings and ast still report full recovered text by design, so a pack-4 bundle as a whole is not a scrubbed artifact.

Provenance sidecars

Independently of the metadata bundle, a chain run writes two provenance artifacts:

  • recovery.json: per-pass status, confidence-tier histogram, and timings. Summarize with disrobe context --out <dir>.
  • provenance/<file>.map.json: a line-level map from each recovered source line to (pass, source_offset, opcode_range, confidence). A reviewer traces any line of recovered source back to the exact bytes it came from, and to the confidence of that recovery.

The .disrobe/ workspace

Scaffold a full agent workspace in the current directory:

disrobe init                    # scaffold .disrobe/
disrobe init --ide claude       # also generate IDE-specific settings (claude, cursor, windsurf, aider)

This lays down an AGENTS.md forensic-framing template, per-symbol annotation schemas under .disrobe/annotations/, skill packs under .disrobe/skills/, slash commands, and a settings hook template that denies edits to ground-truth stage directories (see Diff and guard tooling). Maintain it with:

disrobe annot refresh           # rebuild .disrobe/annotations/<stem>.annot.json
disrobe rename oldName newName --note "why"   # append-only rename record
disrobe context --out recovered/              # summarize the recovery report

Supported families catalog

This is the authoritative per-ecosystem list of every packer, obfuscator, protector, freezer, and bundler disrobe recognizes, with the support tier for each. The live CLI view is disrobe catalog [ecosystem]; a default build, which turns the full feature on, reports 170 families across 15 ecosystems, and the totals on this page are the full ones. Most catalogs sit behind a cargo feature, so a build with features trimmed registers fewer catalogs and reports a smaller total.

cargo run -p xtask -- regen --check re-derives the headline total, the native tier split, and the per-pass counts in the tables below from the tables the binary itself carries (Packer in crates/disrobe-pass-native/src/packers/mod.rs, CATALOG_COUNT in each pass chain_detector.rs, Protector::ALL in crates/disrobe-pass-dotnet/src/protectors.rs, and RaspVendor in crates/disrobe-pass-jvm/src/rasp.rs), so a family added to the binary without this page moving with it fails that check. Three rows count something no catalog table holds, Freezers / packagers, Freezers / packagers (experimental, unvalidated), and JS bundlers (unbundler); for those the check compares the published count against the family list beside it, which keeps the two halves of a row consistent but proves neither against the binary.

disrobe catalog
disrobe catalog native
disrobe catalog python --json

Three words describe how far recovery goes for a family, the same three the README uses:

  • Recover: real recovered output (source, bytes, or structure), measured against an independent oracle where one exists.
  • Partial: the layer is identified and what is intact is extracted or peeled, with the residual stated.
  • Detect-only: identification plus a stated reason the rest cannot be recovered statically.

A wall is the strongest case of detect-only: the data is not in the artifact at all, so no static tool recovers it without the runtime key, the live process, or the network-fetched payload. Every wall is detect-only, and detect-only also covers families that are identified but reported without static recovery. Detect-only is a useful triage result and not a failed analysis: see refusal is a result for why, and reading a result for what to do with one.

disrobe catalog prints the same three tiers with the SupportQuality labels the binary carries (crates/disrobe-core/src/chain/obfuscator_catalog.rs), where full is the Recover tier: [full], [partial], [detect-only].

The disrobe auto chain at the bottom is what stitches these together: it fingerprints the input, runs the matching pass, re-fingerprints the output, and repeats until nothing else applies.

Native packers and protectors (29)

The Packer enum carries 29 variants across five UnpackerStatus tiers (12 + 6 + 3 + 6 + 2 = 29). The native chain detector catalog advertises 27 of them; the two CLR-layer crypters route to the .NET pass, so disrobe catalog native lists 27.

The tier names below are the in-tree UnpackerStatus values, so the split is exactly what the binary advertises. crates/disrobe-pass-native/src/packers/mod.rs test published_tier_counts_match_this_enum asserts every count in the table below against unpacker_status, so a variant cannot be added or moved between tiers without this page failing. The family names in each row are written by cargo run -p xtask -- regen from the same enum, and regen --check fails when a row names a family the binary does not carry, omits one it does, or files one under the wrong tier. Only the Implemented tier reaches an unpack routine, and the same check fails when a family in that row has no dispatch arm in crates/disrobe-pass-native/src/chain_detector.rs or when a family outside it has one.

Tier (UnpackerStatus)CountFamilies
Implemented: byte-exact decoders plus an in-house x86 stub emulator12Donut, sRDI, UPX, ASPack, Petite, MPRESS, FSG, PECompact, Yoda's Crypter, NSPack, MEW, kkrunchy
StubEvalPending: stub emulator validated against a spec-built stub, real-sample recovery tracked6ASProtect, Morphine, nPack, NeoLite, PolyCryptor, Warzone Crypter
GreyZoneDetectAndCarve: virtualizing tier, runtime-keyed handler stream3Yoda's Protector, VMProtect, Themida / WinLicense
GreyZoneDetectOnly: commercial protector tier, reported without static recovery6PE-Protector, PELock, Enigma Protector, Armadillo, Obsidium, WinLicense
DelegatedToDotnet: managed CLR crypter, recovery delegated to the .NET pass2DotNetPatcher, NetCryptor

Donut and sRDI are shellcode loaders, so what the Implemented tier recovers for them is the embedded payload rather than a decompressed image.

The recover tier is scored byte-for-byte against real committed originals: UPX .text and .pdata are bit-identical (the whole loaded image about 96%, the residual being loader-rebuilt relocations and IAT the OS resolves at run time), ASPack and PECompact rebuild the decompressed image with the reconstructed IAT at least 98% byte-identical, and Yoda's Crypter .rsrc is byte-identical with its .text decrypted to full plaintext. NSPack, FSG and Petite each reproduce from a clean checkout with one committed packed-and-original pair apiece. For the NSPack pair the gate holds the whole loaded image at or above 94.1% and the .text, .rdata and .data span at or above 99.3%. A per-section gate scores those three decoders over a wider span that also counts .rsrc: NSPack 57721 of 60060 bytes, FSG 55263 of 60060, and Petite 86986 of 89648. .text and .data are byte-identical for all three, and NSPack's .rdata is byte-identical as well, because its import lookup and address tables are rebuilt from the module record the stub carries rather than left for the loader. Their shared residual is the resource directory, now recovering 2333 of 4672 bytes for NSPack and 1552 of 4672 for FSG once the original tree is placed at its own RVA, and relocations are scored separately as loader-rebuilt. Larger local-only samples score lower on the whole-image measure, and no figure is published for them because those samples are not committed and nothing pins them, so the numbers above describe the committed pairs rather than the families. kkrunchy is byte-exact against committed fixtures and does reproduce. The full breakdown is in the native guide.

Python

SurfaceCountFamilies
Freezers / packagers8PyInstaller 2.x-6.20+, Nuitka (onefile / standalone / module / wheel), cx_Freeze, py2exe, shiv, pex, Briefcase, SourceDefender .pye
Freezers / packagers (experimental, unvalidated)1PyOxidizer (experimental, unvalidated)
Protector (PyArmor)7 versionsPyArmor v6-v9-pro modes are cataloged and routed. Separately, the 72 / 72 manifest-named v8/v9 default-trial wrappers decrypt and decode one complete header-anchored root CodeObject. This self-reported structural result does not cover v6/v7, other v8/v9 wrapper populations, registered-license/pro, BCC, or super mode. The v3-v5 RSA-wrapped-key tier is a runtime-key wall.
Source obfuscators (AST-evaluator)20Kramer/Specter, Berserker, Jawbreaker, BlankOBF, PlusOBF, Wodx, pyobfuscate.com, pyobfuscate.com (2026 XOR/lambda), PyObfuscator (mauricelambert), python-obfuscator (PyPI), ObfuXtreme, Manglify, Oxyry, pyminifier, online-obfuscator family, Xindex, pyobfus, Pypacker, Patchwork, pyc-zipper

Jawbreaker's b16/b32/b64 loader shell is decoded statically, but a payload it fetches from a remote paste at run time is absent from the file. ObfuXtreme's AES-CBC/b85/xor static body is recovered; its runtime-payload segment is not in the artifact. python-obfuscator (PyPI), pyobfus, and Pypacker are detect plus partial-peel. Compiled Cython extensions (.pyd / .so) have their Python-visible surface (function and class names, docstrings, signatures) recovered from the module's symbol tables, with a structural fallback when the binary is stripped. See the Python guide.

JavaScript / TypeScript / WebAssembly

SurfaceCountFamilies
JS chain catalog104 obfuscators (obfuscator.io full pipeline, JS-Confuser, Jscrambler, js-obfuscator (jsobfu)) plus 6 bundler markers (webpack, Vite, Rollup, esbuild, Turbopack, Bun)
JS esoteric encoders + protectorsseparate detectorsJSFuck, aaencode, jjencode, JSFiretruck, Dean Edwards Packer (decoded); JSDefender and Arxan / Digital.ai (detect + static-transform peel); PACE (detect-only)
JS bundlers (unbundler)11webpack 4, webpack 5, Vite, Rollup, Rolldown, esbuild, Turbopack, Bun, Parcel, Browserify, SystemJS
WASM obfuscators5 (catalog)Jscrambler-WASM, Wobfuscator, Tigress-via-Emscripten, Wasmixer (3 with transforms undone on modelled output, no artifact from the tools themselves is committed); Tigress-via-Emscripten and wasm-name-obfuscator are detect + classify only because the Tigress dispatcher and name helpers are not on the wasm deob run path, and wasm-name-obfuscator's hex renames destroy the original names

The JS and WebAssembly guides cover each pipeline.

JVM / Android / .NET

SurfaceCountFamilies
JVM / Android protectors10ProGuard/R8 (mapping replay), Zelix KlassMaster, Allatori, Stringer, DashO, DexGuard (detect + structural peel, with in-class string-decrypt emulation for the keyed-constant variants), BlackObfuscator (DEX deflattening); yGuard, SkidSuite2, JBCO (detect-only)
Android RASP vendors8Promon SHIELD, Guardsquare DexGuard RASP, Guardsquare ThreatCast, Appdome, OneSpan, Arxan / Digital.ai, Zimperium zShield, Licel DexProtector
.NET protectors23ConfuserEx, ConfuserEx2, Dotfuscator, Dotfuscator CE, SmartAssembly, Babel, DeepSea, Spices.Net, Goliath, Skater, .NET Reactor, Eazfuscator.NET, CryptoObfuscator, ArmDot, Agile.NET, Obfuscar, Themida (.NET wrapper), ILProtector, MaxToCode, KoiVM, DotNetPatcher, NetCryptor, BitMono

On .NET, ConfuserEx2 constant decryption is reversed on a real committed sample, the Eazfuscator VM tier is devirtualized at 67 of 67 instructions across six bodies against an in-repo EazVM virtualizer of our own, and the KoiVM VM tier is devirtualized on a sample produced by the real KoiVM tool (6 of 6 bodies lifted to CIL). ILProtector, MaxToCode, and the Themida/.NET wrapper derive their per-method key in a native loader absent from the artifact, so those bodies are runtime-key walled. See the JVM and Android and .NET guides.

Detection covers every family in that row. String decryption is a narrower claim and it rests on different evidence per family. It is graded against a committed assembly the protector's own tool produced for ConfuserEx2, Obfuscar, BitMono. For SmartAssembly, Spices.Net, Skater, .NET Reactor, Eazfuscator.NET, CryptoObfuscator the decoder implements the published algorithm and is graded against a fixture built to that algorithm; no assembly produced by those products is committed, so each of those families is detected and its decryption modelled, not confirmed against a real protected assembly. String recovery stops at detection for Themida (.NET wrapper), ILProtector, MaxToCode, whose keys live in a native loader the artifact does not carry.

Lua

The Lua chain catalog is 16 entries: 14 obfuscator families plus the Luau and GLua dialect detectors.

SurfaceCountFamilies
Obfuscators14IronBrew2 (full VM devirtualization), Prometheus, MoonSec V1, MoonSec V2, MoonSec V3, AztupBrew, DarkSec, Boronide, PSU, WeAreDevs, luaobfuscator.com, SLua, Hercules, Luraph
Dialect detectors2Luau bytecode, Garry's Mod Lua (GLua)

IronBrew2 2.7.0 is reversed on real committed output in standard and MAX mode, validated by a real-lua execution differential. MoonSec-shape recovery runs against a synthetic bootstrap of our own design pending a real sample. The Lua guide walks the devirtualizer.

Shell

SurfaceCountFamilies
Shell obfuscators19PowerShell Invoke-Obfuscation (Token, AST, String, Encoding, Compress, Launcher), Invoke-Stealth, PowerHell, Chameleon, psobf, ISESteroids; Bashfuscator (Token, String, Obfuscate, Compress), bash IFS/eval indirection, and node-bash-obfuscate; Batch %random% and set-indirection

Full VBA p-code decompile (264-opcode table, VBA3/5/6/7) with VBA-stomping detection rounds out the shell pass, alongside Excel 4.0 (XLM) macro-formula recovery (BIFF8/BIFF12 Ptg decode, shared-formula and Auto_Open resolution) and PDF maldoc analysis (embedded JavaScript, launch and embedded-file actions, both xref forms, RC4/AESV2 empty-password decrypt). See the shell guide.

PHP

SurfaceCountFamilies
Commercial encoders3 (catalog)ionCube, SourceGuardian, Zend Guard: envelope detect and wall (the decrypt key is native-loader-resident), with a partial op_array skeleton for the legacy statically-keyed cases

Stacked eval-chain obfuscation (FOPO, Better PHP Obfuscator, and the base64/gzinflate/rot13/XOR layer set) and Phar archives are peeled and walked in the same pass. See the PHP guide.

Other runtimes

EcosystemCoverage
Gogarble report graded None / Detected / Partial / Full; garble -literals simple and full-key literals recovered through static blob pairing plus bounded x86-64 thunk/inline emulation. Type names resolved above an 85% floor on the committed go1.26.3 fixture.
RubyMRI/YARV 2.6-3.4 and mruby recompile-equivalence decompile; Ruby2Exe and Ocra freezers detected; JRuby and TruffleRuby AOT classified.
BEAM.beam and .ez chunk parse, Core Erlang lift, Elixir Dbgi quoted-AST recovery.
Swift / Obj-CMach-O class-dump plus SwiftShield mapping parser and explicit-key single-byte XOR blob decoding; objc_msgSend call sites in recovered native bodies resolved to selector and receiver class.
ActionScript 3SWF parse and AVM2 disasm; commercial obfuscators (secureSWF, DoSWF, Kindi, Irrfuscator, swfLock) detect-only.
Hermes / FlutterHermes bytecode v60-v96 header parse, v76/v84/v96 lift against a real hermesc sample; Flutter Dart kernel byte-exact body recovery and ARM64 AOT disasm, graded against a self-authored Dart 3.12.2 android-arm64 product-mode corpus and, measured locally, a real RustDesk 1.4.9 libapp.so fetched by pinned hash.
Containers / archives / filesystems / firmware / encrypted volumes102 formats detected. 101 carry a generic in-tree extractor; LUKS1 carries a bounded raw-volume-key route and a typed keyless wall. 41 generic routes are driven to member bytes by an input this repository commits; LUKS1 is graded separately against tracked plaintext.

The disrobe auto chain

disrobe auto is the front door to the pass registry compiled into the current binary. It fingerprints the input, picks the highest-confidence pass, runs it, re-fingerprints the output, and repeats until no further pass clears the confidence threshold or the depth cap is hit. disrobe passes prints that registry. Some cataloged operations are reachable only through direct commands; disrobe --help is the authority for those surfaces.

disrobe auto suspect.exe --out recovered/                 # detect + chain the whole pipeline
disrobe auto suspect.exe --out recovered/ --capture-stages # keep the exact output written by each stage
disrobe auto firmware-dir/ --out out/ --batch-max-depth 6

Representative chains:

  • PE -> UPX -> rust-demangle
  • PyInstaller -> PyArmor -> .pyc decompile
  • APK -> dex -> Java + manifest
  • Electron .asar -> unbundle -> source
  • .NET PE -> in-house CIL decompile -> C#

Compiled webview frontends also have a direct static recovery path:

disrobe webview desktop.exe --out frontend/

The standard CLI build does not advertise webview.carve in its auto registry, so use the direct command for Electron, Tauri, and Wails assets.

With --capture-stages, stage outputs land in out/01-*/, out/02-*/, ..., out/final/. The full mechanism, including the depth cap, cycle detection, and the chain.json topology descriptor, is in The chain runner.

Anti-analysis defeat

disrobe is a static, deterministic analyzer that never runs the sample on the default path. It recognizes the standard anti-static-analysis arsenal and recovers what is statically recoverable, stating a wall where the data is genuinely absent rather than fabricating past it.

Signature defeat

Identification never trusts a single magic byte. A zeroed or flipped magic, renamed UPX0/UPX1 sections, or a corrupt UPX! marker is re-identified from internal self-consistency:

  • PE through e_lfanew to the COFF and optional headers.
  • ELF / Mach-O by header offsets that close against the file length.
  • ZIP by its end-of-central-directory anchor.
  • DEX by section-offset consistency.
  • Classfile by a constant-pool walk.
  • wasm by the LEB section stream.

A real UPX executable with a flipped MZ and renamed sections still unpacks byte-identically, because the structural PackHeader (method id, self-consistent compressed and uncompressed lengths, plausible version) is the signal a scrambler cannot remove without breaking the stub's own ability to self-extract.

Code-signing verification

Malware often ships with a broken, expired, self-signed, or mismatched Authenticode signature to look trustworthy at a glance. For a signed PE, disrobe native identify verifies the signature end to end rather than trusting its presence: it recomputes the Authenticode hash and compares it to the claimed digest, walks the PKCS#7/CMS certificate chain to an embedded bundle of trusted code-signing roots, requires the code-signing extended key usage on the leaf, and cryptographically verifies any RFC 3161 timestamp before letting it extend validity. The result is a single verdict (Valid, HashMismatch, Expired, SelfSigned, UntrustedChain, WrongKeyUsage, and the rest), so a tampered .text, a forged timestamp, or a chain that does not reach a trusted root is surfaced instead of silently accepted. The native guide lists the signed-fixture and osslsigncode cross-checks behind it.

String and data encryption

SchemeWhat disrobe does
Single-byte XOR stack stringsRecovers them with English-likeness key detection, on native via the in-house x86 emulator driving each decoder-shaped function.
Per-family keyed stringsMirai, Dridex, and Trickbot keyed-string schemes decoded from their known transforms.
JVM string encryptionEmulates the in-class decrypt(String) / decrypt(int, String) method over the encrypted constants, running <clinit> for a static key or constructing the receiver for an instance key.
.NET constant decryptionConfuserEx2 constants reversed on a real committed sample by emulating the in-assembly decryptor.
JS string-array rotationThe rotated string array is rebuilt and call sites inlined.
Python exec/eval/compile payloadsUnwrapped through base64/85/16/32 and zlib/lzma decode chains.

Runtime-keyed schemes (a key from a system property, the environment, the clock, a secure random, or a live cross-class table) are flagged as walls, not guessed.

Control-flow obfuscation

  • BlackObfuscator DEX flattening is deflattened: the String.hashCode()-keyed dispatcher is recognized, each block's const-string name is matched to its switch case, and the original linear block order is recovered and annotated in the output.
  • OLLVM-style control-flow flattening is deflattened on native, and the result is reported as a cover rather than as an equivalence. disrobe enumerates every state the dispatcher can select out of the sample's own compare tree or jump table, resolves the state transition out of each case region by value-set analysis over the state variable, and reports which states the recovered program reaches. A state it cannot reach stays in the denominator and carries a named reason. The deobf.json sidecar publishes the whole cover: the state list, the recovered state-to-state edge set, the case regions with their block spans, and one reason per uncovered state. Its fully_recovered flag is true only when all three of these hold: every dispatcher state is reached, no reached state has an unresolved transition, and the recovered edge set passes its consistency check. It is not a claim that the recovered program and the flattened one agree on every input. Bogus control flow and instruction substitution are reversed on the same path.
  • Proven-dead conditional arms are folded on the AArch64 native decompile path by a symbolic devirtualizer that runs before structuring (on by default, disabled with --no-devirt). The fold is transactional: on any proof miss or budget exhaustion it reverts to the original function, so it only ever replaces a construct with a proven-equivalent one and never invents an edge.
  • Obfuscator-planted out-of-range exception entries that poison the JVM control-flow graph are dropped before structuring.
  • Flattened JS dispatchers are collapsed back to structured control flow.

Anti-disassembly and MBA

The JVM, Dalvik, and CIL decoders tolerate broken StackMapTable, fake exception ranges, and illegal-but-verifiable bytecode. On native, jump-into-the-middle desync, overlapping instructions, and opaque predicates are resolved in-tree. A mixed-boolean-arithmetic simplifier, wired through the JS and WebAssembly decoders as well, collapses MBA expressions back to their algebraic form through a layered stack: linear signature solving, nonlinear reduction modulo the null-polynomial ideal, e-graph equality saturation over proven ring identities, bounded enumerative synthesis for opaque leaves, and permutation-polynomial inversion. Each layer is sound or abstains, and a rewrite is emitted only after equivalence is proven over the full bitvector domain, so an expression the stack cannot prove is left untouched rather than approximated.

Indirect dispatch is resolved before the SMT tier is ever consulted. A strided-interval value-set analysis reads the masked, compare-guarded, or position-independent index bound off the path constraints and enumerates the jump table to a concrete target set that over-approximates, and usually equals, the reachable targets. It abstains when the table is writable or the index is unbounded, and defers to the solver only for a disequality residual, rather than narrowing past what it can justify. The over-approximation property is unit-tested and graded against a real gcc-compiled switch; it is not proved for every input. The value-set tier carries no solver dependency and compiles without one.

Every SMT verdict the simplifier and the devirtualizer depend on is independently checked before it is trusted. A SAT verdict is re-evaluated against the model the solver returned; an UNSAT verdict is reconfirmed by BDD bit-blasting, or, for the multiply-heavy opaque predicates the bit-blaster cannot settle, by a finite-difference polynomial certificate. A verdict that fails its own check, or a solver that panics or exhausts its budget, degrades to abstain, so a solver bug the independent check catches costs a recovery rather than producing a wrong answer. The external differential runs against pinned Z3 4.16.0 in CI and includes a seeded wrong rewrite as its control. The same harness supports Bitwuzla for local runs, but CI does not provision it. A defect shared by both the solver and its checker is outside what the differential can rule out.

Bytecode virtualization

TargetStatus
Lua (IronBrew2 2.7.0)Devirtualized in standard and MAX mode, graded by a real-lua execution differential.
Native generic VMdisrobe native devirt locates the interpreter, fingerprints each handler's micro-op behaviorally through the in-tree x86 emulator, and lifts to a re-executable IR plus pseudo-code, validated end-to-end on a self-authored Tigress-shape VM (the recovered IR re-executes byte-identically from machine code alone).
VMProtect / Themida / Enigma front-endsExtended from published RE write-ups, not a running commercial sample. A per-machine-keyed handler stream is the residual wall.

Overlay inflation

The PE overlay carve computes the true end of the executable image and isolates any trailing archive (gzip, xz, zstd, bzip2, tar, 7z, cab, rar) into its own segment, so padding cannot mask an appended payload.

Symbol stripping

ProGuard/R8 names are restored from mapping.txt (overload-correct), Go type and stdlib names are recovered from pclntab/moduledata on stripped binaries, Rust/C++/Swift/Itanium symbols are demangled, and structure is recovered from DWARF. garble name-hashing (HMAC-SHA256 over an absent build seed) is a wall, but structure, types, and control flow recover regardless.

What grades each capability

An oracle that can reject a wrong answer (a compiler, a runtime, a verifier, exhaustive enumeration, or concrete re-execution) grades most rows below. The anti-disasm, noreturn, and path-sensitive rows state an in-tree gate instead. Partial and Detect-only rows state their residual.

CapabilityWhat it doesGrading oracle
Opaque-predicate foldFolds OLLVM bogus-control-flow always-taken / always-dead branches to their constant outcomecrates/disrobe-pass-native/tests/ollvm_passes.rs (OpaqueResult::AlwaysTaken, real classify_fla.bin and self-authored predicate)
Control-flow-flattening deflattenEnumerates every state an OLLVM-flattened dispatcher can select, resolves the transition out of each case region, and places the reachable ones as a direct-edge program. Each state it does not reach is reported with a named reason instead of being droppedcrates/disrobe-pass-native/tests/cff_dispatcher_cover.rs. Cover is 9 of the 9 dispatcher states across the two committed OLLVM *_fla.bin functions, with each denominator read back out of that sample's own compare tree rather than typed into the test. Separately a bounded stub_emu differential runs the flattened bytes and the re-emitted recovered bytes on 15 arguments per function and compares the return value against corpus/native/ollvm/probe_src.c, which is 60 executions under a 200000-step and 16 KiB-stack budget. A seeded missing state and a seeded wrong edge each turn one of those gates red
Verified MBA simplifyCollapses mixed-boolean-arithmetic back to algebraic form through the layered simplifier described above, then proves equivalence over the full bitvector domain before emittingThe acceptance gate records a proof at the expression's actual width through exhaustive enumeration where the domain is runnable, exact linear-column identity, BDD bit-blasting, or a finite-difference polynomial identity. A candidate is emitted only with one of those proven verdicts; otherwise the input is left untouched
OLLVM substitution undoLifts substituted arithmetic sequences (including shift-encoded carries and movzx/xchg-loaded narrow operands) back to the original operation, proven minimalollvm_passes.rs (undo_ollvm_substitution, asserts changed && proven, simplified_nodes < original_nodes)
Jump-table + PIC switch recoveryResolves register-indirect dispatch and position-independent switch tables to concrete case-to-target listsdisrobe-pass-native deobf, graded by stub-emulator dispatch equivalence with clobbered-base and out-of-image counter-tests
Stack-string reconstructionDrives each decoder-shaped function through the in-house x86 emulator to recover plaintext that only exists after the decoder runscrates/disrobe-pass-native/tests/stack_string_oracle.rs (gcc-compiled object, stub_emu CPU memory state)
ABI / calling-convention inferenceInfers calling convention, argument count, and return value from liveness on stripped codecrates/disrobe-pass-native/tests/abi_inference_oracle.rs (real clang-compiled prototypes, graded vs the source prototype)
Static type recoveryRecovers per-slot integer width and signedness from instruction semantics, splits a reused stack slot into distinct objects through region-typed memory-SSA and live-range analysis, and reconstructs struct, array, and union shape from access paths. Types resolved from a known library or OS prototype propagate backward into the caller's locals with library!function provenance; a slot with no sign signal, or an unresolved or conflicting call target, is reported unknown rather than guessed. native decompile emits the result as a types.json sidecarcrates/disrobe-typerec graded against an unstripped sibling's DWARF on an O0 corpus: width and struct field offset/width recall 1.0, live-range splitting lifts signedness recall from 0.25 to 1.0 on slot-reuse cases, with mutation checks that reject seeded-wrong widths, signs, offsets, and merged or invented fields
Solver-free indirect-dispatch resolutionA strided-interval value-set analysis resolves masked, compare-guarded, and position-independent indirect jumps to a concrete target set that over-approximates the reachable targets, usually exactly. It abstains, or defers to the SMT tier on a disequality residual, rather than narrowing past what it can justifydisrobe-mba jump-table VSA, unit-tested for the over-approximation property and graded against a real gcc-compiled switch (crates/disrobe-mba/tests/jumptable_compiler_oracle.rs, crates/disrobe-mba/src/jumptable/vsa.rs)
Copy-prop + branch-fold cleanupRegister copy-propagation and dead-store elimination over junk-shuffle blockscrates/disrobe-pass-native/tests/copyprop_oracle.rs (concrete re-execution, live register equal before and after across seeds)
Path-sensitive dead-code removalDrops blocks unreachable under the resolved predicate constraintsdisrobe-pass-native deobf/pathsense.rs, applied only on a proven path constraint
Anti-disasm toleranceResolves jump-into-the-middle desync, overlapping instructions, and junk bytes; the JVM/Dalvik/CIL decoders tolerate broken StackMapTable and fake exception rangesin-tree, exercised on real obfuscator output and malformed-bytecode fixtures
noreturn propagationPropagates non-returning calls so the disassembler stops decoding junk past a terminal calldisrobe-pass-native flow analysis on the disassembled call graph
Generic VM devirtLocates the interpreter, behaviorally fingerprints each handler through the x86 emulator, and lifts to re-executable IR plus pseudo-codecrates/disrobe-pass-native/tests/vm_devirt_oracle.rs (clang-compiled synthetic VM, recovered IR re-executes byte-identically from machine code alone); Lua IronBrew2 2.7.0 graded by a real-lua execution differential
Source-to-sink taint trackingTracks a value from a configured source call through register and stack propagation to a configured sink call over the normalized IR, following a call into its callee's own def-use rather than stopping at the call boundarycrates/disrobe-taint/tests/graded_corpus.rs against the NIST SARD Juliet Test Suite v1.3's own manifest.xml and per-file Flow Variant header (never a value authored in this repository): 93 of 190 labeled CWE-78 command-injection flows recalled (48.9%) at gcc 16.1.0 -O2, 0 false positives across every populated flow category. Recall drops to 12 of 190 (6.3%) at -O0 on the same corpus and compiler, and inter-procedural flow at depth greater than one stays at 0 of 15 true positives at both levels, a measured ceiling. Only the native lift path is graded; wasm, JVM, Dalvik and CIL inputs reach the same engine but have no graded corpus yet

Warning the analyst before anything runs

disrobe also flags the evasion a sample attempts. disrobe behavior and disrobe capabilities surface al-khaser / Pafish-class anti-debug, anti-VM, anti-sandbox, and timing checks, mapped to MITRE ATT&CK and MBC, with a confidence grade per technique. This is detection only: disrobe never executes the sample on its default path and never implements any of these techniques itself.

Runtime-keyed protection

With a matching pyarmor_runtime, the static path is used where supported. Its published 72-of-72 structural result is limited to manifest-named v8/v9 default-trial wrappers that decode to complete header-anchored root CodeObject values. It does not establish source recovery, original .pyc identity, execution, or semantic equivalence. v6/v7 may need the opt-in, sandboxed dynamic-capture path rather than emitting fabricated plaintext. ionCube, SourceGuardian, Zend Guard, ILProtector, and MaxToCode derive their key in a native loader or live process absent from the artifact, so they are walled and reported absent.

See the forensics and malware-safety posture for how the default static path stays safe on untrusted input.

Queryable IR and capabilities

disrobe query and disrobe capabilities turn a stripped binary into something you can interrogate. Both run over the same symbol-independent IR the native disassembler builds (disrobe-query and disrobe-capabilities), and both accept a raw binary or a .dr envelope.

disrobe query: ask the IR a question

disrobe query app.exe functions                 # discovered functions, complexity, exports
disrobe query app.exe calls-to malloc           # call sites to a target
disrobe query app.exe xrefs-to sekret           # references to a symbol or address
disrobe query app.exe string-decoders           # decoder-shaped functions (loops + byte arith)
disrobe query app.exe complexity-over 20        # functions over a cyclomatic threshold
disrobe query app.exe capability network        # instructions tied to a capability
disrobe query classes implementors 'Lpkg/Root;' # concrete JVM/DEX implementors

The query layer is built on the same function discovery the disassembler uses (call-target and prologue scanning), so it works without a symbol table. It accepts a .dr envelope at the Disasm or Mir rung; an envelope at any other rung is rejected with an explicit unsupported-rung error rather than silently returning empty or wrong results. The six verbs are:

VerbReturns
functionsEvery discovered function with its address, size, cyclomatic complexity, and any export name.
calls-to <target>Call sites that reach a named import or address.
xrefs-to <symbol>All references to a symbol or address, code and data.
string-decodersFunctions shaped like a string decoder: a loop plus byte arithmetic over a buffer.
complexity-over <n>Functions whose cyclomatic complexity exceeds a threshold, to triage the dense routines first.
capability <name>Instructions tied to a capability category (network, filesystem, process, crypto, and so on).
implementors <descriptor>Concrete JVM or DEX classes reachable from an interface or abstract-class descriptor, with a child-to-target inheritance proof.

Every query honors the global --json flag, so the output drops straight into a script.

implementors accepts a single .class or .dex file, or a directory containing .class and .dex files. JVM descriptors such as Lpkg/Type; use nonempty components and allow Unicode, hyphens, and control characters except ., ;, [, and /. DEX descriptors follow the file version's SimpleName grammar: ASCII letters and digits, $, -, _, and the format's declared Unicode ranges are valid in every position; DEX 040 additionally admits its declared space characters. Controls, parentheses, and code points outside those ranges are rejected. Target descriptors are limited to 1,048,576 UTF-8 bytes. Quote descriptors at a shell prompt because the terminator is a semicolon. Text output escapes control characters; JSON preserves accepted descriptors. Results and proof paths are sorted by descriptor. The result records missing targets, duplicate definitions, malformed edges, cycles, rejected directory artifacts, and every traversal budget that produced a partial result. A rejected .class or .dex in a directory does not discard valid siblings. Malformed, missing-definition, and rejected-artifact identities are capped independently by count and byte length, with typed truncation diagnostics. APK and JAR containers are not query inputs, and DEX 041 container-relative layouts are rejected explicitly.

The MCP companion exposes the same module model through call_graph, xrefs, function_summary, and neighborhood. These tools use ids derived from the module source hash and function address rather than function names, so duplicate names remain addressable. Direct-call outcomes distinguish an exact function start, an address inside one function, an ambiguous overlap, a non-function symbol, and an unresolved address. Calls without a direct target remain explicit indirect-call records.

call_graph, xrefs, and neighborhood paginate deterministically. Each cursor binds the source hash and request parameters, and a cursor from another module or neighborhood is rejected. A content-derived discriminator keeps distinct same-address function records addressable without depending on their position in the module. Graph construction has fixed function, instruction, call, cross-reference, candidate-work, and retained-memory ceilings before response pagination. Neighborhood traversal records visited ids and bounds both depth and retained records, so recursion and mutual recursion terminate without dropping their cycle edges.

disrobe capabilities: behavior to ATT&CK

disrobe capabilities app.exe
disrobe capabilities app.exe --json

disrobe capabilities runs a rule engine over the same IR and reports the behaviors it matched, each mapped to MITRE ATT&CK techniques and Malware Behavior Catalog (MBC) IDs. Every match carries the instruction offsets that triggered it as per-rule evidence, so a finding can be traced to the exact bytes rather than taken on faith.

The report names the detected format, the match count, and the per-rule evidence. It is the same engine surfaced through the MCP server and the --llm sidecar, so an agent gets the capability surface without re-deriving it.

Where the IR comes from

The native disassembler (an in-tree iced-x86 decoder, detailed in the native guide) discovers functions, partitions them into basic blocks, and records each instruction's register, memory, and rflags effects. The query and capability layers read that structure rather than the original symbols, which is why a stripped binary answers the same questions a symbol-rich one does. Pointing either tool at a .dr envelope reuses a cached disassembly instead of re-decoding the bytes.

Recon, prowl, and indicators

disrobe frisk is disrobe's built-in recon engine. Point it at any file, directory, APK, or disrobe-recovered source tree and it surfaces leaked secrets (cloud keys, SaaS/AI tokens, private keys), API endpoints and routes, cloud-storage buckets, Android manifest exposure (deep-link schemes and hosts, exported components, content-provider authorities, dangerous permissions), and IOCs (URLs, domains, IPs, emails, .onion, webhooks), each with its file, line, and column. Because disrobe recovers the real source first, frisk searches truth, not a shell grep, and it is fully encoding-safe.

Usage

disrobe frisk app/                                  # walk a directory or recovered source tree
disrobe frisk app.apk                               # APK manifest exposure + secrets + IOCs
disrobe frisk recovered/ --format json              # text, json, or sarif
disrobe frisk recovered/ --format sarif > frisk.sarif
disrobe frisk app/ --pattern rules.txt              # custom rule pack: name=regex per line
disrobe frisk app/ --suppress example.com           # drop findings whose value contains a substring
disrobe frisk app/ --emit-baseline > baseline.json  # snapshot current findings
disrobe frisk app/ --baseline baseline.json         # report only new findings
disrobe frisk app/ --entropy                        # include high-entropy generic-secret findings
disrobe frisk app/ --redact --format sarif          # replace secret values in shareable output

frisk is offline. Network enrichment is explicit through prowl, and schema merging is explicit through indicators.

Secret values remain visible by default. Pass --redact, or set output.redact = true, to replace detected secret values throughout text, JSON, SARIF, and HTML reports. Redaction runs after suppression and baseline filtering. It preserves finding counts, locations, and offsets.

Each token contains the first 96 bits of the unsalted SHA-256 digest. The same secret therefore receives the same token across runs. A 96-bit identifier makes an accidental collision improbable. Redaction returns an error when renamed JSON object keys would collide. Values shorter than 16 characters reveal no source characters. Longer values include only the first two and last two characters. Literal replacement processes longer matches first so an overlapping shorter value cannot expose part of a longer value.

Redaction scrubs finding values, previews, messages, paths, URI user information, and nested serialized fields. Nested JSON traversal stops at 64 levels, 1,048,576 values, or 64 MiB of string data. The command returns an error instead of emitting a partially redacted document when a limit is exceeded or redacted object keys would collide.

Decoded string layers

A secret hidden behind an encoding is still a secret, so frisk does not scan only the literal bytes. It peels base58, base62, base45, base91, base92, base122, Ascii85, Z85, uuencode, xxencode, yEnc, percent-encoding, HTML entities, and Punycode recursively, with decompression-bomb caps at every level, and rescans each recovered layer for the same secrets and IOCs. A finding inside a decoded layer reports the position in the file that carried it.

Wide strings

frisk and ioc read UTF-16 text in both byte orders on every input. No flag turns wide scanning on or off, and no flag selects a byte order.

A wide run is four or more ASCII characters, each held in a two-byte code unit whose other byte is zero. Eight bytes is therefore the shortest run that can be reported. A run may start at any byte offset, odd or even. The indicator scan accepts printable ASCII, tab, line feed, and carriage return inside a run. The endpoint and .onion pass accepts printable ASCII only. A code unit outside the accepted set ends the run, and the scan resumes at the next position rather than stopping.

The endpoint and .onion pass in disrobe frisk reads at most 65536 wide runs per file for each byte order, and stops a single run at 65536 bytes. The indicator scan stops after 100000 indicators for one input, wide and plain findings together.

What a wide scan reports

The indicator layer reads wide runs in both byte orders. URLs, domains, IPv4 and IPv6 addresses, emails, and wallet addresses surface from wide text in both commands. disrobe ioc also reports Windows paths, registry keys, and Unix paths found in wide text. disrobe frisk keeps those three only when the value matches a persistence indicator. The endpoint rules and the .onion rule run over each wide run as well.

Secret rules and --pattern rule packs do not read wide runs. They match the file's own bytes and its lossy UTF-8 text. A credential stored only as UTF-16 is reported only when the secret rule matches the raw bytes. frisk and ioc read UTF-16 only, and neither reads UTF-32.

disrobe frisk anchors every finding taken from a wide run to the file offset, line, and column where the run starts, not to a position inside the decoded text. disrobe ioc reports the run's file offset plus the index of the match inside the decoded run, so the offset is exact for a match at the start of a run and approximate for a match further in.

The opposite reading of the same bytes starts one byte later and produces a different value. Both readings can therefore report a finding over one run. Read the rule id suffix, or the encoding value, to see which reading produced a given finding.

Byte order in the output

SurfaceWhere the byte order appears
disrobe frisk, every formatThe rule id gains -UTF16LE or -UTF16BE, for example DR-RECON-ONION-UTF16BE.
disrobe ioc, text outputThe encoding column reads utf16le or utf16be.
disrobe ioc --format jsonThe encoding field reads utf16_le or utf16_be.
disrobe ioc --format sarifThe rule id comes from the indicator kind and carries no suffix. The encoding appears in the result message.
disrobe ioc sample.bin --format json | jq '.indicators[] | select(.encoding == "utf16_be")'
disrobe frisk recovered/ --format json | jq '.findings[] | select(.rule_id | endswith("-UTF16BE"))'

In disrobe frisk, the suffix is part of the fingerprint that --emit-baseline and --baseline compare. A wide finding and its plain-text twin are two separate entries in a baseline.

Finding categories

Every finding carries a category, a rule id, the matched value, a severity, and a file:line:column (or byte offset for non-text input).

CategoryWhat it surfaces
secretCloud keys, SaaS and AI provider tokens, private keys, webhook URLs. High-entropy generic secrets are gated behind --entropy.
endpointAPI endpoints, routes, and request targets recovered from source.
manifestAndroid manifest exposure: deep-link schemes and hosts, exported activities/services/receivers/providers, content-provider authorities, dangerous permissions.
urlHTTP and HTTPS URLs.
domainBare domains.
ipv4, ipv6IP-address IOCs.
emailEmail addresses.
onionTor v2 and v3 .onion hidden-service addresses.
customMatches from a --pattern rule pack.

Flags

FlagEffect
--format <text|json|sarif>Output format. SARIF 2.1.0 drops into GitHub code scanning.
--pattern <FILE>Custom rule pack, one name=regex per line; # comments allowed.
--suppress <SUBSTR>Drop findings whose value contains the substring. Repeatable.
--emit-baselinePrint the current findings as a baseline JSON array to snapshot.
--baseline <FILE>Report only findings absent from the baseline array.
--entropyInclude high-entropy generic-secret findings.

Custom rule packs

A rule pack is one name=regex per line:

# rules.txt
internal-host=https://[a-z0-9.-]+\.corp\.example\.com
deploy-token=DEPLOY_[A-Z0-9]{32}
disrobe frisk recovered/ --pattern rules.txt --format json

Baselines

Snapshot the current findings, then report only new ones on later runs, so a CI gate fires only when a fresh secret or endpoint appears:

disrobe frisk app/ --emit-baseline > frisk-baseline.json
disrobe frisk app/ --baseline frisk-baseline.json --format sarif > new-findings.sarif

Network harvest with prowl

disrobe prowl example.com --subs --sources wayback,commoncrawl,urlscan --format json > prowl.json
disrobe prowl --targets-file targets.txt --proxy http://127.0.0.1:8080 --timeout 20
disrobe prowl --recon-input frisk.json --ioc domain,ipv4,email
disrobe prowl keyring set virustotal

disrobe prowl queries public archives and threat-intel feeds and writes disrobe.prowl/v0. The source labels are:

SourceNotes
waybackWayback capture URLs, date-filtered by --from and --to.
commoncrawlCommon Crawl index URLs.
otxAlienVault OTX URLs and pulses; optional key.
urlscanurlscan submissions; optional key.
crtshCertificate transparency names from crt.sh.
urlhausURLhaus URLs and payload IOCs; key-supported.
threatfoxThreatFox IOCs; key-supported.
virustotalVirusTotal URL/domain data; vt is accepted as an alias and a key is required.

Important flags:

FlagEffect
--targets-file <FILE>Read one domain or URL per line.
--stdinRead targets or a prior recon/IOC JSON report from stdin.
--recon-input <FILE>Seed targets from frisk, ioc, or prowl JSON.
--sources <LIST>Comma-separated source labels; empty means every source.
--subsInclude subdomains for the target.
--blacklist <EXT>Drop URLs with matching extensions.
--mc <CODE> / --fc <CODE>Keep or drop HTTP status codes.
--mt <MIME> / --ft <MIME>Keep or drop MIME substrings.
--ioc <KIND>Keep selected IOC kinds: subdomain, domain, ipv4, ipv6, email, md5, sha1, sha256, asn.
--fpCollapse URLs that differ only in query-parameter values.
--no-iocsKeep URL records without deriving structured IOCs.
--proxy <URL>Route requests through an HTTP(S) or SOCKS proxy.
--timeout <SECS>Per-request timeout, default 45.
--concurrency <N>In-flight source/target requests, default 12.
--per-host-rps <RPS>Per-host request rate, default 4; 0 disables.
--max-pages <N>Max paginated requests per source, default 50.
--max-urls <N>Max retained URLs, default 1000000.
--max-iocs <N>Max retained IOCs, default 1000000.
--retries <N>Retries on 429/5xx, default 3.
--api-key <PROVIDER=KEY>Provide a key for one run. Prefer env vars or keyring for normal use.

Key resolution order is flags, provider environment variables, OS keyring, then the permissions-checked TOML config. Use disrobe prowl keyring set|get|rm|list <provider> to manage stored keys.

Indicator bundles

disrobe frisk recovered/ --format json > frisk.json
disrobe ioc sample.bin --format json > ioc.json
disrobe prowl --recon-input frisk.json --format json > prowl.json
disrobe indicators frisk.json ioc.json prowl.json --format json > indicators.json
disrobe indicators frisk.json prowl.json --targets-only > targets.txt

disrobe indicators ingests disrobe.recon/v0, disrobe.ioc/v0, and disrobe.prowl/v0, deduplicates indicators by class and value, preserves each value's source provenance, and emits disrobe.indicators/v0. --targets-only prints network indicators ready for prowl --targets-file.

String harvest from a write log

disrobe_core::recon::string_emu holds the wide-run reader behind the endpoint and .onion pass in disrobe frisk. It also holds two APIs that no disrobe command reaches: a string harvest over a write log, and a call-site argument reader. Both read state the caller already holds, a list of address and byte pairs in the first case and captured registers and stack bytes in the second. disrobe does not run the sample to produce either input.

The caller supplies a sandbox window of allowed address ranges alongside the write log. A write to an address outside the window is counted in writes_outside_sandbox and dropped, so it never reaches the harvest and never allocates host memory. A window holds at most 64 regions. A region whose base plus length passes the end of the 64-bit address space is refused with DR-RECON-EMU-0001, and a refused region is not recorded. A 65th region is refused with DR-RECON-EMU-0002.

Bytes recovered inside the window are read as UTF-8, UTF-16LE, UTF-16BE, UTF-32LE, and UTF-32BE. A narrow run that holds only ASCII carries the ascii label. When two readings overlap, the longer run wins. A region that the text readings do not cover is also kept as raw bytes with no text. Bytes that do not decode keep their exact values, and no reading substitutes a replacement character.

Harvest properties:

  • A string overwritten in place is still harvested, together with the value that replaced it.
  • Runs never join across the end of the address space. A write near the top of memory and a write at address zero stay separate.
  • Results are deduplicated by address and bytes together. The same value at two addresses is two results.
  • Two harvests of the same write log return the same strings.
  • A truncated write log still yields the strings it contains.
  • A code unit that is not a Unicode scalar value ends the run. The scan continues past it, so a run behind an unpaired surrogate is still recovered.

The harvest stops on the caller's wall-clock deadline or when it has recorded the caller's byte budget, and bound names which of the two stopped it. It reads at most 4194304 log entries. A caller that takes the default limits gets a 750 millisecond deadline and a 262144-byte budget. The harvest itself reports at most 4096 strings, needs four characters to start a run, and stops a single run at 65536 bytes.

Call-site argument slots

argument_slot maps an argument index to a register or a stack offset. extract_arguments reads the values from a CallSiteState that carries the captured registers and the captured stack image.

ConventionRegister argumentsFirst stack offsetStack step
sysv64rdi, rsi, rdx, rcx, r8, r988
win64rcx, rdx, r8, r90x288
aapcs64x0 to x708
cdecl32none44
stdcall32none44
fastcall32ecx, edx44
thiscall32ecx44

Stack words are read little endian. callee_cleans_stack reports true for stdcall32, fastcall32, and thiscall32, and false for cdecl32.

Extraction refuses rather than guesses:

  • An argument index of 64 or higher is refused with DR-RECON-EMU-0003.
  • A register the call site did not capture is refused with DR-RECON-EMU-0004. It is never read as zero.
  • A stack offset past the captured stack image is refused with DR-RECON-EMU-0005. The error names the convention, the argument index, the offset, and the number of bytes captured.

Python

disrobe disassembles and decompiles Python bytecode across CPython 1.0-3.15 and the alternative runtimes, peels source-level obfuscators, and unwraps freezers and protectors back to .pyc.

Python is disrobe's most contested and most developed ecosystem. It ships an in-house Rust decompiler as the product, never a wrapper around pycdc, pylingual, decompyle3, or uncompyle6. Those tools are benchmark competitors, not selectable fallbacks in the shipped Python decompile command.

At a glance

LayerCoverage
Bytecode disassemblyCPython 1.0-3.15, PyPy, MicroPython .mpy v0-v6, Jython, IronPython, Brython
DecompilationIn-house engine across CPython 1.0-3.15 with per-version opcode dispatch; 95.09% per-code-object recompile-equivalence on the full CPython 3.14 stdlib (17378 of 18276), 96.53% on the pinned 200-module corpus (6068 of 6286, above a 96.51% floor a committed CI gate enforces on tag and scheduled runs), and the legacy 1.0-3.7 band asserts a floor, enforced on the same runs, of 150 of 191 proven-correct (166 of 191 measured locally with the period interpreter zoo: 67 by recompile-equivalence, 99 by structural token-match)
Modern constructsmatch, walrus, f-strings and PEP 750 t-strings, exception groups, PEP 695/696/709
Control flowtry/except/else and try/finally structured from the exception-table forest, with-statement folding, multi-exit while True and while COND loops, conditional (ternary) expressions, and chained comparisons in conditions, each recompile-checked
FreezersPyInstaller 2.x-6.20+, Nuitka, cx_Freeze, py2exe, shiv, pex, PyOxidizer (experimental, unvalidated), Briefcase, SourceDefender
ProtectorsPyArmor v6-v9-pro, and 20 catalogued source obfuscators routed to an AST-evaluator backend; per-family depth is in the catalog

Commands

disrobe py decompile module.pyc --out recovered/
disrobe py decompile module.pyc --out recovered/ --backend native    # accepted for explicitness; native is the only backend
disrobe py decompile module.pyc --out recovered/ --emit source,disasm,ast
disrobe py disasm module.pyc --out trace.txt
disrobe py deob obfuscated.py --out clean.py
disrobe py deob obfuscated.py --out clean.py --cleanup

decompile runs the in-tree engine: a frame-tree pre-pass, per-version opcode dispatch, then round-trip verification. --backend native is accepted for explicitness; no external Python decompiler backend is exposed by this command.

disasm writes a faithful per-instruction trace across every supported interpreter dialect. This is the Disasm rung: lossless, offset-preserving, no structural reconstruction.

deob peels source-level obfuscator wrappers (Kramer/Specter, Berserker, Jawbreaker, BlankOBF, PlusOBF, Wodx, pyobfuscate.com, PyObfuscator (mauricelambert), python-obfuscator (PyPI), ObfuXtreme, Manglify, Oxyry, pyminifier, online obfuscator family, Xindex, pyobfus, Pypacker, Patchwork) with an AST-evaluator backend. --cleanup runs a ruff-AST constant-fold and dead-branch-elimination pass afterward.

Freezers and packagers

disrobe pyinstaller extract onefile.exe --out out/       # PyInstaller 2.x .. 6.20+, AES-CTR/CFB decrypt
disrobe pyinstaller detect onefile.exe                   # cookie, Python version, TOC offsets, no extract
disrobe pyfreeze extract app.exe --out out/              # cx_Freeze / py2exe / shiv / pex / PyOxidizer (experimental, unvalidated) / Briefcase
disrobe nuitka detect app.exe                            # flavor + Python version
disrobe nuitka extract app.exe --out out/                # --onefile payload (zstd)
disrobe nuitka symbols app.exe                           # impl_* + module-init scan on --standalone builds
disrobe py sourcedefender app.pye --out app.msgpack      # SourceDefender .pye decrypt

PyArmor

disrobe pyarmor unpack protected.py --out out/
disrobe pyarmor unpack protected.py --out out/ --allow-bcc
disrobe pyarmor unpack protected.py --out out/ --allow-dynamic --dynamic-timeout 60

unpack extracts a decrypted payload and can reconstruct a .pyc; reconstructed output is not claimed byte-identical to an original .pyc. The published 72/72 result is narrower: it covers manifest-named v8/v9 default-trial wrappers and requires one complete header-anchored root CodeObject, not source recovery, emitted .pyc identity, semantic or execution equivalence, or external agreement. v6/v7 can optionally use a dynamic-hook fallback that runs the obfuscated wrapper in a watched subprocess to capture marshal streams; this is opt-in and unsafe on untrusted input.

The --allow-dynamic path executes the sample. Only enable it on trusted samples or inside an isolated sandbox. See Forensics and malware-safety posture.

Other useful flags: --mode auto|standard|super, --target 3.11 (rewrite emitted .pyc magic), --allow-bcc, --strict, and --all-emits.

BCC input is refused with DR-PYARM-0050 unless --allow-bcc is set. With that opt-in, the pass lifts extracted native blobs statically in tree; it does not execute them or invoke Ghidra. Windows x86-64 uses the Microsoft x64 ABI, Linux x86-64 uses the System V ABI, and Darwin ARM64 uses AAPCS64. A function that depends on the PyArmor or CPython runtime dispatch remains an unmodeled record with native disassembly and a typed reason.

The dedicated command writes bcc/bcc-recovery.json, bcc/bcc-pseudo-c.c, and bcc/bcc-recovered.py beneath --out. Path-aware PyArmor extraction through disrobe auto writes the same three byte-identical artifacts. The canonical JSON schema is disrobe.pyarmor.bcc.recovery/v1; it embeds disrobe.pyarmor.bcc.function_map/1 and represents modeled, unmodeled, and refused blob outcomes. The recovered Python file is a deterministic source skeleton derived from the same publication, not a claim of source identity or execution equivalence.

--strict returns DR-PYARM-0052 when unpacking produces no .pyc, records a fallback reason, or records a marshal decode error. It does not add a separate failure condition for incomplete BCC lifting.

End to end

A real-world Python sample is often frozen, then protected, then compiled. disrobe auto chains the whole stack:

disrobe auto suspect.exe --out recovered/    # PyInstaller -> PyArmor -> .pyc decompile

Coverage and fidelity

How the in-house engine works

  1. Frame-tree pre-pass. Before walking instructions, the engine reconstructs the nested source-construct tree from the 3.11+ exception table. This eliminates the single-pass stack-walker desync that causes other decompilers to mis-nest try/except and with-blocks.
  2. Provably-inert normalizations. Twelve normalizations (padding, super-instruction fusion, constant-pool ordering, and more) run before the round-trip check, each gated by an adversarial test proving it masks no real bug.
  3. Round-trip metric. Every emitted file is recompiled on the matching interpreter and compared opcode-for-opcode against the original. PERFECT is byte-identical; SEMANTIC is the same program with a different layout; CODE_DIFF flags a real bug that is fixed before ship. The normalizer preserves jump-condition polarity rather than collapsing all jumps, so an inverted condition reads as a CODE_DIFF instead of passing silently.

Measured equivalence

The per-code-object figure is measured against an independent oracle, not the tool's own output: each recovered module is recompiled on CPython 3.14.5 and its code objects are diffed against the originals. The full stdlib measurement is 95.09% (17378 of 18276 code objects across 574 modules), and the gate that walks that whole population, full_stdlib_recompile_gate.rs, is marked #[ignore]: no workflow runs it, so this figure comes from a local run and CI re-derives only a 115-module slice of it, which carries its own floors. On the pinned 200-module corpus (6286 code objects) the rate is 96.53% (6068 of 6286), above the 96.51% floor a committed CI gate enforces on tag and scheduled runs (arbitrary_recompile_gate.rs). uncompyle6 stops near 3.8 and decompyle3 near 3.9; the ML-based decompilers self-flag benchmark contamination, and there is no model here to contaminate.

Per-interpreter bands

Each band compiles the same pinned module list on its own interpreter, then recompiles the recovered source on that same interpreter. A pinned module an interpreter does not ship is not measured, so the denominators differ and the rates do not rank the bands against each other. Every rate below is cut from the fraction beside it.

BandInterpreterRecoveredRateModulesEnforced on
3.10CPython 3.10.205225 / 5458 code objects95.73%161push, tag, schedule
3.11CPython 3.11.155433 / 5638 code objects96.36%172tag, schedule
3.12CPython 3.12.135402 / 5659 code objects95.45%177tag, schedule
3.13CPython 3.13.145716 / 5966 code objects95.80%190tag, schedule
3.14CPython 3.14.56068 / 6286 code objects96.53%200no band gate, mirrored
3.15CPython 3.15.0b46217 / 6480 code objects95.94%199tag, schedule
1.0 to 3.7period interpreter zoo150 / 191 fixturesfloor, not a measured ratenot applicabletag, schedule

Every figure in the table renders from xtask/data/recovery.json, so moving a bar without regenerating the page fails cargo run -p xtask -- regen --check.

The last column names the CI triggers that run each row. A push to main runs the 3.10 band, the smallest population, which keeps the push route inside its time budget. A tag build and the weekly scheduled build run every row that has a band gate. The 3.10 and 3.15 jobs mark their interpreter mandatory, so a runner that cannot provide it fails the job instead of reporting a pass over nothing.

Two rows read differently from the rest. No workflow measures a 3.14 band, and xtask/src/facts.rs records that bar as unpinned. The row re-plots the 200-module pinned corpus measurement, regen --check holds the two bars equal on every run, and arbitrary_recompile_gate.rs measures the bar it mirrors on tag and scheduled runs. The legacy row counts fixtures rather than code objects, and its fraction is the floor legacy_recompile.rs asserts rather than a measured rate, so it carries no rate.

Cython compiled extensions

A Cython module compiles to a native .pyd / .so, but the module still exposes the surface CPython needs to import it. The disrobe-binfmt Cython reader (disrobe_binfmt::containers::cython) recovers that surface from the compiled ELF, PE, or Mach-O: function names, qualified names, docstrings, calling-convention flags, per-class method groupings, and the original .pyx / .pxd source filenames. It walks the PyMethodDef and PyTypeObject tables through the module's symbols when they survive, and falls back to a bounded structural scan of the readable data sections for PyMethodDef-shaped records when the binary is stripped, resolving data pointers through both static section relocations and ELF dynamic relocations.

Recovery is graded against real compiled Cython fixtures (unstripped, stripped, and separately linked) with a known ground-truth .pyx: the expected functions recover with their exact docstrings and signatures, and the report records whether each name came from a symbol or from the structural fallback (real_cython.rs).

Limits

  • A Cython module's Python source is gone once compiled. Only the import surface described above is recoverable, not the .pyx bodies.
  • The legacy 1.0-3.7 band asserts a lower floor on tag and scheduled runs than the count measured locally, because the period interpreter zoo the local run uses is not present in CI. Of that local count, 67 are proven by recompile-equivalence and 99 by structural token-match.
  • PyArmor v6/v7 may need the opt-in dynamic-hook fallback, which executes the sample. The manifest-named v8/v9 default-trial result is a pure-static structural decoding check only; it does not establish recovery for other variants.

JavaScript / TypeScript

disrobe deobfuscates obfuscated JS/TS, splits bundled output back into per-module sources, and inspects packaged JS runtimes, all behind a deterministic codegen.

At a glance

LayerCoverage
Family detectorobfuscator.io, Jscrambler, jsobfu, plus bundler and minified-only classification, each with confidence and markers
obfuscator.io (--full)string-array decode, control-flow unflattening, opaque-predicate folding, packing expansion, dead-code and debug-protection strip, iterated to a fixpoint
Reverser libraryJS-Confuser (string encoding/compression, dispatcher, flatten, opaque predicates, RGF, shuffle, variable masking, locks and integrity) and Jscrambler template reversals; Arxan-JS, JSDefender, and PACE protector detectors
Esoteric encodersjsfuck, JJEncode, AAEncode, JSFiretruck, Dean Edwards Packer, atob/eval indirection
Renaming--rename (hex idents to var_N) and --rename-scope-aware (oxc_semantic, conflict-checked)
BundlersWebpack 4/5, Vite, Rollup, Rolldown, esbuild, Turbopack, Bun, Browserify, Parcel, SystemJS, AMD
Packaged runtimesV8 cached-data .jsc (bytenode), Node SEA blobs, nexe, nw.js zip-suffix, Electron .asar
Compiled webview hostsElectron ASAR plus embedded Tauri and Wails frontend trees through the direct webview command

Commands

disrobe js deob bundle.min.js --out clean.js --full --rename-scope-aware
disrobe js deob legacy.js --out clean.js --legacy auto --unminify
disrobe js unbundle app.bundle.js --out src/
disrobe js unbundle app.bundle.js --out src/ --emit sourcemap
disrobe js v8 app.jsc
disrobe js v8 app.asar --json-out report.json
disrobe auto app.asar --out recovered/        # Electron and Node packaging chains run end to end
disrobe webview desktop.exe --out frontend/    # Electron, Tauri, or Wails assets

Use js v8 to inspect a packaged JavaScript runtime and webview to recover the shipped frontend tree from a desktop binary. The standard CLI exposes webview directly rather than through auto. See Webview desktop frontends.

The default deob path runs string-array recovery and writes a detection.json sidecar naming the matched family. --full runs the complete obfuscator.io reversal pipeline and records per-stage statistics in a pipeline.json (string-array call sites inlined, dispatch blocks collapsed, opaque predicates folded, packed blocks expanded). --legacy jsobfu|jscrambler-free|auto targets the older families; --unminify adds the !0/void 0/string-concat peepholes.

For Rust callers, AstRuleId::AsyncRestore is a selector-compatibility no-op: Babel-style async wrappers are preserved. Any call carrying an exact Babel async-helper specifier quarantines the entire AST and preset-env pass, without assuming the callee is CommonJS require. AstRuleId::ArgumentSpread and AstRuleId::TemplateLiteral are disabled-by-default selector-compatibility no-ops, with stable zero-valued report counters. AstRuleId::RegeneratorRestore is a disabled-by-default selector-compatibility no-op with a stable zero-valued report counter; regenerator state machines are preserved. undo_preset_env keeps helpers_removed empty and its spread, class, and async counters at zero; it currently reports only AST-proven optional-chain and nullish-coalescing restoration.

unbundle auto-detects the bundler runtime from its markers (the full table above) or forces one with --target auto|webpack|webpack4|webpack5|vite|rollup|esbuild|turbopack|bun. Modules land as separate files with chunk and module identifiers preserved, plus a manifest.json. --emit sourcemap synthesizes per-chunk v3 source maps and decodes embedded data-url maps.

Coverage and fidelity

js v8 classifies the artifact and prints real detection: bytenode header layout and Node version for .jsc, SEA flags and code length, nexe/nw.js payload geometry, or the .asar entry listing.

For .jsc, disrobe is the self-contained, static, offline option: it recovers the user-string layer plus structure and detects the serializer version across Node 18-24, with no patched V8 binary (View8), Ghidra (ghidra_nodejs), or online service (jscdecompiler.com) required.

Limits

  • .jsc internalized identifiers (most variable and property names, for example console and log) are serialized as references into V8's read-only snapshot heap, not as inline bytes in the .jsc. Resolving them needs the exact V8 binary's RO heap. disrobe reports that as a lossy-internalized-roots boundary rather than fabricating past it.
  • For V8 snapshots disrobe reports a SnapshotDeserializeWall: the format prevents full bytecode recovery, so it scrapes the string pool (tunable via --scrape-min) and states the boundary rather than fabricating past it.

Webview desktop frontends

disrobe webview statically recovers the frontend files embedded in Electron, Tauri, and Wails desktop applications. It writes HTML, JavaScript, CSS, source maps, fonts, images, WebAssembly modules, and other asset bytes under an output directory without starting the application.

disrobe webview desktop.exe --out frontend/
disrobe --json webview desktop.exe --out frontend/ > webview.json

The text result names the detected family and each recovered path, byte size, and compression method. JSON output uses the disrobe.webview.carve/v1 schema and includes the input path, family, output directory, asset count, externally unpacked paths, and per-asset records.

Supported layouts

FamilyStatic inputRecovery
ElectronA standalone ASAR or an ASAR concatenated into a larger executableParse the ASAR pickle header and file tree, recover file bytes, verify integrity metadata when present, and preserve safe relative paths.
Tauri v1 and v2A generated embedded asset map in the native imageResolve the table through PE, ELF, or Mach-O mappings and relocations, decode each entry, and recover the original asset tree.
Wails v2A Go embedded filesystem in the native imageLocate the embedded records and recover the original frontend tree.

The embedded-map decoder handles uncompressed entries plus Brotli, zstd, and gzip. Mixed encodings are decoded per entry. It bounds the number of scan candidates, table probes, paths, recursion depth, output bytes, and compression expansion before allocating or writing output.

Recovered paths pass through the same path sanitizer used by the container layer. A single leading / on a non-empty key is treated as bundle-root-relative. Traversal components, UNC paths, drive prefixes, and other escaping names fail instead of writing outside --out.

Packages and auto reachability

webview accepts one file. If the application image is still inside an archive, installer, disk image, or application bundle, extract that container first and pass the recovered executable to webview:

disrobe extract desktop-package.bin --out package/
disrobe webview package/path/to/application.exe --out frontend/

The standard CLI exposes webview recovery as a direct command. Its chain feature does not enable webview.carve, so disrobe auto does not list that pass in the standard build. Confirm the binary's auto surface with disrobe passes.

Evidence and limits

Committed Tauri v1, Tauri v2, and Wails v2 builds are compared against their complete source asset trees in crates/disrobe-pass-webview/tests/real_toolchain.rs. The gate compares both the full path set and every file's bytes, so a plausible subset cannot pass. Electron parsing has committed structural and CLI integration coverage; a conditional parity test also packs a source tree with the real @electron/asar CLI when that tool is available.

The graded host-format matrix covers PE32+, 32-bit and 64-bit ELF in both byte orders, thin Mach-O, and universal Mach-O. PE32 remains declared but unobserved; the shared 32-bit read path is graded on ELF32 in both byte orders. Real committed desktop builds cover the current Tauri and Wails layouts; constructed fixtures exercise the wider host and compression matrix.

Static recovery cannot return assets that a development build reads from disk or a server at run time because those bytes are absent from the executable. Wails v3 remains ungraded while it is prerelease. Tauri resource-section storage is also ungraded because the tested released toolchains emit record arrays instead. These cases are not included in the byte-identity claim.

WebAssembly

disrobe parses WebAssembly modules, lifts them to four target surfaces, reverses Wasm-specific obfuscators, and decodes the Component Model and GC type graphs.

At a glance

LayerCoverage
Lift targetsRust, TypeScript, WAT, or C pseudo-source, or a JSON summary
Instruction setMVP plus the SIMD, atomics, bulk-memory, table/element, reference, and tail-call proposals
Name recoveryDWARF and source-map names where debug info is present
Op-coverage gradeEvery operator in the function lowered, and the re-emitted WAT re-parsed by an independent parser
Execution gradeReturn values, trap parity, and linear memory compared against the original under wasmtime
ObfuscatorsJscrambler-WASM, Wobfuscator, and Wasmixer reversed; Tigress-via-Emscripten and wasm-name-obfuscator detected and classified only
Control-flow unflatteningDispatcher loops whose state lives in a local, a private mutable global, or a non-atomic i32 memory slot are rebuilt as structured control flow; a dispatcher outside that set is left in place
EnvelopesComponent Model, memory64, and the GC type graph parsed by dedicated scanners; threads instructions also lift to C, Rust, and TypeScript

Commands

disrobe wasm decompile module.wasm --target rust --out lifted.rs
disrobe wasm decompile module.wasm --target ts   --out lifted.ts
disrobe wasm decompile module.wasm --target wat  --out lifted.wat
disrobe wasm decompile module.wasm --target c    --out lifted.c
disrobe wasm decompile module.wasm --target json --out summary.json
disrobe wasm deob module.wasm --out clean.wat --emit-wasm clean.wasm
disrobe wasm component module.wasm        # parse the Component Model envelope -> world / adapter manifest
disrobe wasm types module.wasm            # recover the GC type graph (struct / array / ref types)

decompile lifts to Rust, TypeScript, WAT, or C pseudo-source, or a JSON summary, with DWARF / source-map name recovery where debug info is present.

deob writes the recovered module as WAT to --out. Add --emit-wasm to write the recovered binary as well. Two JSON files land beside the WAT with its extension replaced, so --out clean.wat produces clean.summary.json and clean.recovery.json. The recovery report holds the per-transformation counts, including the unflattening counts described below.

Coverage and fidelity

Per-op coverage is measured, not assumed, and it is not divided by a number disrobe produced. wasm-tools 1.250.0 disassembles each committed .wat and its per-function instruction inventory is checked in at tests/golden/external_wasm_op_inventory.json, keyed by each fixture's BLAKE3, so the denominator cannot shrink along with a decoder that stops finding instructions. Against that inventory disrobe lowers 1034 of the 1034 opcodes in the 38 parseable modules, and the two decoders agree instruction for instruction with none unseen. An opcode counts only when its function's re-emitted WAT re-assembles, so output that does not re-parse contributes nothing to the numerator while its instructions stay in the denominator. That covers all 133 of the 133 functions in those 38 parseable modules. The other 2 of the 40 corpus files are rejected by wasm-tools as well, and its error text is pinned beside them. The MVP instruction set plus the SIMD, atomics, bulk-memory, table/element, reference, and tail-call proposals are lowered.

Coverage of this kind is not correctness. The denominator is external, but the numerator is still disrobe counting the opcodes it lowered, and a lowering rule firing is not proof the lowering is right, so the figure is published in the self-reported tier. Regenerate the inventory with:

cargo test -p disrobe-pass-wasm-deob --test external_op_denominator -- --ignored regenerate_external_inventory

That regeneration demands wasm-tools 1.250.0 and fails without it. The check itself reads only the checked-in inventory, so a missing tool can never turn into a pass.

Op-coverage is not the same as execution-equivalence. Separately, all 57 of the 57 execution-eligible functions (a numeric or nullable-reference ABI the harness can isolate per-function or drive as a whole faithful module) are execution-equivalent to the original under wasmtime: the semantic_differential test compares return values, trap parity, and linear memory between the original and the recovered module, and 6 are byte-identical in memory.

wasm deob reverses three Wasm obfuscator families with byte- or IR-transforming passes: Jscrambler-WASM (strip integrity imports, fold opaque predicates), Wobfuscator (recover the eval op-table and lift each handler), and Wasmixer (unwrap the XOR decrypt stub, defragment). Tigress-via-Emscripten is detected from its Emscripten-marked exports, but wasm deob does not run its separate dispatcher-unflattening or _Z name helper.

Control-flow unflattening

wasm deob rebuilds control-flow-flattened dispatchers as structured control flow. It recognizes a function that writes a constant start state, enters a loop, selects a case with a br_table on that state, and branches back to the loop head. Each case ends by writing the next state as a constant. The rewriter reads any module, not only one that matches a detected obfuscator family, and it is separate from the Tigress-via-Emscripten helper named above.

The dispatch state can live in three places. A local holds it, read straight into the br_table or copied into a temporary at the loop head. A mutable global holds it, on the condition that the module does not export it and no other function reads or writes it. A linear-memory slot holds it, read at the loop head by a non-atomic i32.load at a fixed offset from a base local and written back by a non-atomic i32.store to the same memory and offset.

Two routes rewrite a recognized dispatcher. The first route applies when the state lives in a local and every case names one successor. It replays the case bodies in execution order, and wraps a repeating tail in a loop. The second route rebuilds the state graph as nested if/else and loop blocks. It takes each case that chooses between two successors, and it takes every dispatcher whose state lives in a global or a memory slot. Both routes remove the br_table from the rewritten function and stop the state cell from carrying dispatch state. flattened_functions_restructured counts a function either route rewrote. flattened_conditional_restructured counts the second route.

The rewrite is graded by execution. For each fixture pair, tests/cff_conditional_reloop.rs first confirms that the flattened module agrees with a separately written module that computes the same function without a dispatcher, over a fixed argument battery under wasmtime. It then requires the rewritten module to agree with that same reference on return value and trap for every argument in the battery. A mutant fixture that swaps the two successors of one conditional transition must disagree with the reference, so a wrong edge cannot pass the battery.

disrobe refuses a dispatcher whose shape it cannot resolve. A refused dispatcher stays as it is, the module still validates and behaves as before, and flattened_dispatchers_walled records it. Set DISROBE_DEBUG=wasm-deob to read the reason on stderr:

  • state cell is observable outside the dispatcher. The state global is exported or imported, another function reads or writes it, or code after the dispatch loop reads the state cell. A module that carries a function with more nested instruction sequences than the scan bound reports this same reason for every global-state dispatcher in it, because the scan then treats no global as private.
  • state transition is not a resolvable constant edge. A case does not end in a constant state write, hides that write behind a branch, or reads the state cell in its own work or condition.
  • state graph has no sound structured form. The state graph has no equivalent shape built from if/else and loop. A cycle entered at two different states, a loop with more than one exit, and a state the entry cannot reach all land here.

An ordinary compiler can emit a shape outside the supported set. rustc 1.96.1 targeting wasm32-unknown-unknown at -C opt-level=0 lowers a hand-written match over a state variable into a next-state temporary that is not a resolvable constant edge. The committed tests/fixtures/cff_rustc_temp_state.obf.wasm and its Rust source record that case. The dispatcher is refused, the output still validates, and it behaves the same as the input under wasmtime.

disrobe auto reaches the same rewriter through the wasm chain pass. That pass writes wasm.recovered.wasm and the real counts in wasm.recovery.json when a transformation changed the module. A module whose only finding is a refused dispatcher changes nothing, so its wasm.recovery.json carries zeros. Read a refusal from wasm deob or from DISROBE_DEBUG=wasm-deob, not from the chain sidecar.

Limits

  • Two corpus modules are skipped on wat-parse or signature-extraction failure, so the op-coverage figure covers the supported subset, not all of wasm.
  • Functions outside the execution-eligible set are op-coverage-only; their behavior is not compared against the original.
  • The Component Model envelope, memory64, and the GC type graph are parsed and decoded by dedicated scanners. Their per-instruction semantics are not all lifted to source. Threads are different: all 67 threads-proposal atomic operators lift to C, Rust, and TypeScript, and the runtime tests grade shared-memory contention, wait and notify, fence, and memory growth under a wall-clock deadline.
  • Tigress-via-Emscripten is detected and classified only. Its standalone dispatcher-unflattening and name helpers are not on the wasm deob run path.
  • A fifth obfuscator family, wasm-name-obfuscator, is detected and its rename strategy classified, but its high-entropy hex renames destroy the original names, so there is nothing to reverse.
  • Control-flow unflattening runs only while the module stays under the intra-function folding budget. Above that budget the recovery report sets intra_function_folding_skipped and no dispatcher is rewritten.
  • A state cell read with an atomic i32 load is not recognized, so wasm deob leaves that function as it stands.
  • flattened_dispatchers_walled counts dispatchers the rewriter recognized and then refused. A shape it never recognized is not counted, and only the three reasons above are printed.

JVM and Android

disrobe decompiles JVM classfiles and Android DEX through a unified command, adding protector analysis, ProGuard/R8 mapping reports, and chain auto-detection, with headless wrappers for FOSS decompilers selected by the format's default routing or --backend.

At a glance

SurfaceSupport
Inputs.class, .jar, .dex, .apk, .aab; the classfile itself validated in-house (format 1.0.2-25)
DecompilersIn-house classfile and Dalvik decompilers, the Dalvik one default on .dex and .apk; CFR, Vineflower, Procyon, JADX, and others via --backend
Language surfaceRecords, sealed types, pattern matching, enum constant bodies, declaration and member annotations, enhanced for, multi-catch, plus Kotlin and Scala idioms
Obfuscator handlingString recovery for supported Zelix KlassMaster, Allatori, Stringer, and DashO patterns; DexGuard and BlackObfuscator control-flow analysis; ProGuard/R8 name reports from mapping.txt
Detection and routing roster (10)ProGuard/R8, Zelix KlassMaster, Allatori, Stringer, DashO, DexGuard, BlackObfuscator, yGuard, SkidSuite2, JBCO (the last three detect-only)
RASP vendors (8)Promon SHIELD, Guardsquare DexGuard RASP and ThreatCast, Appdome, OneSpan, Arxan/Digital.ai, Zimperium zShield, Licel DexProtector
Signaturesv1 signing-material inventory; v2, v3, and v3.1 content-digest verification; v4 .idsig parsing with APK-digest matching to v2 or v3

Commands

disrobe jvm decompile App.class --emit source --out src/          # write the in-house Java source
disrobe jvm decompile app.jar --backend vineflower --out src/
disrobe jvm dex2-jar classes.dex --out classes/  # in-house .class tree + classes.jar
disrobe jvm decompile classes.dex --backend dex2-jar --out external/  # installed d2j-dex2jar
disrobe jvm decompile app.apk --backend jadx --out src/
disrobe jvm decompile classes.dex --backend jadx --out src/
disrobe jvm decompile classes.dex --format ghidra --out recovered/
disrobe jvm decompile app.jar --mapping mapping.txt --out src/   # write name-restoration.json
disrobe jvm extract app.apk --out classes/    # extract a .jar / .apk + dump classfile inventory
disrobe jvm backends                          # report available JVM/Android backends on PATH
disrobe jvm jni app.apk                       # link native methods against the apk's own .so files
disrobe jvm jni App.class --native libnative.so --json
disrobe jvm jni module.aar                    # nested classes.jar against jni/<abi>/*.so
disrobe jvm jni base.apk --native split.apk    # cross-split link against an APK Set member
disrobe apk app.apk                           # also prints the JNI link table for the embedded dex/.so pair
disrobe auto app.apk --out recovered/         # recursively process recognized payloads
disrobe auto classes.dex --format json --out recovered/

Backend routing differs by format. .dex and .apk write in-house Dalvik source by default and invoke an Android backend only when --backend jadx or --backend dex2-jar is selected. .jar writes in-house per-class source by default, and --backend auto also invokes the first available JVM backend. .class uses --backend auto for the first available JVM backend; add --emit source to write the in-house source. disrobe validates the classfile itself (format 1.0.2-25) and recovers records, sealed types, and pattern matching where the selected backend supports them, plus Kotlin and Scala idioms.

disrobe jvm dex2-jar is the in-house DEX-to-class translator. It writes a deterministic class tree and classes.jar without an external executable. disrobe jvm decompile <dex> --backend dex2-jar is different: it invokes an installed d2j-dex2jar backend and keeps that backend's contract and output separate from the in-house translator.

For a standalone DEX, jvm decompile --format ghidra|ida|json writes descriptor-keyed class, method, and field entries beside the normal output. auto accepts the same formats when the original input itself reaches a successful jvm.classify node and writes the sidecar under exports/dalvik/. It does not reuse a DEX extracted from an APK or another container. Each entry keeps the original owner, original member name, descriptor, and replacement as separate values. When the current recovery path has not changed a name, the original and replacement stay identical. The Ghidra and IDAPython scripts resolve the original logical identity in the loaded DEX database before renaming the database object; they do not assign virtual addresses to Dalvik identifiers. IDAPython class renames require IDA 9.4 or later because that release introduced the loader's persistent DEX type-rename store. Method and field renames use the older DEX record APIs.

Coverage and fidelity

Classfile

The in-house classfile decompiler is gated against real javac: on the EdgeCases corpus, the asserted floor is 131 of 131 decompiled methods (100%) recompiling error-free on JDK 25. Its emitted source carries enum types with their constant bodies, declaration and member annotations, enhanced-for loops over arrays and iterables, and multi-catch clauses, each recompile-checked on the same corpus (foreach_multicatch_recovery.rs). CI provisions a JDK so this gate runs there.

Dalvik

The Dalvik lifter's recovered bodies are graded by the real JVM bytecode verifier rather than by the lifter's own output: a committed gate assembles the recovered classes from the committed dex corpus, loads them under -Xverify:all, and asserts that the recovered classes pass the verifier; 100% of verifier-presented classes pass (118 of 118, 0 lifter verify failures; the other 37 of 155 classes are link-skipped because they reference supertypes the harness does not bundle, Kotlin's Function1 among them, which is a test-harness limit and not a lifter defect). A live-range-splitting pass recovers method bodies whose registers carry conflicting JVM types across control-flow joins; 317 re-hosted bodies verify clean under the same gate. The committed-corpus verifier floor and the EdgeCases recompile floor are asserted by committed test gates.

The in-house Dalvik decompiler, the default for disrobe jvm decompile on .dex and .apk, is graded on the same corpus against the real EdgeCases.java source rather than its own output. A value computed in one basic block and consumed in another (an array length, or a wide argument to a call such as Math.abs or charAt) is materialized into a local at its real use site instead of being dropped across the block boundary, so all eight leaf methods reconstruct their call sites with full fidelity while every method's signature, control flow, and operators recover (dalvik_decompile_oracle.rs).

The same source path reverses core-library desugaring emitted by D8 9.1.31 with desugar_jdk_libs_configuration 2.1.5. It restores marker-confirmed public API types in the j$/time, j$/util, and j$/nio namespaces, receiver-first $-EL calls, $-CC interface static calls, and exact supported Desugar* retarget helpers. The committed minimum-API-21 DEX covers time, streams, functions, Optional, concurrent, and NIO APIs. A minimum-API-34 DEX built from the same Java source provides the original call-shape reference. The test recompiles every recovered compilation unit with Java 11, then executes every recovered API probe method through an independent harness. Unknown configuration identifiers, application-owned j$/ classes, wrapper conversions, API flips, unknown helpers, and malformed receiver shapes remain unreversed instead of being renamed by prefix.

Obfuscator analysis and recovery

For JVM classfiles, disrobe peels supported protector string patterns before in-house source emission. On Dalvik, it analyzes DexGuard-style control-flow flattening and BlackObfuscator dispatchers. --mapping parses ProGuard/R8 mappings and writes recovered class and member names to name-restoration.json; it does not rewrite emitted Java source. disrobe registers detection and routing for 10 obfuscator and protector families: ProGuard/R8, Zelix KlassMaster, Allatori, Stringer, DashO, DexGuard, BlackObfuscator, yGuard, SkidSuite2, and JBCO (the last three detect-only, identified by marker strings and, for JBCO, its jsr/ret control flow).

On the Android side it also applies eight runtime application self-protection (RASP) fingerprint rules: Promon SHIELD, Guardsquare DexGuard RASP and ThreatCast, Appdome, OneSpan, Arxan/Digital.ai, Zimperium zShield, and Licel DexProtector. For APK signatures, disrobe inventories v1 signing material, verifies v2, v3, and v3.1 content digests, and parses v4 .idsig files to match their APK digest to a computed v2 or v3 content digest.

String-encryption protectors are decrypted by emulating each class's decrypt method on the encrypted constants rather than by guessing a key, so Allatori and DashO style decrypt(String) and decrypt(int, String) routines are recovered statically. The evaluator runs <clinit> for a per-class static key, constructs the receiver and runs <init> for an instance decrypt keyed on a field, synthesizes the calling frame for a caller/stack-trace-keyed key, and covers the long-accumulator arithmetic, getfield/putfield, and switch-dispatched keystreams these routines emit, all under a hard step cap. With disrobe jvm decompile --mapping mapping.txt, ProGuard and R8 names are written to name-restoration.json, with overloaded methods disambiguated by descriptor argument count; emitted Java identifiers are unchanged.

On the Android side, the BlackObfuscator analyzer recognizes the String.hashCode() keyed dispatcher, matches each block's const-string block-name to its switch case, and reports the recovered linear block order in the decompiled output. The method body remains rendered from the flattened graph; the annotation records the recovered order rather than replacing that body. Separately, obfuscator-planted out-of-range exception-table entries are dropped before classfile structuring so they cannot poison the control-flow graph, and jsr/ret subroutines are inlined into a linear stream.

DexGuard hides string constants in an encrypted static String[] decrypted at run time through java.lang.reflect.Method.invoke rather than a direct call. The key and ciphertext are present in the dex; only the dispatch is reflective. disrobe runs a constrained Dalvik register machine over the dex's own routine: it executes the class <clinit> to rebuild the encrypted table, then runs the decrypt(int) body for each index (read the table element, apply the per-char transform against the embedded key, rebuild the string) and emits the plaintext, with the Class.getDeclaredMethod + Method.invoke call sites resolved to their concrete target. disrobe jvm decompile app.dex surfaces the recovered strings and resolved sites in the manifest.

JNI linking

disrobe jvm jni and disrobe apk link each declared native method to its C implementation across the DEX/classfile-to-.so/.dll/.dylib boundary. Static binding computes both the short and the long JNI symbol from the class name, method name, and descriptor (_, $, [, and non-ASCII characters mangled per the JNI spec) and matches them against the library's exported symbols. A RegisterNatives call built at compile time is recovered directly from the library's read-only data: the tool walks candidate JNINativeMethod triples, applies ELF relocations to their pointer fields, and resolves the target function address to its symbol, including calls made indirectly through the JNIEnv function table. The output is the typed link table plus a JNIEXPORT ... JNICALL C prototype per native method, graded against javac -h and compiled against a real NDK jni.h. An APK or AAB whose zip carries both the DEX and the native library links them without the caller naming either side; --native supplies the library explicitly for a bare .class/.jar/.dex input, including a Windows .dll or a macOS .dylib for desktop JNI. An AAR unzips its nested classes.jar and scans jni/<abi>/*.so (the AAR convention, distinct from an APK's lib/<abi>/). An APK Set (.apks), or a base APK plus one or more split APKs passed via --native, merges every split's dex and native libraries into one input set, so a native method declared in the base dex can resolve against a symbol that exists only in a config split. A raw .oat file locates its single embedded dex through the OAT header's oat_dex_files_offset; a multi-dex .oat refuses rather than guesses the per-entry record stride, which is version-dependent and undocumented across ART releases. disrobe auto performs the same link and writes jni-link.json when both sides are present in one container.

A native method with no matching symbol in any library is reported unresolved rather than dropped. A symbol exported by more than one library is reported ambiguous rather than silently bound to the first. Each resolving library's ABI directory name is carried on its entry so a multi-ABI APK states which .so a symbol came from. A JNINativeMethod array the compiler placed in the library's read-only data recovers as a static triple; a table a program assembles in memory at run time leaves no trace in the file and is not statically recoverable, so that native reports as unresolved with dynamic_only counting it.

Only two things are absent from the static artifact and therefore unrecoverable: a fnPtr computed at runtime rather than stored as a relocatable pointer, and a FindClass target name built dynamically rather than passed as a string literal. The native function body at a recovered address is not this surface's job; it goes to the native pseudo-C decompiler.

Limits

  • The Dalvik body figure measured on the gitignored real FOSS apks is a self-report, not verifier-attested. Across all three apks the lifter self-reports a lowered body for 83609 / 83943 methods that declare a code item, 99.6%. That denominator excludes 5,573 abstract, native, and interface-instance declarations that cannot carry a body; the same numerator over all 89,516 declared methods is 93.4%. The figure counts the lifter returning a body rather than a throw-stub, so it grades its own output. Those apks are gitignored, so neither figure below re-derives in CI.

Per apk, self-reported bodies over methods that declare a code item: transmissionic 26,337 of 26,416, rustdesk 29,825 of 29,983, and enrecipes 27,447 of 27,544.

A separate and much smaller population is graded by the real JVM rather than self-reported. Of 83,577 non-stub candidate bodies, a deterministic 100-permille sample takes 8,414, and 2,998 of those can be re-hosted into an isolated carrier. Of the ones presented, 2985 of 2998 pass -Xverify:all. The remaining 5,416 are excluded by harness limits and are ungraded rather than passing: 1,407 constructors, 1,730 invokespecial receivers, 2,137 unresolvable framework dependencies, and 142 others.

Per apk, attested bodies over bodies presented: transmissionic 987 of 990, rustdesk 1,215 of 1,221, and enrecipes 783 of 787. Every attested and every rejected body is listed by name under crates/disrobe-pass-jvm/tests/golden/dalvik_body_attest/, so a body cannot start failing the verifier while the count stays flat.

  • Runtime-keyed string schemes (system property, environment, clock, secure random, or a live cross-class table, as Stringer sometimes uses) are flagged as walled instead of faked.
  • A DexGuard routine that derives its key from runtime-only state (a system property, the environment, the clock, or a secure random) is reported as runtime-keyed rather than guessed.
  • Commercial DexGuard is paid Guardsquare software whose protected output is unsafe to build on an analysis box, so that path is validated against a benign sample that exhibits the same reflection-string-decryption technique. The sample is a hand-written .java compiled by real javac and dexed by real d8; the plaintext it is graded against is the stdout of the same program run under a real JVM, not a list written beside the fixture.
  • yGuard, SkidSuite2, and JBCO are detect-only.

.NET / CIL

disrobe parses .NET PE and CLR metadata, decompiles CIL to C#, F#, and VB pseudo-source, registers detection and routing for 23 protector families, and probes ReadyToRun and Native AOT images.

At a glance

SurfaceSupport
DecompileIn-house CIL disassembler and CIL-to-C#/F#/VB lowering, so the structural recovery is disrobe's own even when a rendering backend is used
Rendering backendsILSpy, dnSpy, dnSpyEx, de4dot via --backend
ImagesReadyToRun (R2R) and Native AOT detection; the library AOT report recovers names and metadata attribution on committed fixtures, while CLI analysis currently reports detection only; single-file bundles extracted member by member
ConfuserEx2Constant decryption and control-flow deflattening recovered in-house on real committed output; the encrypted-resource layer is carved byte-exact but walled on the runtime key; runtime-string and anti-tamper cleanup delegates to --backend de4dot
Eazfuscator.NETString decryptor emulated over the #US table, graded on a fixture built to the published algorithm; VM tier devirtualized at all 67 instructions against an in-repo EazVM virtualizer of our own, not the shipping product
KoiVMDevirtualized on a committed sample produced by the real KoiVM tool, all six virtualized bodies lifted back to CIL
SmartAssembly, .NET ReactorEmbedded-assembly resource decompressed and encrypted-string table decrypted, graded against Roslyn-built fixtures we build to the published algorithm; no assembly produced by either product is committed
ObfuscarDedicated in-house peeler: NameMaker odometer classification plus HideStrings recovery
ILProtector, MaxToCodeInvoke-stub and zero-RVA structures enumerated on in-repo fixtures; native-keyed configurations remain report-only
Themida .NET, ArmDotDetected; no native-VM devirtualizer ships

None of the walled bodies is fabricated. Other registered families have protector-specific reports and only the static recovery their corresponding peel paths can substantiate.

Commands

disrobe dotnet decompile App.dll --backend ilspy --out src/
disrobe dotnet decompile App.exe --backend dnspy-ex --out src/
disrobe dotnet decompile App.single-file.exe --out recovered/
disrobe dotnet decompile App.dll --backend de4dot --out src/
disrobe dotnet analyze App.dll
disrobe dotnet backends                  # report available .NET backends on PATH
disrobe auto App.exe --out recovered/     # static protector peel + in-house CIL-to-C#

decompile always runs the in-house CIL renderer. Its default --backend auto policy may also invoke the first installed external backend in this order: ILSpy, dnSpyEx, dnSpy, then de4dot. --backend ilspy|dnspy|dnspy-ex|de4dot requests one explicitly, but the current selector falls back to the same first-installed order if the requested tool is absent. disrobe auto stays on the registered in-house pass and does not launch those backends. analyze reports the PE and CLR summary, protector detection, and whether ReadyToRun (R2R) or Native AOT is detected. Detailed AOT names and metadata attribution are exposed by the library AOT report, not the current CLI summary.

Coverage and fidelity

Single-file bundles

A .NET single-file deployment packs application files into a host executable. A self-contained deployment can also carry native runtime components. The bundle reader (disrobe_binfmt::containers::dotnet_bundle) finds the bundle marker inside a PE, ELF or Mach-O host, reads the manifest, and returns each embedded member under its sanitized relative path. It inflates a deflate-compressed member and returns a stored member as it lies. Members are managed assemblies, native libraries, deps.json, runtimeconfig.json and symbol files. The reader also parses deps.json into a typed manifest rather than only carving it, so the runtime assembly list and the library table are readable as data.

disrobe extract writes the members to disk, and disrobe auto routes them onward with no dedicated flag: an embedded managed assembly reaches the CIL pass on its own. disrobe dotnet decompile accepts the same bundle as a direct input. It stages all recovered output beside the destination and publishes the directory only after extraction and every managed assembly decompilation succeed. members/ holds every embedded file. assemblies/<relative-path>/ holds the normal manifest and pseudo-source for each managed assembly. bundle.manifest.json records the bundle version, bundle ID, quota accounting, and managed assembly list. The command refuses a non-empty destination and refuses more than 512 managed assemblies before invoking any rendering backend.

The format defines exactly three manifest major versions, and the reader accepts those three and refuses any other by number. Major 1 is what .NET Core 3.x wrote, major 2 is .NET 5, and major 6 is .NET 6 and later. Major 1 has no deps or runtimeconfig block and records every entry as type Unknown. Compression exists only from major 6.

Coverage is graded against bundles the real .NET tooling produced, in corpus/binfmt/dotnet-single-file. Each extracted assembly is compared byte for byte with the assembly the compiler emitted before bundling. The committed set spans all three major versions, PE, ELF and Mach-O hosts, every one of the six entry types, and a bundle that mixes compressed and stored entries. A declared member size that runs past the buffer, a path that escapes the output directory, a duplicate path and a decompression bomb are each refused.

A universal (fat) Mach-O host is not supported. Its header-offset field is relative to the slice rather than to the file, so the reader sees an implausible version and refuses the file instead of reading the wrong offset.

Native AOT images

The AOT report recovers metadata names, type and method attribution, method boundaries and pseudo-C bodies. Every Native AOT image it is graded against, in both the crate fixtures and the corpus, is x86_64. No aarch64, arm or x86 Native AOT image is committed in any container, so no grade covers those architectures and none is claimed for them. The PE, ELF and Mach-O host containers are each graded, but only at x86_64.

The parser reads the layout from the image rather than from the architecture, so it is expected to carry to other architectures. Expected is not graded, and until an image exists to grade against, that expectation is the only basis for it.

Obfuscator reversal

disrobe registers detection rules for 23 protector families. Recovery depth varies by protector and by what is statically present in the artifact. The per-family evidence below states what artifact is graded, what data is recovered, and where static recovery stops.

Detection and string decryption are separate claims, and the evidence behind the second one differs by family. String decryption evidence below states which families are graded on an assembly the protector itself produced and which are graded on a fixture we build to the published algorithm.

Reversed on a real committed sample (plaintext recovered from the artifact, plaintext-absent oracle):

  • ConfuserEx2: in-house recovery reverses the constants protection (the documented FOSS "Ki.Constants" block-XOR / LZMA-validated algorithm) on a real committed SampleConstants.confuserex2.dll, with a test whose fixture holds only ciphertext plus the real decryptor and asserts plaintext not present anywhere in it. The encrypted-resource layer is carved byte-exact but walled on the runtime key. Control-flow flattening is deflattened in-house: disrobe rebuilds the original control-flow graph from the while(true)/switch dispatcher and recovers the switch-key encoding across ConfuserEx's NormalPredicate, x86Predicate (the native decoder stub emulated through the in-house x86 interpreter), and ExpressionPredicate (the inverse expression folded symbolically) modes, graded on real ConfuserEx ctrl flow output against the known clean baseline with every benign method's control-flow graph recovered to match. For de4dot's runtime-VM string and anti-tamper handling, confirm de4dot is available with disrobe dotnet backends, then use disrobe dotnet decompile App.exe --backend de4dot. disrobe auto does not invoke that external backend.

In-assembly-decryptor recovery, graded by round-trip against the pre-encryption original. This list is grouped by how recovery works, not by who produced the sample, so a family in it also appears in String decryption evidence below: the round-trip proves the decoder inverts the encryption, and the table below states whether the encrypted input came from the protector's own tool or from a fixture built to its published algorithm.

  • Eazfuscator.NET: locates the static char[]/byte[] string-decryptor method and emulates its CIL over the encrypted #US literal table to recover the pre-VM plaintext strings, graded on a fixture built to the published algorithm rather than on Eazfuscator.NET output, which is why the family also sits in the modelled row below. The VM tier is devirtualized against an in-repo EazVM virtualizer of our own: the committed assembly is encoded by that virtualizer, not the shipping Eazfuscator.NET product. disrobe reads the embedded resource, recovers the per-build opcode map from the in-assembly dispatch table by fingerprinting each handler, decrypts the position-keyed instruction stream, and lifts every virtualized method body back to CIL. It then applies width-checked MBA simplification to supported straight-line int32 expressions before grading the result against the clean DLL. The ordered instruction comparison resolves branch targets to instruction indexes, and 67 of 67 instructions match in sequence across six bodies (100%). A second gate rebuilds a runnable assembly from the recovered CIL and asserts its stdout is byte-identical to the clean baseline; CI provisions .NET, while local runs require dotnet on PATH. For the committed seeded build, the randomized opcode map is recovered from the assembly rather than read from its sidecar.
  • KoiVM (ConfuserEx VM): located by #Koi stream and VMDispatcher markers, then devirtualized on a committed sample produced by the real KoiVM tool (the TheProxyRE KoiVM fork driven through its public Virtualizer API over a benign self-authored exe, not a self-made encoder). disrobe reads the #Koi stream, fingerprints the VM-dispatch handler table, decodes the per-method instruction stream, and lifts all six virtualized bodies back to CIL through the same in-house CIL stack-machine used for Eazfuscator. The recovered bodies are graded against the independently compiled KoiSample.clean.exe: Add and Square recover fully and aggregate structural recovery stays at or above a 75% CI floor against hand-derived ground-truth ops (a non-circular oracle), and the unobfuscated baseline correctly yields no KoiVM summary.
  • SmartAssembly (embedded assemblies): the mode-1 chunked raw-DEFLATE resource that carries a merged or embedded dependent assembly is decompressed back to the original assembly bytes, graded byte-for-byte against a committed Roslyn-built fixture. The sample recovers 1 / 1 embedded resource. The payload inside it is a real assembly; the mode-1 framing around it is built to the published algorithm, not taken from a SmartAssembly build. Non-mode-1 carriers are marked Unknown and malformed mode-1 is Rejected, never fabricated. String encryption is a separate axis (below).
  • .NET Reactor (encrypted-resource strings): the AES key and IV are read from the reachable encrypted-string resource and the string table is decrypted back to the original literals, graded against the runtime-validated originals of committed Roslyn-built fixtures carrying the .NET Reactor v4 static-string resource shape (astral-plane, embedded-nul, empty, and CJK strings all round-trip). No assembly produced by .NET Reactor is committed. An ambiguous or disconnected decoy key/IV tuple is Rejected as report-only, never guessed.
  • ILProtector / MaxToCode: classified on in-repo structural fixtures by Invoke-stub and zero-RVA method enumeration, runtime-resource and .mtc/.text1 section location, and container-framing parse. For native-keyed configurations, the managed assembly does not carry the per-method key used by the runtime loader, so the encrypted bodies remain report-only.
  • Obfuscar: dedicated in-house peeler (NameMaker odometer classification plus HideStrings recovery: the hidden ldstr literals are read back to their original bytes from the in-assembly FieldRVA carrier through the generated accessor, 15 / 15 on the gauntlet sample).

Detected and classified without a family-specific string fidelity claim: Babel, Dotfuscator (Pro), Goliath, DeepSea, Agile.NET. Babel reports detection and an explicit string-recovery wall because no authentic protected/plain sample or authenticated decoder chain is committed. The other peel paths report matched watermarks, identifier characteristics, and relevant encrypted-resource details, and may run the generic static decoder when it can prove a pure transform.

String decryption evidence

Which families disrobe decrypts strings for, and what each claim is graded against:

EvidenceFamilies
Graded on an assembly the protector's own tool producedConfuserEx2, Obfuscar, BitMono
Decoder implements the published algorithm, graded against a fixture built to it; no assembly from the product is committedSmartAssembly, Spices.Net, Skater, .NET Reactor, Eazfuscator.NET, CryptoObfuscator
Key is native-loader-resident, so recovery stops at detectionThemida (.NET wrapper), ILProtector, MaxToCode

The second row is the one to read before pointing disrobe at a real protected assembly from the products listed there. Each decoder there is written from the protector's published algorithm and is graded by encrypting a known plaintext with that algorithm and asserting the decoder returns it. That is evidence the decoder implements the algorithm we believe the protector uses. It is not evidence that the shipping product uses it, and it does not cover per-build variation those products may carry. They are all commercial, and none has an artifact that is both benign and licensed for redistribution here, so no sample is committed to close the gap.

crates/disrobe-pass-dotnet/src/protectors.rs carries this split as Protector::string_evidence, and the tables above are regenerated from it. cargo run -p xtask -- regen --check fails if a family claims a committed sample the tree does not carry, or claims one whose MANIFEST.toml does not record the tool that produced it, or sits in the modelled row without being published there.

Limits

Genuine walls (the key or the original code is not in the static artifact):

  • Themida / .NET wrapper: native VM bodies are outside the current recovery scope; disrobe does not ship a native-VM devirtualizer.
  • ArmDot: detected and reported, but no static devirtualizer ships.
  • ILProtector / MaxToCode native-keyed configurations: when the per-method key is computed inside the native stub, the original CIL is not statically present.
  • Obfuscar renames: the rename itself embeds no in-PE name map, so original identifier names stay walled behind the out-of-band Mapping.txt.

Commercial protector findings are reported with the recovery wall when the static artifact lacks the needed key or handler stream.

Native (PE / ELF / Mach-O / NE)

disrobe ships its own in-tree x86-64 and AArch64 decompiler, and around it the layer that recovers a binary's symbols, disassembles it, identifies what built and protected it, and patches, fingerprints, or diffs it. It also hands Ghidra and IDA clean, unpacked, symbol-rich input, and can drive Ghidra headlessly in one command.

Two adjacent surfaces have their own pages: native decompile for the in-tree decompiler and the Ghidra backend, and native unpacking for packers, protectors, and bytecode-VM devirtualization.

At a glance

SurfaceSupport
ContainersPE, ELF, Mach-O, Windows 3.x NE, OS/2 1.x NE, plus a flat code blob with --raw
Architecturesx86 / ARM / RISC-V / MIPS / PowerPC / SPARC / eBPF / AVR
Debug formatsDWARF, PDB, STABS
DemanglingRust and C++ symbols, with the C++ class hierarchy recovered from RTTI and vtable layout
Function discoveryCall-target and prologue scanning on stripped input, basic-block partition, whole-program call graph
IdentificationCompilers, packers, protectors, installers, linkers, and code-signing, each finding routed to the pass that handles it
AuthenticodeDigest recomputation and PKCS#7 chain walk to a single verdict from nine outcomes
Queryable IRfunctions, calls-to, xrefs-to, string-decoders, complexity-over, capability
CapabilitiesMITRE ATT&CK technique and MBC ID mapping, with matching instruction offsets as per-rule evidence
String recoveryStatic scan plus decoder execution through the in-house x86 emulator
EditingByte patch and nop-range at a virtual address, wildcarded signature generation, cross-build function diff
Object modelsDelphi and C++Builder classes with parents, published properties, fields, methods, dynamic method handlers and interfaces, enumeration members, string literals, DFM form resources, and compiler release identification
ForensicsEntropy map (text / JSON / SVG), crypto and FLIRT signatures, import/export graph, CycloneDX SBOM

Commands

disrobe native symbols app.exe --out symbols.json            # symbols, sections, imports, debug info, RTTI

disrobe native disasm app.exe --out app.asm                   # recovered per-function listing
disrobe native disasm app.exe --emit cfg-dot --out cfg.dot    # per-function basic-block CFG
disrobe native disasm app.exe --emit json --out disasm.json   # structured instruction stream
disrobe native disasm --raw shellcode.bin --base 0x1000 --bits 64 --syntax intel   # linear sweep
disrobe native callgraph app.exe --out callgraph.dot          # whole-program call graph

disrobe native patch app.exe --at 0x1400 --bytes 0x90,0x90 --out patched.exe   # rewrite + revalidate
disrobe native patch app.exe --nop-range 0x1400:0x1410 --out patched.exe        # nop a span
disrobe native sigmaker app.exe --at 0x1400                                     # wildcarded signature
disrobe native diff old.exe new.exe                                             # match functions across builds

disrobe native identify app.exe --out identity.json           # compiler, packer, protector, signature verdict

disrobe query app.exe functions                         # discovered functions, complexity, exports
disrobe query app.exe calls-to malloc                   # call sites to a target
disrobe query app.exe xrefs-to sekret                   # references to a symbol
disrobe query app.exe string-decoders                   # decoder-shaped functions (loops + byte arith)
disrobe query app.exe complexity-over 20                # functions over a cyclomatic threshold
disrobe query app.exe capability network                # instructions tied to a capability
disrobe capabilities app.exe                            # MITRE ATT&CK + MBC behavior report

disrobe strings app.exe                                 # static strings + decoder-execution recovery

disrobe native entropy app.exe                           # ASCII heat-strip + byte histogram + packed-region runs
disrobe native entropy app.exe --format svg --svg map.svg # dark-theme SVG entropy map with section overlays
disrobe native entropy app.exe --format json --out e.json # the disrobe.native.entropy/v0 document
disrobe native signatures app.exe --out sigs.json        # AES T-tables, SHA/MD5 IV+K, ChaCha20 sigma
disrobe native signatures app.exe --flirt db.sig         # match against an IDA FLIRT database
disrobe native fingerprint app.exe                       # crypto + FLIRT + string-xref sidecar
disrobe native graph app.exe --out imports.dot           # import/export table as Graphviz DOT
disrobe native sbom app.exe --out app.cyclonedx.json     # CycloneDX 1.5 SBOM from cargo-auditable metadata

Coverage and fidelity

Symbol recovery and dumping

native symbols dumps symbols, sections, segments, imports, and debug info from PE, ELF, Mach-O, and 16-bit NE files. It demangles and restores Rust and C++ symbols across x86 / ARM / RISC-V / MIPS / PowerPC / SPARC / eBPF / AVR, reading DWARF, PDB, and STABS debug formats. For C++ binaries it recovers the class hierarchy from the in-memory RTTI and vtable layout: ABI, each class's inheritance kind, direct base classes (virtual flagged), virtual-method slot counts, and detected STL templates.

New Executable structure

The NE parser accepts the little-endian segmented container used by Windows 3.x and OS/2 1.x. It validates the 64-byte header, segment and resource ranges, entry bundles, resident and nonresident names, module references, imported names, relocation records, and non-additive relocation chains. It lowers segment-relative entry addresses, exports, and imported names into the same native model used by behavior, IOC, HTML-report, and native-symbol surfaces. The identify surface classifies both variants. The registered native.ne-structure pass gives detect and auto the validated structure. It does not claim 16-bit disassembly or decompilation.

NE names use an OEM code page rather than UTF-8. Printable ASCII bytes other than backslash render directly. Backslash and every other byte render as \xNN, which keeps the representation unambiguous without guessing a host code page or dropping malformed text.

Disassembly, call graph, and CFG

An in-tree iced-x86 disassembler discovers functions without symbols (call-target and prologue scanning), partitions each into basic blocks, builds the whole-program call graph, and renders the per-function listing or, with --emit cfg-dot, the basic-block CFG. --raw disassembles a flat code blob with no container, and --syntax intel|at&t|nasm|masm selects the dialect for raw output. Each decoded instruction carries its register, memory, and rflags read/write effects, and the native layer can re-encode and relocate instruction blocks (the engine behind native patch).

Patching, signatures, and binary diff

native patch rewrites bytes at a virtual address (or nops a VA span), maps the VA to a file offset, applies the edit, and revalidates the image. native sigmaker generates a wildcarded byte signature from the function at an address (immediates and displacements masked via the instruction decoder), then uniqueness-tests it across the image. native diff matches functions across two binaries by content, relocation-invariant, and control-flow-graph fingerprints and reports the added, removed, and changed functions. All three work on stripped input through the in-tree function discovery.

Queryable IR and capabilities

disrobe query runs a queryable-IR layer over the disassembled code (functions, calls-to, xrefs-to, string-decoders, complexity-over, capability), symbol-independent and driven by the same function discovery. disrobe capabilities runs a rule engine over that IR and reports matched behaviors mapped to MITRE ATT&CK techniques and Malware Behavior Catalog (MBC) IDs, with the matching instruction offsets as per-rule evidence. Both accept a stripped binary directly or a .dr envelope.

Emulation-driven string recovery

Beyond a static scan, disrobe strings locates decoder-shaped functions and drives each through the in-house x86 emulator, recovering the plaintext that only exists after the decoder runs. Emulation-recovered strings are deduplicated against the static set and reported with the decoder and buffer addresses, so a single-byte or multi-byte XOR/sub stack-string scheme yields the decoded text without executing the sample natively.

Identifying the compiler, packer, and protector

An in-house multi-signal signature engine fingerprints what built and protected a binary through self-consistency-based identification, then routes each finding to the disrobe pass that handles it. It detects compilers and toolchains (Go, Rust, MSVC, GCC, Clang, MinGW, Delphi, Nim, Zig, Crystal, Swift, GHC, .NET, Nuitka), packers (UPX, ASPack, PECompact, FSG, MEW, MPRESS, Petite, NSPack, kkrunchy), protectors (Themida, VMProtect, Enigma, Obsidium, Armadillo, ConfuserEx, .NET Reactor, Eazfuscator), installers (NSIS, Inno Setup, InstallShield, AutoIt, PyInstaller, Electron, Bun), linkers (MSVC link via the Rich header, GNU ld, LLD), and code-signing. Every hit carries a support route: a Go binary points at disrobe go recover, a packed PE at disrobe native unpack, a .NET assembly at disrobe dotnet decompile, an installer at disrobe auto.

Unpacking and byte-level recovery

disrobe native unpack is graded per section against a committed original rather than by whether the output merely parses. Three families ship one packed-and-original pair each, so their figures reproduce from a clean checkout:

Family.text.rdata.data.rsrcContent total
FSG (Hash.exe)18188 / 181882311 / 398833212 / 332121552 / 467255263 / 60060
NSPack (hash.exe)18188 / 181883988 / 398833212 / 332122333 / 467257721 / 60060
Petite (hello.exe)70796 / 7079615734 / 18396456 / 456none86986 / 89648

.text and .data are byte-identical for all three, and NSPack's .rdata is byte-identical as well, so the decompressors themselves are exact and the residual is placement and fixups rather than lost payload. The content denominator is the original's own section span and is asserted by equality, so a recovery that emits fewer bytes scores worse instead of shrinking what it is measured against. The byte-identical sections are held as a membership list rather than a count, so a section that stops recovering exactly drops out of the list instead of being masked by another section improving. Two spans are reported and they are not interchangeable: the per-section total above counts .rsrc, while an older whole-image gate measures NSPack over .text, .rdata and .data only and reads 99.36% for the same fixture. Relocations are classified as loader-rebuilt and scored separately, because the OS resolves them at load time.

The residuals have specific causes worth knowing when reading a recovered image. FSG's .rdata gap is the import descriptor region: the stub's block-destination table names one aPLib stream per original section, all four are decoded, and the descriptor block is decoded then deliberately withheld from the recovered image because the stub writes it over original .rdata content at run time. NSPack's .rdata reaches byte identity instead, because its import lookup and address tables are rebuilt from the module record the stub carries rather than left for the loader to resolve. The .rsrc gap is shared across families, at 1552 / 4672 for FSG and 2333 / 4672 for NSPack, and it narrowed once the original resource-directory tree is placed at its own RVA rather than wherever the decompressed image left it; what remains is the part each stub reconstructs at run time. Larger uncommitted vendor samples score lower on the whole-image measure for the same two reasons, and no figure is published for them because those samples are not committed and nothing pins them. kkrunchy and kkrunchy classic recover their payload byte-exactly against committed fixtures.

An absent committed fixture is never a silent pass. Setting DISROBE_REQUIRE_PACKER_FIXTURES=1, which CI does, turns a missing committed fixture into a failure, a skip always prints the fixture path and states that nothing was graded, and a fixture that is present but unreadable is a hard failure in every mode, because that is how a quarantined or truncated sample would otherwise stop grading unnoticed (committed_packer_byte_recovery.rs, fsg_unpack.rs, nspack_byte_recovery.rs, petite_unpack.rs, kkrunchy_unpack.rs).

Authenticode verification

For a signed PE, identify verifies the Authenticode signature rather than only noting its presence. It recomputes the SHA-1/256/384/512 digest over the Authenticode hash range (the file minus the checksum field, the certificate-table directory entry, and the certificate table itself) and compares it to the digest the signature claims. It walks the PKCS#7/CMS signer chain (RSA PKCS#1 v1.5 and ECDSA P-256/P-384) up to an embedded bundle of eight trusted code-signing roots, and requires the code-signing extended key usage on the leaf certificate. It verifies any RFC 3161 timestamp, both its own signer chain and its message imprint, before trusting that timestamp to extend validity. The outcome is a single verdict, one of Valid, NoSignature, HashMismatch, Expired, SelfSigned, UntrustedChain, WrongKeyUsage, UnsupportedAlgorithm, or MalformedSignature, written into identity.json with the computed and claimed hashes, the certificate chain, and the timestamp. The verdicts are graded against real signed PE fixtures carrying injected defects: a flipped .text byte, a forged timestamp, a wrong-EKU leaf, and self-signed and expired chains, each asserted to its expected verdict. The computed digest is cross-checked against osslsigncode where it is installed, and live Microsoft-signed System32 binaries reach Valid against the embedded roots (authenticode_oracle.rs).

Delphi and C++Builder RTTI

For a Delphi or C++Builder binary, the native Delphi analyzer (disrobe_pass_native::delphi::analyze) recovers the published object model from the compiled RTTI. It locates virtual method table anchors across the three ABI eras (pre-2009 32-bit, Delphi 2009+ 32-bit, and 64-bit), accepting a class only when its table is self-consistent, and recovers each class's name, parent, instance size, published properties with the ancestor that introduced each, published methods, published fields with their declared class, dynamic method and message handlers, and implemented interface identifiers. Enumeration member names, ordinal ranges and set element types come from the referenced type records. Compiled-in string literals are recovered from the reference-count header across the pre-2009, code page and 64-bit layouts, accepted only when the declared length lands exactly on the terminator. The unit initialization table is reached by following the entry point stub, giving the unit count and each unit's initialization and finalization address, and is refused unless every address lands in an executable section. Each class is tagged as runtime library or author code from its RTTI unit name, so an analyst can read only the author's classes. The compiler release is named only when independent signals agree: the table layout era, a linked runtime package name, dotted unit scope names, and build toolchain path strings. When they disagree the report says so rather than choosing one. It also decodes the binary DFM form resources (TPF0) back to their textual object ... end representation.

Form decoding is compared byte for byte against form streams and renderings both produced by the Free Pascal RTL converters, over five committed cases covering collections, nested collections, child objects, binary data blocks, value lists, sets, control characters and 64-bit integers; the converter source and a regeneration script ship beside the fixtures. Float rendering is excluded and remains ungraded, because the reference renderer's float format differs from Delphi's. The virtual method table and published table walks are graded against the documented layout rather than against a Delphi-compiled binary, so they catch a regression but not an error shared between the reader and the specification transcription. Each table is rejected whole unless every entry validates, so a wrong layout yields nothing rather than invented names. False-positive controls confirm no class, field, dynamic method, interface, type record or string literal is produced from real kernel32.dll, ntdll.dll, user32.dll or shell32.dll.

Nim, Zig, Crystal, and D binaries

These four compilers erase the source, so recovery works from each binary's own tables rather than from anything resembling the original file. disrobe detects the toolchain, demangles its name scheme, and recovers the symbol and metadata surface the compiler left behind. Where DWARF survives, aggregate members come back with full types, including multi-dimensional array dimensions and const/volatile qualifiers, so a field reads as const u8[4] or u8[2][3] rather than an opaque blob. A stripped D PE, which carries neither DWARF nor a name table, is a wall: the format is identified and nothing further is claimed.

Entropy map and byte histogram

disrobe native entropy slides a 4 KB window across the file computing Shannon entropy (bits/byte) to locate packed, compressed, or encrypted regions, and renders the profile three ways via --format text|json|svg:

  • text (default): a Unicode heat-strip sparkline (one glyph per 4 KB block, taller = higher entropy), a 16-bucket ASCII byte-frequency bar chart, and a list of contiguous high-entropy runs (entropy >= 7.0 bits/byte) with their file offsets, the candidate packed or encrypted regions. Pass --out <path> to also drop the JSON document.
  • svg: a self-contained neutral-gray entropy map with no external dependency. Each block is a column colored on a calm-gray to amber to red ramp, with PE/ELF/Mach-O section boundaries (parsed from the file's own section table) overlaid as labeled dashed markers and a color legend. Written to ./out/<stem>.entropy.svg or the explicit --svg <path> (which implies SVG rendering). The SVG is byte-stable for a given input (no clock or RNG) and all section names are XML-escaped.
  • json: the machine-readable disrobe.native.entropy/v0 document: per-block entropy, the sparkline/heat-strip strings, the 16-bucket histogram, the detected high-entropy runs, and the section spans.

The reusable rendering logic lives in disrobe_pass_native::entropy_viz (entropy_sparkline, byte_histogram, histogram_ascii_16, high_entropy_runs, render_entropy_svg) so other tools can embed it.

Limits

  • No committed benchmark compares the in-tree decompiler with Ghidra or IDA, so this guide states no ranking against them. What it does state: disrobe hands either one clean, unpacked, symbol-rich input, and can drive Ghidra headlessly. See native decompile for the in-tree decompiler, its two grading levels, and the Ghidra backend.
  • Virtualizing protectors (Themida, VMProtect) are detect-and-carve only, never a fabricated devirtualization. See native unpacking for what survives and why.
  • Authenticode chain validation walks to an embedded bundle of eight trusted code-signing roots and covers RSA PKCS#1 v1.5 and ECDSA P-256/P-384. Anything outside that lands as UnsupportedAlgorithm or UntrustedChain rather than a pass.
  • The Delphi VMT walk accepts a class only when its VMT is self-consistent, so a partially-overwritten or hand-built table yields no class rather than a guessed one.

Native decompile (x86-64 to C or Rust; AArch64 to pseudo-C)

disrobe's own x86-64 decompiler lifts native machine code to C or idiomatic Rust, while its AArch64 decompiler emits pseudo-C. Neither requires an external tool or install step. Ghidra can run headlessly instead when you want its whole-program recovery.

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

At a glance

SurfaceSupport
Architecturesx86-64 uses the shared IR pipeline and emits C or Rust; AArch64 uses object-context and NIR recovery to emit pseudo-C
Output formatsC (default) for x86-64 and AArch64; idiomatic Rust for x86-64 only
Call resolutionWhole-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 dispatchDense switch recovered from the binary's own jump table
Type recoveryx86-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 typesx86-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 conventionx86-64 inferred per function, including thiscall and vectorcall
AArch64 scalar floating pointh0 to h31, s0 to s31, and d0 to d31; IEEE binary16, binary32, and binary64 arithmetic, conversion, comparison, rounding, and loads or stores
Vectorized loopsx86-64 SSE/AVX reduction and pointer-walk map kernels lowered back to the equivalent scalar loop
Constant divisionx86-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 devirtualizerSymbolic, on by default in a full build, --no-devirt to disable; transactional, reverts on any proof miss
GradingC output is execution-differentially recompiled with real gcc or clang; x86-64 Rust output with rustc
Sidecarsmanifest.json (schema disrobe.native.decompile/v1) for both architectures; x86-64 also emits types.json (schema disrobe.native.types/v1)
Optional backendGhidra 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) is disrobe's own x86-64 and AArch64 decompiler: no external tool, no install step. It performs whole-program call resolution over every function the module discovers, not isolated per-function guessing. 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 rather than guessed. A function with no validated outgoing calls degrades to a plain leaf recovery, so stitching only ever improves recovery, never regresses it. AArch64 uses object-context recovery first, with the NIR lift and image-backed recovery available as narrower fallbacks.

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 can only ever replace a construct with a proven-equivalent one and never invents an edge. 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 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).

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.
  • Reach for --backend ghidra on large, deeply nested binaries where Ghidra's whole-program type and structure recovery still leads: disrobe's job there is to hand it a clean, unpacked, symbol-rich input.

Native unpacking and devirtualization

disrobe detects the runtime packer on a PE / ELF / Mach-O and unpacks it, driving stub-based packers through an in-house x86 emulator, and lifts bytecode-VM protected code back to IR and pseudo-code.

For symbol recovery, disassembly, identification, and forensics see the native guide; for the in-tree decompiler see native decompile.

At a glance

TierSupport
In-house decodersUPX, MPRESS, Petite, MEW, ASPack, PECompact, FSG, NSPack, kkrunchy and kkrunchy classic
Stub emulationYoda's Crypter is driven to its original entry point through the in-house x86 stub emulator. The same emulator drives ASProtect, Morphine, nPack, NeoLite, PolyCryptor, Warzone Crypter, whose tier records the emulator as validated against spec-built stubs with real-sample recovery still unproven
Detect and carveYoda's Protector, VMProtect, Themida / WinLicense
Detect only, no static recoveryPE-Protector, PELock, Enigma Protector, Armadillo, Obsidium, WinLicense
Bytecode-VM devirtualizationInterpreter located, handler micro-ops fingerprinted behaviorally, opcode table recovered, VM CFG reconstructed, bytecode lifted to re-executable IR plus pseudo-code
Devirtualization gradeRecovered IR re-executes to the same outputs as the original across arithmetic, loop, and branch programs, lifted from machine code alone (vm_devirt_oracle.rs)
Per-fixture scoresPinned in corpus/native/packers/MANIFEST.toml

Commands

disrobe native unpack packed.exe --out unpacked.bin
disrobe native devirt protected.exe --out recovered/

native devirt writes the recovered listing, the pseudo-code, and a devirt.manifest.json (schema disrobe.native.devirt/v1) into the output directory.

Coverage and fidelity

Packers

In-house decoders cover UPX (.text and .pdata byte-identical, ~96% whole loaded image), MPRESS, Petite, MEW, ASPack, and PECompact, plus NSPack, FSG and Petite, each of which ships a committed original and packed pair so its byte-recovery figure re-derives from a clean checkout; kkrunchy and kkrunchy classic ship committed fixtures and recover their payload at a pinned 100.00% floor from a clean checkout.

On committed samples ASPack and PECompact rebuild the decompressed section image at its load RVA: the section report confirms the recovered .text byte-identical and the import table >=98% byte-identical to the original, both gated in CI, while the packed .text of near-random entropy and zero resolvable calls drops to ~6.2-6.5 with hundreds of disassembler-resolvable intra-code calls. Because the whole rebuild is a loaded-memory image rather than a disk-aligned file, the bench marks whole-output byte-identity n/a. MEW rebuilds a flat image of the committed Sysinternals samples, read as the entropy drop to ~4.2-4.9 and tens of thousands of decoded instructions.

Yoda's Crypter is recovered by driving its unpack stub through the in-house x86 stub emulator: the stream decryptor runs to the original entry point inside the emulator, then the reconstructed sections are read back and sliced byte-for-byte, so its .rsrc recovers byte-identical and its .text decrypts to full plaintext. The same emulator drives ASProtect, Morphine, nPack, NeoLite, PolyCryptor, Warzone Crypter, which sit one tier lower: the emulator is validated against spec-built stubs, and no vendor-packed sample in the corpus proves recovery on a real one.

Per-fixture recovery scores are pinned in corpus/native/packers/MANIFEST.toml.

Bytecode-VM devirtualization

disrobe native devirt targets the bytecode-VM tier rather than the compression tier. It locates the interpreter, fingerprints each handler's micro-op behaviorally by probing it through the in-tree x86 emulator (so a per-build handler permutation does not break the lift), recovers the handler-to-opcode table, reconstructs the VM CFG, and lifts the handler bytecode to a re-executable IR plus pseudo-code.

The lifter is validated end-to-end on a self-authored Tigress-shape bytecode VM: the recovered IR re-executes to the same outputs as the original across arithmetic, loop, and branch programs, lifted from machine code alone (vm_devirt_oracle.rs).

Limits

  • FSG, NSPack, and Petite each ship one committed packed-and-original pair, and the published figures for them are measured on those pairs alone. Their larger samples, and the extra MPRESS and UPX megafiles beside them, are kept out of the tree on size or license; every such path is marked local in corpus/native/packers/MANIFEST.toml with a recipe for rebuilding or refetching it, and no figure is published for any of them, because nothing in a checkout re-derives one.
  • Yoda's Protector is detect + resource-carve, its stream key being a runtime-only value absent from the file.
  • On UPX and NSPack the whole-image residual is the loader-rebuilt zone (bound import address table and base relocations): those addresses are resolved by the OS loader at run time and are not present in the packed stream, not a decoder gap.
  • The virtualizing protector tier (VMProtect, Themida, Enigma, and 15+ others) is detect-and-carve: the stub is still driven through the emulator, but the original code is decrypted only by a per-machine key assembled after the stub validates an un-instrumented host (RDTSC deltas, debugger-handler identity, BOUND/FPU exception fingerprints). That key is not present in the file, so faithful recovery is an information-theoretic wall; disrobe carves what survives in place and reports the wall rather than fabricating an unpack.
  • The commercial VM front-ends (VMProtect, Themida, Code Virtualizer, Enigma, WinLicense, PELock) mutate their handler set per build. The lifter is the generic engine and the Tigress-shape VM is its validated level, but disrobe ships no per-family devirtualizer for the commercial protectors, so those are detected and section-carved rather than lifted back to source.
  • A handler stream assembled at run time from a per-machine key, or fetched over the network, is an information-theoretic residual. Protector identification and section carve stay available for every family.

Go

disrobe recovers symbols from stripped and garbled Go binaries across PE, ELF, and Mach-O by parsing the Go runtime's own metadata tables.

At a glance

LayerCoverage
Binary formatsPE, ELF, Mach-O, on little- and big-endian targets
Architectureslittle-endian amd64/arm64 and big-endian s390x, ppc64, and mips, with the pclntab, type, and itab tables read in the image's own byte order
pclntabHeader eras go1.2, go1.16, go1.18, and go1.20, located structurally even when the magic word has been stomped
Symbol recoverypclntab function table, moduledata, typelinks/itablinks type metadata, buildversion
Obfuscationgarble report graded None / Detected / Partial / Full, with per-scheme literal-recovery statistics
Embedded dataembed.FS maps located through the compiler's own slice header, every member recovered byte for byte and checked against its stored digest, plus //go:embed directive extraction
Debug infoDWARF report when the sections survive

Commands

disrobe go recover app --out app-go.json
disrobe go info app
disrobe auto packed-app --out recovered/    # unpack the UPX layer first, then recover the Go symbols

recover writes the full analysis JSON (default ./out/<stem>-go.json); info prints the fingerprint without writing anything. Output shape (illustrative):

go recover: OK
  input:        app
  image kind:   elf
  ptr size:     8
  pclntab ver:  go1.20
  buildversion: go1.26.3
  funcs:        ...
  packages:     ...
  garble:       None
  embed.FS:     used=true directives=...
  wrote:        ./out/app-go.json

info adds the stripped-binary fingerprint: whether the symbol table was stripped, how many functions were still recovered from pclntab, and the stdlib-name ratio that feeds the garble grading.

UPX-on-Go chains automatically: disrobe auto unpacks the UPX layer first, then recovers the Go symbols underneath.

Coverage and fidelity

The garble report separates a real wall from a tooling boundary. garble -literals is handled separately from names. Simple rodata schemes are recovered by pairing adjacent key/data blobs and applying the inverse XOR/ADD/SUB or repeating-key operation. Full-key literals are recovered when their code and ciphertext are present: the thunk scanner follows bounded x86-64 init thunks or inline materializers, emulates the decrypt path, and accepts only UTF-8/readable plaintext. The tests assert the source strings are absent as cleartext before requiring byte-exact recovery, so the oracle is not circular.

An embed.FS map is located through the layout the Go compiler is required to emit rather than by matching a pattern. The compiler writes the slice header and the file records into one symbol and sets the records pointer to that symbol plus three pointer-sized words, so the header points at itself and the length equals the capacity. Discovery keys on that identity, which also fixes the record count from the header instead of reading records until one fails to parse. Ordering is not assumed: real compiler output places frontend/dist/style.css before frontend/dist/assets/, so a scanner that requires a sorted run rejects every genuine map.

Each file record carries the first sixteen bytes of a digest the compiler computed over the member contents, and the pass recomputes it. Members at or below 1024 bytes take a one-shot form and larger members take a streaming form, a boundary measured from real toolchain output rather than assumed. The toolchain generation is identified by which construction verifies the records, so no digest depends on trusting the version string an image reports, and a map whose members are all above the boundary is reported as unable to distinguish two generations rather than being assigned one. Recovery is graded against the exact files each fixture was built from, across PE, ELF, and Mach-O at both pointer widths, both byte orders, and both position-dependent and position-independent links. Go's internal linker materializes the record pointers even in a position-independent image, so the same discovery path serves both. A directory record, a zero-length member, and a member whose contents are not valid UTF-8 are all covered.

The pass is validated against a go1.26.3 fixture, and the test suite gates type-name recovery at >= 85% on that fixture; 838 of 838 type names (100%) are recovered at HEAD, a count the gate now pins by equality, since the typelinks and moduledata tables survive -s -w stripping. Big-endian recovery has its own oracle: a cross-built stripped linux/s390x binary is parsed as a big-endian ELF and its named type and itab pairs are recovered by back-searching the metadata tables, graded against the build (go_bigendian_recovery.rs).

Limits

  • The deliverable is symbols, types, and embedded data, not source bodies.
  • garble's hashed user identifiers stay walled. garble hashes them with a keyed HMAC-SHA256 over a build seed that is not in the binary, so the original names are information-theoretically gone and are reported as a name_recovery_wall rather than guessed at. Standard-library names survive in pclntab and are recovered.
  • The remaining limits on literal recovery are concrete: missing bytes, runtime-only key material, unsupported architectures, exhausted budgets, or ambiguous short plaintext.

Lua

disrobe decompiles compiled Lua chunks across every common dialect, peels the major Lua obfuscators, and devirtualizes custom-VM wrappers back to runnable Lua source.

At a glance

LayerCoverage
DialectsLua 5.1 / 5.2 / 5.3 / 5.4, LuaJIT 2.0 / 2.1, Luau, GLua
Luau opcode coverage86 of 88 opcodes in disrobe's declared table are lifted, with BREAK debugger instrumentation and NEWCLASSMEMBER decoded and reported rather than lifted; child-proto linking
Decompile outputLua source plus a manifest recording the detected dialect, fidelity grade (Lossless / Lossy / BestEffort), and any warnings
Obfuscators (14)IronBrew2, Prometheus, MoonSec V1 / V2 / V3, AztupBrew, DarkSec, Boronide, PSU, WeAreDevs, luaobfuscator.com, SLua, Hercules, Luraph
Chain catalog16 entries: the 14 obfuscator families above plus the Luau and GLua dialect detectors
Peelers (--family)prometheus, moonsec-v1, moonsec-v2, moonsec-v3, ironbrew2, wearedevs, slua; default auto detects first
VM devirtualizationIronBrew2 2.7.0 reversed on real committed output, graded by real-Lua execution differential (hello / arith / control / tables / edge in standard and MAX mode); MoonSec-shape recovery is pending a real sample
Prometheus VmifyContainer dispatch tree lifted back to structured Lua, including a closure that captures a variable; up to four stacked Vmify layers unwrapped in one call; graded by real-Lua 5.1 execution differential against the original source

Commands

disrobe lua decompile script.luac --out script.lua
disrobe lua detect script.luac
disrobe lua deobfuscate obfuscated.lua --out clean.lua
disrobe lua deobfuscate vmified.lua --family prometheus --out clean.lua
disrobe lua deobfuscate dumped.lua --family moonsec-v3 --i-have-authorization
disrobe lua opcode-map --canonical vanilla.luau --client client.luau --build-id 2026-08-24 --out 2026-08-24.json
disrobe lua decompile client.luau --opcode-map 2026-08-24.json --build-id 2026-08-24 --out recovered.lua

Build-keyed Luau opcode maps

lua opcode-map imports only observations proven by a supplied canonical/client pair. The pair must use the same supported bytecode version, have the same prototype tree and instruction counts, and retain every non-opcode and auxiliary instruction word. The map records the explicit client build identifier and bytecode version. lua decompile --opcode-map selects it only when both values match.

The importer accepts a conflict-free bijection for the opcode bytes it observes. It does not infer the remaining permutation. A missing entry causes decompilation to refuse at the instruction location; an incompatible, stale, or malformed map is not substituted with another build's map.

Opcode availability is validated against Luau's bytecode history at 367f9d83: version 3 adds FORGPREP and JUMPXEQK*, version 4 adds IDIV/IDIVK, version 5 adds SUBRK/DIVRK, version 6 adds FASTCALL3, version 9 adds userdata field access, version 10 adds NEWCLASSMEMBER, and version 11 adds CALLFB.

decompile writes the recovered source (default ./out/<stem>.lua) and a manifest.json recording the format, fidelity grade, and warnings. detect reports the dialect and header field summary (constant, proto, and code counts) without writing output. MoonSec v3 and IronBrew2 are commercial-tier wrappers; their peelers require the explicit --i-have-authorization flag.

--family pins one peeler instead of letting auto choose. Auto-detection tries Prometheus first, so a Vmify container needs no flag. Pass --family prometheus when you want a file carrying no Prometheus signature to fail rather than fall through to another family.

Output shapes below are illustrative.

lua decompile: OK
  input:        script.luac
  format:       Lua54
  fidelity:     Lossless
  warnings:     0
  wrote:        ./out/script.lua
  manifest:     ./out/script.manifest.json
lua deobfuscate: OK
  family:       Auto
  detected:     Prometheus (confidence=0.97)
  passes run:   3
    - string_decode
    - bytecode_unwrap
    - emit
  recovered:    12 string(s)
  fully peeled: true
  residual:     0
  wrote:        ./out/obfuscated.peeled.lua

The deobfuscate report lists every pass that ran, recovered string constants, a fully peeled verdict, and any residual markers.

Coverage and fidelity

IronBrew2 and MoonSec ship their payload behind a custom register-VM: a permuted opcode-handler table and an embedded constant pool that a stock decompiler cannot read. The permutation is not stored in the loader; it is computed at load time inside the obfuscated bootstrap, then used to dispatch handlers and key the constant decryptor. disrobe reconstructs it the same way the loader does.

For IronBrew2 2.7.0, the devirtualizer parses the bootstrap's dispatch chain to derive the encoded -> canonical permutation and the XOR key, decodes the constant pool, and lifts the VM bytecode back to runnable Lua. The committed corpus/lua/ironbrew2 set carries real obfuscated bootstraps for five programs in both standard and MAX mode; each must produce byte-identical output to the original under a real lua binary (tests/ironbrew2_real_oracle.rs).

MAX mode adds three layers on top of standard: a control-flow-flattened dispatch (a nested binary search over the opcode enum, which the same handler walker un-flattens), comparison-polarity number-mutation (the EQ handler tests ~= and jumps on equality, captured as the literal operator plus jump direction), and fused super-operator handlers (one VM step covering several real ops, whose hoisted scratch locals are stripped after classification).

Prometheus Vmify

The Prometheus Vmify step rewrites a chunk into a register machine held inside one container function. Every original function becomes a numbered entry point in a shared dispatch tree, and every call becomes a jump through that tree. disrobe reads the container, rebuilds a control-flow graph for each entry point, and re-emits structured Lua. The pass appears as prometheus-vmify-container-devirt in the passes run list. Vmify applied over its own output is unwrapped layer by layer, up to four layers in one call. A chunk that needed more than one layer also carries a prometheus-vmify-nested-devirt-<n>-layers entry naming the layer count.

Vmify does not keep a captured variable as a Lua upvalue. It moves the variable into a store table shared by the whole chunk, hands each closure a list of slot indices into that store, and uses a reference count to decide when a slot is cleared. disrobe fingerprints the allocator and the release helper by their statement shape, which is how it tells the capture store apart from any other table in the chunk. It then gives each allocation one Lua local, named __vu0, __vu1, and so on in allocation order, and declares it once in the scope that allocates it. Every read and write of that slot is rewritten to the same local, both in the allocating function and in each closure that receives the slot, so the recovered closure captures the variable by reference the way the original source did.

The committed corpus/lua/prometheus/vmify_upvalue pair holds real Prometheus Vmify output for a counter factory whose inner function captures a local. Two gates grade it. One asserts that the committed obfuscated file already prints what the clean file prints under a real lua 5.1 binary, so the fixture is a faithful transform. The other runs the recovered source under the same binary and requires identical output (tests/reexec_diff_oracle.rs).

Set DISROBE_DEBUG=lua to trace the capture analysis. It emits prometheus_vmify.box_model, naming the registers the capture helpers resolved to, and prometheus_vmify.captured_variables, the number of captured variables bound in the run.

Limits

  • Where fully peeled is false the report carries the residual marker names and the reason (runtime key, anti-tamper variant, unmodeled VM tier, or a refused Vmify recovery).
  • Runtime-key and anti-tamper variants (MoonSec v3 with an encrypted constant pool keyed at runtime) are the wall: the key is not present statically, so disrobe returns fully_recovered: false with a runtime keys residual marker.
  • The MoonSec emulate_perm_builder path interprets a bootstrap table-builder over its seed and is unit-tested on a realistic synthetic bootstrap of our own design whose permutation is derived at runtime. End-to-end validation against a real captured MoonSec dump is pending: no live sample is publicly available.

Prometheus Vmify refusals

Vmify recovery either emits source that runs the same as the original or refuses. It never leaves a placeholder function standing in for a body it could not recover. A refusal names its cause, and where the cause is a specific expression it names the byte offset of that expression in the text the layer was reading.

A refusal takes one of two shapes.

  • The whole container is refused. fully peeled is false, prometheus-vmify-container-devirt is absent from passes run, the output file holds the earlier peel stage rather than a devirtualized body, and a residual marker reads a Vmify container was found but recovery was refused rather than emitted partly wrong, followed by the reason.
  • Some layers recovered and a further layer is refused. The recovered source is written, fully peeled is false, and a residual marker records how many layers recovered before the refusal, followed by the reason.

disrobe refuses these captured-variable shapes.

  • A capture allocated on a cycle in the recovered control-flow graph, which is what a capture written inside a source loop becomes. Each iteration of such a loop captures a fresh variable, and one declaration at function scope would alias them into a single shared variable. The committed corpus/lua/prometheus/vmify_loop_capture pair is a loop that builds three closures over three different values, and disrobe refuses it. The cycle search runs under a step budget, and disrobe refuses when the search exceeds it.
  • A chunk that creates a closure with a non-empty capture list, but whose reference-counted capture helpers do not match the shape disrobe fingerprints. The fingerprint accepts exactly one allocator and exactly one store bound to that allocator's reference-count table, so a chunk carrying more than one of either yields no model and no captured variable can be resolved to a real Lua variable. A chunk that captures nothing needs no model and is unaffected.
  • A register that receives a second capture inside one recovered function, because the register cannot then carry one stable name.
  • A capture allocator referenced anywhere other than a plain allocation assignment.
  • A store slot read or written through anything other than a register the same function allocated a capture into, or an entry of the closure's own capture list indexed by a positive whole-number literal. An index past the end of the supplied capture list is also refused.
  • A closure-creation call whose capture list is not a table constructor, or whose capture list carries a keyed entry. A keyed entry has no stable position to match against the closure body.
  • A closure-creation call whose entry point is not a literal number, because the closure body cannot then be located in the dispatch tree.
  • One dispatch leaf shared by two closures with different captured variables, which would bind one expression to two different recovered values.
  • A recovered body that still names the container's own capture machinery, meaning the capture table, the store, the allocator, or a slot register. At least one access went unresolved in that case, so the body is refused rather than emitted.

Recovery is graded against Prometheus output built for Lua 5.1 with Vmify as the only step. Other target dialects and other step combinations are not graded.

PHP

disrobe surfaces the three supported commercial encoder envelopes fully offline, recovering statically available layers and reporting when loader-resident keys keep payloads sealed; nothing is uploaded anywhere. It also peels stacked eval-chain obfuscation and walks Phar archives.

At a glance

LayerCoverage
Commercial encodersionCube, SourceGuardian, Zend Guard: envelope detect and wall (the decrypt key is native-loader-resident); a partial op_array skeleton only for legacy statically-keyed cases (Zend legacy XOR), graded StructuralOnly otherwise
Phar archivesManifest walker with path-sanitized extraction
Eval-chain layersbase64_decode, gzinflate, gzuncompress, gzdecode, bzdecompress, str_rot13, strrev, str_replace, urldecode / rawurldecode, hex and octal escapes, pack and unpack in the hex, byte and 16-bit and 32-bit word forms, chr() concatenation, uudecode, single-key XOR, create_function, nested eval, FOPO, Better PHP Obfuscator. A strtr in front of base64_decode covers the URL alphabet and any custom alphabet
Decode loopsfor, while, do-while and foreach over str_split, with modulo, plain, reversed, stride, rotating and nested indices, and XOR, add and subtract with wraparound, rotate, negate, table substitution and index-parity byte operations. The key must be present in the file. A bounded interpreter runs the loop body under explicit step, wall-clock, output-size, heap, expression-depth, frame-depth and loop-count budgets, and refuses every call outside a pure-function allowlist
Block ciphersopenssl_decrypt in aes-128-cbc, aes-192-cbc, aes-256-cbc, aes-128-ecb, aes-192-ecb and aes-256-ecb, with raw or base64 input and PKCS#7 or zero padding. The passphrase is truncated or zero-extended to the key length, as php does. The key and the initialisation vector must both be present in the file
ConstantsA key held in a define() constant, read as a bare name or through constant(). defined() is answered from the same table. The php OPENSSL_RAW_DATA and OPENSSL_ZERO_PADDING flags are known, and a file cannot redefine them
Decode helpersA helper function declared at the top level of the file, called directly or through a variable holding its name, including recursion, mutual recursion, default arguments and array arguments. The helper runs in its own scope, as php runs it, so it reads only what it is passed
Recovery gradingEvalChainPeeled / OpArrayDecompiled / StructuralOnly / PlainSource

Commands

disrobe php decode payload.php --out out/payload-php/
disrobe php decode payload.php --encoder ioncube --i-have-authorization
disrobe php deobfuscate obfuscated.php --out clean.php
disrobe php extract archive.phar --out extracted/

--encoder is auto (default), phar, ioncube, sourceguardian, or zendguard. Commercial encoders require the explicit --i-have-authorization flag. The output directory receives the decoded payload, a skeleton .php when an op_array was decompiled, and a manifest.json recording the encoder, version label, marker offset, ciphertext and plaintext byte counts, and the recovery stage.

Output shapes below are illustrative.

php decode: OK
  input:        payload.php
  encoder:      Ioncube
  out dir:      ./out/payload-php
  manifest:     ./out/payload-php/manifest.json

deobfuscate unwraps stacked eval() layers until the residue is plain PHP. The manifest counts each layer kind that was peeled and flags whether any eval remains in the residue.

php deobfuscate: OK
  input:        obfuscated.php
  layers:       3
  residual_eval:false
  wrote:        ./out/obfuscated.peeled.php
  manifest:     ./out/obfuscated.peeled.manifest.json

extract walks the Phar manifest and extracts every entry through a path-sanitizer (no .. escapes), writing a manifest.json with the entry count and API version.

php extract: OK
  input:        archive.phar
  entries:      14
  out dir:      ./out/archive-phar
  manifest:     ./out/archive-phar/manifest.json

Coverage and fidelity

The commercial PHP encoder market has no maintained FOSS competition offline. Every recovery carries an explicit stage in its manifest: EvalChainPeeled, OpArrayDecompiled, StructuralOnly, or PlainSource, so a caller can tell a fully peeled payload from an envelope parse.

Limits

  • ionCube, SourceGuardian, and Zend Guard keys live in the native loader, not the file. Those envelopes are detected and walled. A partial op_array skeleton is recovered only for legacy statically-keyed cases (Zend legacy XOR).
  • When an encoder's key lives only in its runtime loader, the decode is graded StructuralOnly and the manifest carries the residual ciphertext length rather than pretending at plaintext.
  • A decode loop whose key arrives at run time, from $_GET, $_POST, a header or the network, is not statically recoverable. The loop is left in place and no plaintext is produced.
  • A helper declared inside a conditional or inside another function is not evaluated. Only a declaration at the top level of the file is, which matches where php makes a function callable before its own text.
  • A helper taking a parameter by reference, or a variadic parameter, is not evaluated, because the interpreter models values rather than references.
  • The interpreter reads a variable only when the file defines it. An undefined read, a call outside the pure-function allowlist, or any exceeded budget abstains and leaves the loop in place.
  • An undefined constant abstains. A constant whose value comes from the file name, the request, or the environment is not resolved, because the value is not in the file.
  • A cipher mode outside the listed AES set, including a stream or authenticated mode, is refused. The call is left in the source rather than decrypted by a different primitive.
  • A division that does not divide exactly abstains. The interpreter models integer values only, so strlen($d) / 3 is not evaluated where php would produce a float.
  • mcrypt_decrypt is not evaluated. The extension was removed in php 7.2, so a loader that calls it cannot be graded against a real interpreter.

Ruby

disrobe analyzes the Ruby artifact formats listed below and decompiles YARV and mruby bytecode toward source. A recompile-equivalence gate on a real MRI interpreter measures YARV recovery.

At a glance

LayerCoverage
Flavors detectedMRI source, YARV binary (YARB magic), mruby RITE, JRuby .class, TruffleRuby AOT, Ruby2Exe, Ocra
YARVIBF reader (iseqs, object table, literals) plus a decompiler driven by per-version opcode tables for Ruby 2.6 through 3.4
mrubyRITE reader covering format versions 0001-0007, 0030, 0200, and 0300, with irep disassembly and decompilation
Fidelity100% opcode-multiset equivalence on a greeter fixture; 98.67% measured on a mixed-construct megafile, above the CI gate's 98% floor
OutputAnalysis JSON; a .rb source file for YARV and recovered mruby bodies, with a YARV disassembly trailer when available

Commands

disrobe ruby decompile app.bin --out app-ruby.json
disrobe ruby detect app.bin

decompile sniffs the flavor, runs the matching analyzer, and writes the analysis JSON (default ./out/<stem>-ruby.json). For YARV, or for mruby when a body is recovered, it also writes a .rb source file beside the JSON; YARV output includes a disassembly trailer when available. detect reports the flavor and exits without writing output.

Output shape (illustrative):

ruby decompile: OK
  input:        app.bin
  flavor:       YarvBinary
  yarv header:  major=3 minor=4
  yarv iseqs:   12
  yarv bodies:  12
  yarv objects: 34
  yarv literals:18
  yarv insns:   97
  yarv decomp:  Lossless
  yarv stmts:   23
  decompiled:   ./out/app.rb (yarv)
  wrote:        ./out/app-ruby.json

Coverage and fidelity

For MRI source the summary reports token and definition counts. For YARV it adds the IBF header fields, iseq and object counts, instruction count, decompile fidelity, and statement count. For mruby it reports the compiler version string, irep count, instruction count, and whether a body was recovered.

A committed recompile-equivalence oracle compiles the recovered YARV source on the matching interpreter and diffs the opcode multiset. The gate asserts 100% equivalence on the greeter fixture and at least 98% on the megafile fixture; both run in CI.

Ruby2Exe and Ocra self-extracting packages are detected as their own flavors. Analysis records their embedded-payload offsets and lengths; for Ocra opcode streams it also parses contained file records.

Limits

  • JRuby .class files are classified but not decompiled here. JVM-class material belongs to the JVM guide.
  • TruffleRuby AOT images are classified but not decompiled here. Their native code belongs to the native guide.

BEAM (Erlang / Elixir)

disrobe parses BEAM IFF files, recovers Erlang or Elixir source when debug chunks survive, lifts to Core Erlang otherwise, and disassembles the Code chunk per instruction.

At a glance

LayerCoverage
ChunksAtU8 (short and long form), Code, StrT, Attr, CInf, Dbgi, Docs, ExpT, ImpT, LocT, FunT, Line, LitT (zlib-deflated on OTP 26 and earlier, raw on OTP 27+); unknown chunks are preserved verbatim
Source recoveryErlang abstract code when present, Elixir source from a Dbgi form, best-effort Core Erlang lift as the floor; provenance is recorded in recovered_from
DisassemblyPer-instruction Code-chunk trace including the bs_match (opcode 182) command list; a flat .txt listing lands beside the JSON
Containers.ez archives extract through the container layer

Commands

disrobe beam parse module.beam --out ./out/module-beam.json
disrobe beam lift module.beam --out out/module-beam-lift/
disrobe beam disasm module.beam --out ./out/module-beam.disasm.json

Output shapes below are illustrative.

parse reports the module name, atom / export / import / fun counts, which optional chunks are present, and any unrecognized chunk names.

beam parse: OK
  module:       my_module
  atoms:        42
  exports:      8
  imports:      15
  funs:         3
  wrote:        ./out/module-beam.json

lift writes three files: <stem>.<ext> (recovered Erlang or Elixir source, extension derived from recovered_from), <stem>.surface.json (the surface record with provenance), and <stem>.core.json (lifted Core Erlang functions), plus a manifest.json linking them.

beam lift: OK
  module:       my_module
  core fns:     8
  recovered:    AbstractCode
  source:       ./out/module-beam-lift/module.erl
  surface:      ./out/module-beam-lift/module.surface.json
  core erlang:  ./out/module-beam-lift/module.core.json
  manifest:     ./out/module-beam-lift/manifest.json

disasm emits the per-instruction Code-chunk trace as JSON and a flat .txt listing beside it.

beam disasm: OK
  input:        module.beam
  instructions: 214
  wrote:        ./out/module-beam.disasm.json
  listing:      ./out/module-beam.disasm.txt

Coverage and fidelity

When a Dbgi chunk is present the original forms are recovered directly and labeled AbstractCode (Erlang) or ElixirDbgiForm (Elixir). Each lift records where its source came from in recovered_from, so a caller can tell a recovered original from a lift.

For stripped BEAM, 18 / 19 committed corpus entries recover to Core Erlang source that recompiles under Erlang/OTP 27.3.4, preserves the original export set, and returns the same result from the entry's committed test/0 battery. Each corpus entry is an Erlang module compiled from committed source; the gate removes both Dbgi and Docs before recovery so neither source path can participate.

This is a test/0 recompile-execution differential, not a claim of equivalence for every input to every exported function. The original and recovered source are compiled independently by real erlc; real erl then compares exit status and stdout. A mutation control replaces a recovered test/0 with one that raises while still recompiling with matching exports, and requires the runtime leg to reject it.

The Linux CI test leg pins OTP 27.3.4 and makes missing erlc or erl fatal. macOS and Windows retain explicit optional reporting when that toolchain is absent. Reproduce the Linux gate with:

DISROBE_REQUIRE_ERLANG=1 cargo test -p disrobe-pass-beam --test erlc_recompile_equivalence -- --nocapture

Limits

  • Without a Dbgi chunk the original source is not in the file. The output is then a best-effort Core Erlang lift labeled CoreLifted, not the original text.
  • An opcode beyond the known table fails with an explicit DR-BEAM-0012 error naming the offending opcode rather than silently skipping bytes.

Swift / Objective-C

disrobe extracts the type metadata the Objective-C and Swift runtimes leave in a native binary, demangles it, parses SwiftShield rename mappings into a lookup for downstream use, and rebuilds the dylibs a dyld shared cache bundles into standalone Mach-O images.

At a glance

LayerCoverage
Objective-C metadata__objc_classlist, __objc_catlist, __objc_protolist: classes, categories, protocols, ivars, properties, method selectors with type encodings
Swift metadata__swift5_types, __swift5_fieldmd, __swift5_proto: type names, stored fields, conformances, symbols demangled
ContainersSingle slice via disrobe swift classdump; raw thin and fat Mach-O binaries via disrobe macho classdump, which walks every slice
dyld shared cacheHeader layouts legacy, local-symbols, slide-mappings, sub-caches, relocated-images; bundled dylibs rebuilt as standalone Mach-O images, per-image __LINKEDIT, slide info versions 1 to 5, sibling sub-cache and .symbols files
Rename obfuscatorsSwiftShield mapping parser
String blobsExplicit-key single-byte XOR blob decoding for model fixtures
Message dispatchobjc_msgSend, objc_msgSendSuper, objc_alloc, and objc_alloc_init sites resolved to selector, receiver class, and a rendered message expression
Mach-O surfaceHeader, load commands, segments, sections, fat slices, LC_ENCRYPTION_INFO records

Commands

disrobe swift classdump App.app/App --out dump.json
disrobe swift shield-undo map.txt --out renames.json
disrobe swift xor-decrypt blob.bin --key 0x55 --out strings.json

disrobe macho classdump universal.bin --out dump
disrobe macho dump App.app/App
disrobe macho fat universal.bin
disrobe macho dyldcache dyld_shared_cache_arm64e --out ./out/cache-dylibs

classdump reconstructs the type interface from the two metadata sources the runtime leaves in the binary. Beside the JSON, it writes a .swift declaration file with recovered type signatures when reflection metadata yields declarations; source-level function bodies do not survive in this metadata.

shield-undo parses a SwiftShield mapping. SwiftShield renames symbols to high-entropy identifiers and emits an obf ==> original mapping in the .dSYM. disrobe writes that mapping as a lookup for downstream use; class-dump does not apply it automatically. xor-decrypt decodes printable strings from a single-byte XOR blob when the caller supplies --key. Its tests cover hand-authored model fixtures, not SwiftConfidential output.

macho dump reports the header, load commands, segments, sections, and any LC_ENCRYPTION_INFO or LC_ENCRYPTION_INFO_64 records. macho fat walks a fat binary and reports each slice's CPU type, subtype, and offset.

macho dyldcache rebuilds every dylib the cache bundles and writes each one under --out at a path taken from its install name. It reads the single file you name and writes compact images. Use disrobe auto on a cache that is split across sibling files or when you want images with a rebuilt __LINKEDIT.

Output shape (illustrative):

swift classdump: OK
  input:        App
  cpu/bits:     arm64 / Bits64
  swift types:  24
  reflected:    18
  mangled syms: 312
  demangled:    312
  swift declarations: ./out/App-swift.swift
  wrote:        ./out/App-swift.json

Coverage and fidelity

The Objective-C side walks __objc_classlist, __objc_catlist, and __objc_protolist to recover classes, categories, protocols, ivars, properties, and method selectors with their type encodings. The Swift side parses the reflection sections (__swift5_types, __swift5_fieldmd, __swift5_proto) and demangles the symbols to recover type names, stored fields, and conformances.

The demangler recovers async functions, actor and distributed-actor entities, and opaque return types. It also recovers key-path and protocol-witness thunks, partial-apply forwarders, Objective-C bridging thunks, attached macro expansions, and cross-module protocol conformance descriptors and witness tables. Each arm is checked against a real swift-demangle run (arm_coverage.rs).

Objective-C calls compile to objc_msgSend, so a raw disassembly shows only indirect calls into the runtime. When the chain pipeline recovers native function bodies from a Mach-O (disrobe chain / disrobe auto over a Mach-O), each objc_msgSend, objc_msgSendSuper, objc_alloc, and objc_alloc_init call site is resolved back to its concrete selector, the receiver class where it is statically determinable, and a rendered Objective-C message expression such as [NSString stringWithUTF8String:x2]. The dispatch maps are built from the binary's __objc_selrefs and __objc_classrefs, the dyld bind opcodes, and the __stubs section, then a bounded per-call backward walk over an arm64 or x86-64 def-use model traces the selector and receiver into the call.

The resolution is graded on real clang-compiled fixtures for both arm64 and x86-64 stripped dylibs: every message send in the fixture recovers with the correct selector and receiver class and zero false positives, and the rendered expressions match the source (objc_dispatch_sends.rs).

dyld shared cache

A dyld shared cache packs many system dylibs into one file. disrobe claims a file that carries the dyld_v1 magic and whose header parses. A file that only starts with the magic is left to another pass.

The offset of the mapping table fixes which header layout the cache uses. disrobe names that layout legacy, local-symbols, slide-mappings, sub-caches, or relocated-images. It reads the image list from the legacy header fields when they are set, and from the relocated image fields otherwise. The cache report carries the layout beside the architecture, header size, UUID, platform, format version, the simulator and chained-fixups flags, the mapping, image, and sub-cache counts, the sub-cache entry kind, whether local symbols sit in a separate symbols file, one entry per slide region naming its version or the version number disrobe does not support, the mappings whose file range runs past the end of the file, the pairs of mappings whose address ranges overlap, and up to 4096 install names.

Each image is rebuilt from the mappings that cover its segment addresses, and its segment and section file offsets are rewritten to point at the recovered bytes. Compact output packs the segments back to back. Page-aligned output starts every segment on a 0x4000 boundary and replaces __LINKEDIT with one built for that image alone. A rebuilt __LINKEDIT holds a symbol table, an indirect symbol table, and a string table for that image, and it copies the image's dyld info blobs, chained-fixups blob, exports trie, function starts, and data-in-code table out of the cache file that holds them. The LC_CODE_SIGNATURE offset and size are set to zero, so a recovered image is unsigned. Dysymtab tables the image does not carry are zeroed instead of left pointing into the cache. Two runs over the same cache produce the same bytes.

The cache keeps each image's local symbol names in a run of its own. The legacy, local-symbols, and slide-mappings layouts use the narrow entry, which keys each run by the image's file offset in the primary file. The sub-caches and relocated-images layouts use the wide entry, which keys each run by the image's offset from the cache base, and they move the runs into a separate symbols file when the header carries a symbol-file UUID. disrobe reads both forms and appends the run for an image to the symbol table it builds for that image.

Slide info records the pointers the cache builder rewrote to cache addresses. disrobe reads versions 1, 2, 3, 4, and 5, and writes each pointer back to the value the image declares. An authenticated pointer is recorded with its key (IA, IB, DA, or DB), its diversity value, and its address-diversity bit, and the pointer in the recovered image holds the bare target. Each image records up to 65536 authenticated pointers and reports the full count beside a flag that states whether the list was cut. A slide-info version outside 1 to 5 stops the image, and the error names the version it found.

The cache container in the tests is written to the header layouts above, and the image inside it is a compiled Swift dylib committed to the corpus. Recovery is graded against that original file's segment bytes, symbol table, exported symbols, and function starts (dyld_cache_reconstruction.rs, dyld_cache_chain.rs). Slide info is graded end to end through a rebuilt image for versions 3 and 5, and at the page walk that decodes them for versions 1, 2, and 4.

Sibling cache files

A cache can declare sibling files that hold the rest of its mappings. disrobe locates them by computing names from the primary file you named. For sub-cache N it looks for <primary>.N, and for a single-digit index it also looks for the zero-padded <primary>.0N. For the unmapped local symbols it looks for <primary>.symbols. A file suffix declared inside the cache is never used as a path. Such a suffix is rejected before any file is opened when it holds .., a path separator, :, one of *, ?, ", <, >, |, or a byte outside graphic ASCII.

A missing .symbols file does not stop recovery. Each image keeps exactly the symbol table it declares, and the run continues.

A missing numbered sibling does stop recovery, because the segments it holds are unreachable. The chain refuses, and the error names the sibling it looked for, how many images it could not reach, and the segment that was out of reach for each one.

Recovered dylibs in the chain

disrobe auto and disrobe chain detect the cache, write the cache report as JSON, and emit each recovered dylib as a child artifact that continues through the chain. Both commands pass the input path down, which is how the sibling files are found. Children are page-aligned images with a rebuilt __LINKEDIT. A child keeps its install name as its relative path, with the leading separator and any . or .. component dropped. Every character outside graphic ASCII becomes _, and so does each of :, *, ?, ", <, >, and |. An install name that leaves nothing after that becomes dyld-cache-image.

disrobe auto dyld_shared_cache_arm64e --out ./out/cache-chain

The chain refuses a split cache when it has no path to compute sibling names from. It refuses in the same way when the path it is given is not a readable file.

Limits

  • Swift and Objective-C compile to native machine code. Source-level function bodies are not part of the runtime metadata; native code can still be analyzed by the native pass. This pass recovers the metadata the runtimes need at run time.
  • A dispatch site whose selector or class cannot be traced within the backward-walk window is left unannotated rather than guessed, so a spurious annotation counts as a soundness failure.
  • disrobe macho dyldcache reads the one file you name and writes compact images. It does not rebuild __LINKEDIT, so a compact image keeps the symbol table offsets the cache gave it. It does not read sibling cache files. It stops at the first image it cannot reach instead of reporting a partial result.
  • The chain handles a cache of at most 256 images. A cache above that count is refused whole, and the error points at disrobe macho dyldcache, which carries the limits above.
  • One rebuilt image is capped at 512 MiB, one whole-cache run at 1 GiB, and one cache family at 12 GiB across its files. A cache that needs more than a cap is refused rather than truncated.
  • A recovered image is unsigned. disrobe clears the LC_CODE_SIGNATURE offset and size whenever it rebuilds __LINKEDIT.
  • FairPlay-encrypted regions (App Store DRM) are reported detect-only via LC_ENCRYPTION_INFO: the decryption key is not present in the binary, so class-dump of those regions is an information-theoretic wall.

ActionScript 3 / Flash

disrobe parses SWF files, disassembles their embedded ActionScript 3 bytecode (AVM2), and lifts method bodies back to readable AS3 pseudocode via operand-stack abstract interpretation.

At a glance

LayerCoverage
ContainerEvery SWF tag, with its TagCode, byte offset, and payload size
BytecodeEvery DoABC and DoABCDefine block, disassembled per instruction
Source liftClass skeletons with expressions, calls, loops, conditionals, lookupswitch, exception regions, and residual goto control flow
Obfuscator detectionsecureSWF, DoSWF, Kindi, Irrfuscator, swfLock, each finding with a confidence score

Commands

disrobe as3 disasm movie.swf --out out/
disrobe as3 tags movie.swf

disasm walks every DoABC and DoABCDefine tag, emits a per-instruction AVM2 listing as <label>.disasm.txt beside the JSON, and reconstructs class skeletons with lifted method bodies as <label>.source.as3. tags lists every tag in the SWF: TagCode, byte offset, and payload size.

Output shape (illustrative):

as3 disasm: OK
  input:        movie.swf
  swf version:  10
  abc blocks:   2
  classes:      6
  methods:      24
  instructions: 512
  source files: 2
  disasm files: 2
  out dir:      ./out
  manifest:     ./out/manifest.json

Coverage and fidelity

The source lifter reconstructs class skeletons with expressions, calls, loops, conditionals, forward lookupswitch regions, exception regions, and residual goto control flow by abstractly interpreting the operand stack. At each tracked forward lookupswitch join, it compares the stack height and values from every predecessor. Equal-height predecessors preserve an identical value or produce a named merge value and mark the method partial. Conflicting heights keep the raw control-flow form instead of selecting a value from the last case visited. A /// DR-AS3-PARTIAL: line identifies an unsupported opcode, opaque operand, dropped code, or internal control-flow residue detected by the lifter. Absence of that line means only that these internal checks found none; it does not establish independent semantic or reference equivalence.

disrobe fingerprints commercial AS3 obfuscators (secureSWF, DoSWF, Kindi, Irrfuscator, swfLock) and flags techniques: string encryption, name mangling, control-flow flattening, register and stack shuffle, string-pool-rebuild candidates. Each finding carries a confidence score.

Limits

  • Obfuscator handling is detection only. No decryption, pool rebuild, or unflattening is performed.
  • ABC erases local variable names (non-parameter slots surface as loc{n}) and the compiler erases generics before ABC. Both are hard ceilings.
  • FFDec is the mature full Flash decompiler and goes further on source-level recovery; disrobe covers SWF parsing and AVM2 disassembly as part of its chain pass, not as a Flash-decompiler replacement.

Mobile (Hermes / Flutter)

disrobe detects the runtime inside a mobile package, extracts React Native and other bundles, lifts Hermes bytecode to a JavaScript surface, and recovers Dart source or disassembles the ARM64 AOT snapshot from Flutter artifacts.

At a glance

LayerCoverage
Runtimes detectedreact-native-apk, react-native-ipa, hermes-raw-bytecode, flutter-libapp-so, flutter-dart-kernel, xamarin-apk, cordova-apk, capacitor-apk, nativescript-apk, ipa, android-apk-dex, unknown
HermesBytecode versions v60 through v96 parse against the documented header layout; v76, v84, and v96 lift to pseudo-JavaScript against a real hermesc-built sample
Dart kernel.dill / kernel_blob.bin parsed to byte-exact original Dart bodies from the kernel source table
Dart AOTlibapp.so AArch64 bodies disassembled with resolved direct-call and branch targets, plus class table, library URIs, and string pool
Dart AOT declaration graphfull library/class/method/field inventory, with method parameter counts, from a libapp.so or four standalone snapshot blobs on a pinned Dart SDK snapshot version
Rename mapsFlutter obfuscation_map.json parsed into a typed original-to-obfuscated lookup

Commands

disrobe mobile detect app.apk
disrobe mobile extract app.apk --out bundles/
disrobe mobile hermes index.android.bundle --out disasm/
disrobe mobile flutter libapp.so --out layout.json

disrobe hermes decompile index.android.bundle --out surface/
disrobe hermes disasm index.android.bundle --out disasm/
disrobe hermes info index.android.bundle

disrobe flutter dump libapp.so --out layout.json
disrobe flutter dump libflutter.so --format json --engine-symbol-map engine-symbols.json
disrobe flutter decompile libapp.so --out estimate.json
disrobe flutter kernel app.dill --out kernel.json
disrobe flutter disasm libapp.so --emit-listing
disrobe flutter map obfuscation_map.json --out map.json
disrobe flutter inventory libapp.so --out inventory.json
disrobe flutter inventory-standalone vm_data vm_instructions isolate_data isolate_instructions --out inventory.json

mobile detect classifies the package; mobile extract pulls bundles out of the container. It writes a manifest.json for React Native, Flutter, Cordova/Capacitor, NativeScript, and Xamarin extraction; Android APK/Dex and Android bundle paths write child files directly without a manifest.

hermes decompile lifts each function back to pseudo-JavaScript. hermes disasm emits a per-function summary without a JS surface. Pass --function <INDEX_OR_NAME> to select a zero-based index or exact function name, and add --json to receive the same instruction list as a structured document. Duplicate names require an index. hermes info prints the version, function count, string count, and identifier count.

flutter dump reports the four Dart snapshot sections and their sizes. With --format, an optional --engine-symbol-map accepts the versioned disrobe.flutter.engine-symbol-map JSON format. Disrobe applies those names only when the map's GNU build ID matches the ELF input and every address lies inside an image segment. The parser caps the map at 1 MiB and 10,000 unique addresses. flutter map parses a Flutter obfuscation_map.json into a typed original-to-obfuscated lookup.

{
  "format": "disrobe.flutter.engine-symbol-map",
  "version": 1,
  "identity": {
    "kind": "elf-build-id",
    "value": "b71885094a73117bf90d3cfa05824129"
  },
  "symbols": [
    { "address": 4096, "name": "Dart_Invoke" }
  ]
}

address is an unsigned virtual address in the input image, not a file offset or image-relative offset. Addresses must be unique. A validated external entry replaces a symbol-table entry at the same address; all other local symbols remain. JSON exports record the map path, format, and matched build ID in provenance. The same map can be supplied to a single-file automatic run with disrobe auto <input> --format json --engine-symbol-map <map>.

Output shapes below are illustrative.

hermes decompile: OK
  input:        index.android.bundle
  hermes ver:   96
  functions:    8
  with body:    8
  identifiers:  24
  strings:      12
  opcode cov:   100.0% (312 reconstructed / 0 fallback)
  if/loop/try:  3/2/1
  source:       ./out/index.android.bundle-hermes/index.android.bundle.js
  manifest:     ./out/index.android.bundle-hermes/manifest.json
flutter kernel: OK
  input:        app.dill
  format ver:   130
  libraries:    3
  classes:      8
  procedures:   21
  fields:       14
  bodies:       21 recovered (byte-exact Dart source from the kernel source table)
  strings:      112
  wrote:        ./out/app-dart-kernel.json
  dart source:  ./out/app-dart-kernel.recovered.dart

Coverage and fidelity

decompile reads the header of Hermes bytecode versions v60 through v96 against the documented layout. Lifting each function back to pseudo-JavaScript is graded against a real hermesc-built sample at v76, v84, and v96 only; other versions in the v60-v96 band parse but are not graded against a real compiler. On a hermesc-built HBC v96 sample (8 functions, CI-gated) every function lifts at 0 fallback opcodes. A non-redistributable 66 MiB production bundle parsed the 122,633-function table with no parse failure (measured locally, not CI-gated).

Two distinct recovery paths cover two distinct Flutter artifacts.

Corpus provenance: self-authored sample. disrobe_sample/ is self-authored: Flutter stable SDK, bundled Dart SDK 3.12.2 (windows_x64), kernel format version 130/132, gen_snapshot android-arm64-release in product mode with no DWARF. Its source, disrobe_aot_sample.dart, exercises:

  • Classes with final fields, a const constructor, a computed getter, and an instance method that returns a new instance of its own class
  • A top-level recursive function and sequential if/return control flow
  • Nullable types with ?? and ?., string interpolation, a for-in loop over a List, and a non-capturing lambda passed to .where()

It does not exercise async or generator functions, a user-defined generic class or function, a closure that captures an enclosing variable, extension methods, mixins, Future/Stream chains, or build_runner-generated code, so recovery of those constructs is untested.

Corpus provenance: obfuscated builds. pinned_graph_fixture/ is a second self-authored source built three times by the real flutter build apk --release toolchain (Flutter 3.44.6, Dart 3.12.2): plain, with every class and field renamed, and with --obfuscate --split-debug-info=build/symbols. The obfuscated build is graded, not merely present: obfuscated_flutter_build_reports_structure_only and obfuscated_auto_mode_never_claims_source_names require the declaration graph to report StructureOnly with opaque names and never claim a source name it does not have.

A real third-party sample, local only. crates/disrobe-pass-mobile/tests/real_flutter_rustdesk.rs fetches RustDesk 1.4.9's arm64-v8a release APK (AGPL-3.0, 26,871,021 bytes, sha256 pinned in the test and in corpus/mobile/flutter/MANIFEST.toml) and extracts lib/arm64-v8a/libapp.so and libflutter.so from the zip in memory; the APK itself is never committed. Reproduce it with:

mkdir -p "$TMPDIR/disrobe-scratch/rustdesk-flutter-cache"
curl -sSL -o "$TMPDIR/disrobe-scratch/rustdesk-flutter-cache/rustdesk-1.4.9-aarch64-signed.apk" \
  "https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-aarch64-signed.apk"
cargo test -p disrobe-pass-mobile --test real_flutter_rustdesk

This result is [local]: no CI job populates the cache, so it never runs there, and DISROBE_REQUIRE_RUSTDESK_FLUTTER=1 fails the run instead of skipping it when the cache is absent. On this real build the RAW static path recovers

23,471 function boundaries,

10,351 class-name strings, 28,952 method-name strings, and 1,489 library URIs, each cross-checked against an independent whole-file package:*.dart string scan that finds 1,271 of its own. Every one of those counts is pinned by equality in the test, so a figure that moves fails the gate rather than drifting silently. RustDesk's Dart snapshot falls outside the pinned Dart 3.12.2 android-arm64 product tuple, so the declaration-graph path (flutter inventory) reports unsupported-version on it rather than guessing at a cluster layout it has no pin for.

Dart kernel (.dill / kernel_blob.bin). A kernel is the serialized Dart AST. disrobe parses the kernel binary format (magic 0x90abcdef): the footer component index, the string table, per-library class and procedure offset tables, and the embedded UriSource table. From the source table it recovers byte-exact original Dart bodies, sliced per procedure by the kernel file offsets. The recovered .dart source file is always written beside the JSON without needing --emit-source.

ARM64 AOT snapshot (libapp.so). The AOT snapshot is ordinary AArch64 machine code. disrobe locates the four _kDart*Snapshot* symbols, recovers class and method names from the isolate-data string table, scans frame prologues to bound functions, and disassembles each body to readable instructions with resolved direct-call and branch targets. flutter decompile also recovers the class table estimate, library URIs, and a string pool from the isolate image.

Beyond the disassembly, the release ARM64 path recovers class membership and method-to-class attribution, and lifts each function to nested if/else/while pseudocode through the shared structurer. That lift is gated by a source-free CFG round-trip: when the recovered structure does not round-trip to the same control-flow graph, the function falls back to a flat call list rather than presenting a shape the graph does not support.

Each call in that pseudocode carries its reconstructed argument list. disrobe tracks the Dart argument registers, the Dart stack slots that hold arguments past the register file, and the caller's frame slots across the call sequence, then renders each argument as an expression: an immediate, a null or boolean read from the null register, a value produced by an earlier call, a field load by offset, or an object-pool entry. A tail call renders the same way, as a returned call. Pool entries are inlined as literals. On a pinned snapshot version disrobe deserializes the object pool per slot, so a call that loads a string, a double, an integer, a list, or a declared name reads that value back at the call site. Inlining is depth-bounded and size-bounded, and a cyclic pool reference stops at the placeholder. An argument the tracker cannot resolve renders as ?, and an argument produced by a control-flow merge always renders as ? rather than one branch's value. A call whose arguments cannot be recovered at all keeps the opaque (...) form.

Declaration graph (flutter inventory / flutter inventory-standalone). The clustered object graph inside the snapshot data blobs carries the full library, class, method, and field declarations, keyed to the exact Dart SDK snapshot version and feature tuple that serialized them. disrobe reads that graph directly for the pinned Dart 3.12.2 Android arm64 build (both the plain product tuple and the --split-debug-info DWARF tuple): every class, method with its parameter count, and instance field name, cross-checked on real flutter build apk --release output against the snapshot's own cluster-header totals. A snapshot compatibility hash outside the pinned set reports unsupported-version rather than guessing at cluster offsets.

Detected runtimes

disrobe mobile detect|extract routes the packaged JavaScript and .NET runtimes out of an .apk or .ipa: React Native Hermes, Flutter, Xamarin, Cordova, Capacitor, and NativeScript, each handed to the pass that reads it.

Limits

  • The Hermes path is a structured lifter, not a full decompiler. Variadic call arguments are marked <arg?> where the Hermes frame-register layout is not modeled; unreconstructed opcodes appear in disasm form inline.
  • Exact Dart source for an optimized AOT function is not byte-recoverable from the machine code (register allocation and inlining are lossy). Use the kernel path for source bodies.
  • Instance field names are not recovered by the ARM64 disassembly path (flutter decompile / flutter disasm); they are only reachable through the declaration graph (flutter inventory), and only on a pinned Dart SDK snapshot version.
  • The declaration graph path recovers names, not values or optimized-body source; it never guesses a cluster layout for a snapshot compatibility hash it does not have pinned.

Python pickle

disrobe analyzes pickles statically so you can audit what a stream would do on load before it ever touches a real interpreter. It never unpickles, never calls __reduce__, never executes a REDUCE opcode.

Pickle is a code-execution format wearing a data format's clothes. Unpickling a crafted stream runs arbitrary code through __reduce__ / REDUCE, which makes weaponized .pkl and .pt files a recurring ML supply-chain vector.

At a glance

LayerCoverage
Protocols0 through 5
Symbolic VMFull object graph, memo, stack, and STOP result reconstructed with nothing executed
ReconstructionGraph rendered back to re-executable Python assignments, including the listitems and dictitems extension streams
Safety gradingThree severity tiers, each finding tagged with a confidence tier
ContainersPyTorch, TorchScript, and numpy model files; zip, zip64, and tar polyglots
BindingsThe same static suite is available as a Python library

Commands

disrobe pickle disasm model.pkl --out trace.txt
disrobe pickle decompile model.pkl --out graph.py
disrobe pickle safety model.pkl
disrobe pickle trace model.pkl
disrobe pickle polyglot suspicious.bin
disrobe pickle ml-detect model.bin

trace walks the opcode stream and reconstructs the object graph the same way a real unpickler would build it, but every operation is symbolic. A GLOBAL records a (module, name) reference without importing the module; a REDUCE records "this callable would be applied to these arguments" without calling it; the memo, stack, and STOP result are all inert values. The output is the full graph (objects, memo, resolved globals, reduce count) with nothing executed. decompile renders that graph back to equivalent Python assignments.

polyglot catches the trick of hiding a malicious pickle inside a zip, zip64, or tar that a model loader will open as an archive and then unpickle. ml-detect recognizes PyTorch, TorchScript, and numpy containers and lists every embedded pickle stream, so a multi-file .pt archive is enumerated rather than treated as one opaque blob.

The same static suite is available as a library. Nothing is ever unpickled.

import disrobe
from disrobe import PickleSafety

with open("model.pkl", "rb") as fh:
    payload: bytes = fh.read()

safety: PickleSafety = disrobe.pickle_safety(payload)

severity: str | None = safety.severity          # "benign" | "suspicious" | "overtly_malicious"
finding_count: int   = safety.finding_count
reduce_count: int    = safety.reduce_count
listing: str         = disrobe.pickle_disasm(payload)

Coverage and fidelity

The graph the symbolic VM builds is rendered back to re-executable Python, and the reduce protocol's listitems and dictitems extension streams are modeled, not dropped. That is what lets collections.deque, OrderedDict, and defaultdict reconstruct: a REDUCE that builds the container is followed by the item stream, which disrobe re-emits through extend/__setitem__ helpers so the rebuilt object is populated exactly as the original was, without ever running the pickle.

A committed differential oracle proves this against real CPython. It pickles a corpus spanning primitives, containers, cyclic and shared references, __reduce__ objects, and the collection types above across every protocol (0 through 5), reconstructs each with disrobe, and re-executes the reconstruction under a real interpreter to check it rebuilds a deep-equal object. Every case that is not a proven information-theoretic ceiling re-executes equivalently, a 100% floor a CI gate enforces wherever a CPython interpreter is present.

disrobe pickle safety grades a stream into one of three severity tiers. Each finding is tagged with a confidence tier so a reviewer can tell a certain hit from an inference.

SeverityMeaning
benignNo dangerous import, no reduce against a risky callable, no memo abuse
suspiciousA pattern that can be malicious in context (unusual import, opaque reduce, memo manipulation)
overtly_maliciousA reduce against a known code-execution sink (os.system, subprocess.Popen, builtins.eval / exec, __import__)
ConfidenceMeaning
signature_certainThe finding follows directly from the opcodes (a GLOBAL os system then REDUCE)
pattern_inferredA heuristic shape, not a literal signature match
context_dependentRisky only depending on how the loader uses it

The report also surfaces the resolved import list, the REDUCE count, and the unused-memo count (a common obfuscation tell), so a triage decision does not require reading the raw opcodes.

Limits

  • Nothing is executed, by design: a GLOBAL records a reference without importing the module and a REDUCE records an application without calling it. The report tells you what a load would do; it is not the loaded object.
  • The cases outside the differential corpus's 100% floor are proven information-theoretic ceilings: what they need in order to rebuild is not in the stream.
  • A pattern_inferred or context_dependent finding is a shape, not a signature match. Treat it as a lead for review rather than a verdict.

Shell / PowerShell

disrobe deobfuscates PowerShell, Bash, Batch, VBScript, and VBA. It reverses every major PowerShell obfuscator family and Bashfuscator, recovers VBA source from Office documents, decompiles VBA p-code with stomp detection, recovers Excel 4.0 (XLM) macro formulas, and analyzes PDF maldocs.

At a glance

DialectFamilies
PowerShellInvoke-Obfuscation (Token, AST, String, Encoding, Compress, Launcher), Invoke-Stealth, PowerHell, Chameleon, psobf, ISESteroids
BashBashfuscator (Token, String, Obfuscate, Compress modes), indirection peeler
Batch.bat / .cmd random-char and set-indirection patterns
VBA / VBScriptVBA module source recovery, VBScript WSH patterns
Other surfaceCoverage
VBA p-code264-opcode table across VBA3 / VBA5 / VBA6 / VBA7 (32-bit and 64-bit) with identifier resolution, plus VBA-stomping detection
Excel 4.0 (XLM)BIFF8 (.xls) and BIFF12 (.xlsb) macro sheets, full Ftab and Cetab function tables, shared-formula resolution, auto-run entry points
PDF maldocsBoth cross-reference forms, empty-password RC4 / AESV2 decrypt, embedded JavaScript and every Launch, URI, GoToR, SubmitForm, ImportData, and EmbeddedFile action

Commands

disrobe shell deob payload.ps1 --out recovered.ps1
disrobe shell detect payload.ps1
disrobe shell deob book.xls                    # Excel 4.0 macro-sheet formulas from BIFF8 or BIFF12
disrobe auto payload.ps1 --out recovered/      # detect the dialect and route obfuscated shell automatically

deob auto-detects the dialect and obfuscator family, applies the right reversal, and writes the recovered source plus a manifest.json. detect reports the dialect, family, confidence score, and detection markers without writing output.

Output shape (illustrative):

shell deob: OK
  input:        payload.ps1
  dialect:      PowerShell
  family:       InvokeObfuscationToken
  confidence:   0.94
  markers:      ["iex", "token-replace"]
  wrote:        ./out/payload.deob.ps1
  manifest:     ./out/payload.deob.manifest.json

Coverage and fidelity

VBA source and p-code

From a .docm / .xlsm / .bin Office container, disrobe parses the dir stream (MS-OVBA), maps each module to its stream and TextOffset, and MS-OVBA-decompresses the CompressedSourceCode at that offset to emit the original .bas / .cls text per module (multi-chunk compression and CopyToken bit-count edges handled). Validated against real Word and Excel documents authored via COM, byte-for-byte against the known module text.

The p-code path lifts a 264-opcode table across VBA3 / VBA5 / VBA6 / VBA7 (32-bit and 64-bit) with identifier resolution. The disassembly is graded for exact-or-explained instruction parity against real pcodedmp dumps. Explained differences are limited to four pinned classes of pcodedmp 1.2.6 defects: a shifted identifier-table index, an unresolved object name, a truncated declaration parameter chain, or a missing user-defined type name. The step above it, rebuilding VBA source from that p-code, is graded separately by comparing the recovered text line by line and in order against the authored .bas, keeping every operator and operand order: it recovers 100% of authored lines on both committed modules, 71 of 71 on SourceProbe and 552 of 552 on the wider EdgeCases. That is measured on two modules, not a claim about every VBA project, and the comparison is pinned so a regression fails rather than being printed. VBA-stomping detection runs a p-code-vs-source classifier that flags modules whose compiled p-code diverges from the stored source and recovers the stomped behavior from the p-code.

Excel 4.0 (XLM) macros

disrobe shell deob book.xls recovers Excel 4.0 macro-sheet formulas from a BIFF8 (.xls) or BIFF12 (.xlsb) workbook. It decodes the Ptg RPN token stream back to formula text over the full Ftab and Cetab function tables, resolves shared-formula masters to per-cell absolute references, and flags the built-in auto-run names (Auto_Open, Auto_Close, Auto_Activate, Auto_Deactivate) as execution entry points, so a =EXEC("...") or =FORMULA(...) macro reads back in full. A token the decoder does not recognize is emitted as an explicit unknown marker rather than a fabricated formula. Recovery is graded against hand-built BIFF fixtures with known formulas, covering BIFF12's wider reference fields, shared-formula relative-to-absolute resolution, and the Auto_Open entry point (xlm_fixtures.rs).

The same scriptlang pass covers four runtimes that ship compiled or packaged rather than as plain source.

HashLink (.hl). The register bytecode is parsed byte-exact: type table, functions, natives, globals, and constants. Function bodies disassemble with reconstructed signatures, and source class and method names are recovered and graded against the original .hx. Haxe compiled to JavaScript or SWF routes to the JS and Flash stacks instead.

Perl. A B::Concise op-tree is read back, and ByteLoader-encoded scripts are decoded.

R. .rds serialized objects round-trip.

Tcl. A starkit extracts byte-identically.

PDF maldoc analysis

The shell pass carries a PDF analyzer (disrobe_pass_shell::analyze_pdf) for document-borne malware. It loads a PDF through both cross-reference forms (the classic xref table and cross-reference streams), transparently decrypts a Standard-security-handler document that uses RC4 or AESV2 under an empty user password (authenticating against /U per the PDF algorithms, with no password supplied), then walks the catalog, name trees, page annotations, and form fields to recover embedded JavaScript and every Launch, URI, GoToR, SubmitForm, ImportData, and EmbeddedFile action with its resolved target. Hex-escaped names, split or concatenated JavaScript strings, and Flate / LZW / ASCII85 filter chains are decoded along the way, and every decompression is bomb-bounded. It is graded against hand-crafted PDF fixtures that plant a known marker behind each path (classic-table and xref-stream JavaScript, RC4 and AESV2 empty-password decrypt, a launch target, an embedded file, name-tree and additional-action scripts), plus an RC4 published-vector check and an empty-password authentication test (pdf_fixtures.rs).

Limits

The XLM decoder is graded three ways that do not share a reading of the specification. Workbooks authored by real Microsoft Excel 16.0 are decoded and compared against the formulas as authored, across 99 cells in both directions so that a missing cell and an unexpected extra cell each fail, with every fixture pinned by length and sha256; a control flips one byte of the real =SUM(1,2) Ptg stream and must be rejected. The function tables are graded against an independent decoder's published snapshot, agreeing on 476 shared Ftab ids and 396 shared Cetab ids, which is what catches the wrong-index case where every CALL and EXEC an analyst reads would be renamed. Third, XLMMacroDeobfuscator 0.2.7 reads the same committed workbooks at test time and its formula text is compared cell by cell against the recovery, agreeing on 44 cells across two workbooks once three formatting conventions are folded: the reference renders a whole number as 1.0, spaces its argument separators, and quotes sheet names. Folding is string-aware, so a comma or a quote inside a literal is left alone, and the folding rules cannot rewrite a function name, a cell reference or a digit. Two of the four committed workbooks are outside that leg because the reference cannot read them: it stops at a ptgFuncVar function id it does not carry, and it refuses the Ftab probe sheet that deliberately carries index 0x00FF. Both refusals are recorded as named constants with their exact message, so a refusal that changes is a deliberate edit rather than a silent pass, and neither workbook is counted as graded.

A fourth leg grades the function tables at formula level rather than at name-table level. Each of the 476 Ftab and 396 Cetab ids is set into a real Excel-authored workbook, at four call sites that already exist in that workbook plus two synthesized call sites shaped to take four or five arguments, and the resulting file is read back by XLMMacroDeobfuscator 0.2.7. Of the 476 Ftab ids, 359 come back named by the reference and agree with the recovery; the remaining 117 are ids the reference's own parser table does not carry a name for at all, so they stay graded only against the name-table leg. All 396 Cetab ids come back named and agree. One divergence is recorded: 0x005C is SERIES and 0x019E is SERIESSUM, and the reference names both SERIESSUM, folding two distinct entries onto one name. Two mutation tests demonstrate the leg catches a wrong index rather than passing regardless of one: shifting 0x006E (the index the committed workbook already calls through EXEC) to its neighbor is reported, and so is shifting 0x0125, an id only the tables exercise. Two further tests walk the BIFF8 record and Ptg-class space each committed workbook actually carries, and state a reason for every declared record type and Ptg class no committed workbook carries.

Of the four committed workbooks, real_xlm_excel16.xls and real_xlm_ptgspread.xls are graded against formulas read back from real Microsoft Excel 16.0, not retyped from the byte layout. bench_biff12.xlsb and ftab_probe.xls are real Excel-produced bytes whose expected formulas are authored to the specification rather than confirmed by an Excel readback; both fixtures are recorded that way in the manifest test data reads, and the ftab-index sweep above grades every function table entry those two carry independently of that distinction, since it writes its own probe workbooks rather than trusting either fixture's expected values.

Two limits remain. Specification-assembled fixtures still cover shapes Excel will not author, and for those the same reading of the specification produces both the bytes and the expectation, so they catch a decoder that contradicts the specification but not a misreading shared by both sides. Breadth over arbitrary real-world workbooks is not graded, because the Excel-authored coverage comes from one producer version.

The VBA source-from-p-code line figure is measured on two committed modules, as stated above, not on every VBA project. A Ptg token the XLM decoder does not recognize is emitted as an explicit unknown marker rather than a fabricated formula.

Containers and archives

Before disrobe can decompile anything, it often has to get inside a container. The disrobe-binfmt layer detects every format below. Most use the generic member-byte extractor; LUKS1 uses a dedicated raw-volume-key route and reports a typed key wall when no key is supplied. Auto-detection, recursive chaining through nested layers, and shared zip-slip and decompression-bomb guards remain in effect. A committed input drives 41 generic extractors to member bytes on disk, and the tracked LUKS1 fixture separately proves byte-exact decryption into the VHD extractor.

At a glance

SurfaceSupport
Formats detected102 archive, installer, filesystem, firmware, and encrypted-volume formats; 101 use the generic extraction entry point and LUKS1 uses its bounded raw-volume-key entry point
Formats exercised41 of them are driven to member bytes on disk by an input this repository commits, measured by crates/disrobe-cli/tests/container_breadth.rs and pinned in crates/disrobe-cli/tests/golden/container_breadth.txt. The rest carry an extractor that no committed input reaches, so they are unverified rather than shown to fail and the declared roster is a capability list rather than a measurement
Carve engineA recursive carve-everything scan for every known magic, modeling chunked payloads, recursing by depth, and using entropy to separate code from padding
NestingContainer-in-container chaining, governed by --max-depth (default 8)
Directory inputBatch-processed recursively, bounded by --batch-max-depth
GuardsPer-entry and aggregate size caps, recursion-depth cap, zip-slip path sanitization, on every format

Supported formats

CategoryFormats
Archives and installersZIP (incl. ZIP64 + AES), tar.gz / tar.bz2 / tar.xz / tar.zst, 7z, RAR4 and RAR5 (stored members from both; RAR5 LZ "normal" method decoded in-tree; RAR 2.9/3.x LZ, PPMd, mixed LZ and PPMd blocks, and canonical delta and x86 filter programs decoded in-tree, with every decoded member checked against the CRC-32 its header declares), .cab, MSI, MSIX / APPX, NSIS (solid and non-solid), Inno Setup 4.0.9 through setup-data profile 7.0.0.3 (stored, zlib, BZip2, LZMA1, and LZMA2 members, including solid groups), InstallShield legacy cabinets (ISc( majors 0 and 5: stored members, u16-length chunk-framed raw DEFLATE, and full-flush raw DEFLATE, with obfuscated members decoded before inflate), .deb, .rpm (metadata), AppImage Type 1 (ISO 9660, Rock Ridge, and zisofs) and Type 2 (SquashFS), Flatpak, Snap
Bare compression streamsgzip, bzip2, zstd, lzma, lzip, lz4-frame, zlib, .Z (Unix compress)
Legacy archivesar, arj (methods 0-4 decoded, with header and member CRC32 verified; split volumes refused by name), arc (methods 1-9 decoded; independent byte checks cover methods 2 and 5-9; methods 8-9 use grouped dynamic LZW with exact declared-size and CRC checks; methods 10-11 refused), LZH header levels 0-3 (-lh0- through -lh7-, -lhx-, -lz4-, -lz5-, -lzs-, and -pm0- decoded; -lhd- directories retained; -lhd- symbolic links refused; -pm1- and -pm2- decoded in tree and reaching extraction, each member checked against the CRC-16 its archiver stored; byte, code-page, and UTF-16 paths recovered; missing split volumes refused), lzop, FreeBSD uzip, Xamarin xalz, par2, ELF appended-overlay carve, StuffIt (classic stored, method 2, method 5 LZAH and method 13 forks decoded with record-header and fork CRC validation; StuffIt 5 containers parsed and their Arsenic and stored forks decoded in tree, though extraction still carves a StuffIt 5 archive rather than writing its forks; classic methods 6 and 8 and StuffIt 5 method 14 are not implemented and refuse by name, because no archive using them was found to grade a decoder against; encrypted forks refused), partclone (decoded)
Embedded-linux filesystemssquashfs, cramfs, ext4, romfs, minixfs, jffs2, UBI + UBIFS, yaffs, erofs (full and compact indexes with lz4, deflate, zstd, and microlzma decoded), NTFS, android-sparse, btrfs-send
Disk images and partitionsGPT and MBR (partition tables parsed; each partition carved and recursed in-tree), VHD (fixed and dynamic BAT), VHDX (region table + BAT; logical disk materialized from the block-allocation table, then partition-carved and FAT12 / 16 / 32 walked to pull individual stored files), WIM (header resources with XPRESS / LZX / LZMS chunk payloads decompressed in-tree), FAT12 / 16 / 32 (boot sector, FAT chain walk, root and subdirectory traversal)
Encrypted volumesLUKS1 with aes-cbc-plain, a 128-, 192-, or 256-bit raw volume key, and a SHA-1, SHA-256, or SHA-512 PBKDF2 header digest. The raw key is read from a bounded file or standard input, verified against the header digest, and zeroized after use before the decrypted payload enters the normal container pipeline. Keyless extraction succeeds with a typed wall that names the cipher, mode, digest, iteration count, and missing raw volume key
Apple.dmg (UDIF: koly trailer + blkx mish chunks; ADC / zlib / bzip2 / LZFSE / LZMA chunk decoders; then HFS+ catalog walk extracts individual files, all in-tree), .pkg (xar TOC + gzip / bzip2 heap, extracted in-tree)
Vendor firmwareD-Link (SHRS / encrypted-img AES / alpha / fpkg), EnGenius XOR, Autel ECC table, QNAP PC1, plus CRC-verified Netgear (chk / trx), Xiaomi, Tesla, HP, Moxa, INSTAR, and Airoha carves; OTP-AES Airoha firmware is an information-theoretic wall and is carved verbatim
Standalone executablesBun --compile binaries (embedded JS module graph + sourcemaps), Unity AssetBundle (UnityFS), .NET single-file bundles (majors 1, 2 and 6; embedded assemblies routed to the CIL decompiler, native entries to the native pass)
App / runtimeElectron .asar, Docker image tarball, OCI image manifest + layers, ISO 9660 with Joliet fallback, Rock Ridge names, ordered multi-extent files, and zisofs v1 (extracted in-tree)

Commands

Most extraction happens implicitly inside disrobe auto, which detects a container, extracts it, and recurses into the contents. Archive-shaped inputs are also available directly:

disrobe py extract package.whl --out extracted/
disrobe auto installer.msi --out extracted/
disrobe auto firmware-dir/ --out extracted/ --batch-max-depth 6
disrobe extract crash.dmp --out carved/
disrobe extract volume.luks --luks1-raw-volume-key-file volume.key --out decrypted/
disrobe extract volume.luks --luks1-raw-volume-key-file - --out decrypted/

Directory inputs are batch-processed recursively; --batch-max-depth limits directory descent. Container nesting inside a detected artifact is governed by --max-depth (default 8).

For ZIP, TAR, ARC, RAR, ARJ, LZH, StuffIt, Inno Setup, InstallShield, RPM, AppImage, .NET single-file, EROFS, and UEFI firmware-volume inputs, disrobe extract and disrobe auto apply the same default quota policy and retain the same ordered, byte-identical archive members. Extractor-generated sidecars are tagged separately and remain direct-only; an archive-controlled name such as .disrobe-user.bin remains an archive member. When an independently indexed member has an unsafe path, exceeds a quota, or cannot be decoded, extraction skips it and continues with trustworthy siblings. The direct result records the ordered reasons in integrity_violations; the automatic chain writes the ordered prefix that fits the 64 KiB container.refusals bound. container.refusals_total, container.refusals_omitted, and container.refusals_truncated make any truncation explicit. A malformed directory or table that cannot identify the next member safely still refuses the whole container.

Coverage and fidelity

Windows crash dumps

disrobe extract crash.dmp (or disrobe auto crash.dmp) carves the loaded PE modules out of a Windows minidump. It parses the stream directory, reads the module and memory lists, and for each module rebuilds an in-memory PE image by copying whatever memory the dump actually captured into a buffer at the correct RVAs, rewriting each section's file-offset field to match its virtual address so downstream PE parsers read the result as a well-formed image. Coverage is reported per page: the summary records how many bytes were recovered, which ranges the dump truncated, and which it never captured, with a reason for each gap, so a partially captured module is never presented as complete. The carve is graded by wrapping a real on-disk PE into a minidump and confirming the carved .text comes back byte-identical (minidump_real_pe.rs); each carved module and a .disrobe-minidump.json coverage summary land in the output directory.

Deno eszip archives

The disrobe-binfmt eszip reader (disrobe_binfmt::containers::eszip) parses a Deno eszip module-graph archive, versions 2 through 2.3, including one embedded inside a deno compile standalone executable, and reconstructs the module graph: each module's specifier, kind, and source bytes, plus redirects and npm specifiers, with per-module source-hash verification that drops any module whose stored hash does not match. It is exercised by a build-then-parse round-trip that also confirms a corrupted source hash is rejected.

Safety guards

Every extractor shares the quota machinery in crates/disrobe-binfmt/src/quota.rs:

  • Per-entry size cap and aggregate size cap defuse decompression bombs.
  • Recursion-depth cap defuses container-in-container bombs.
  • Zip-slip path sanitization (sanitize_entry_path): every entry path is sanitized so no extraction can escape the output directory, on every format.

Bypasses of any of these are treated as security issues; see the security policy.

Limits

Where a format's payload is not decoded in-tree, the table above names it per entry rather than implying full extraction:

  • LUKS1 recovery is limited to aes-cbc-plain with a caller-supplied raw volume key. Passphrases and keyslot unlocking, XTS modes, and LUKS2 are not accepted by this route. A detected keyless LUKS1 header produces a successful typed wall. A detached LUKS1 header is refused until its separately stored encrypted payload is supplied. VeraCrypt and TrueCrypt may not be identifiable without a key, and headerless dm-crypt has no on-disk header to detect.
  • RAR 2.9/3.x members carry their transforms as RARVM programs. disrobe identifies a program by exact length and CRC-32 and runs a native transform for the canonical delta, x86 e8, x86 e8/e9, itanium, rgb and audio programs. It does not interpret RARVM bytecode, so a member carrying any other program is refused by name. The corpus grades the delta and x86 e8/e9 transforms against a real archive. The plain x86 e8, itanium, rgb and audio transforms have no real archive in the corpus yet, so their output rests on the CRC-32 check every decoded member passes and on known-answer tests rather than on a graded fixture. The crate publishes this gap as RAR3_FILTERS_WITHOUT_REAL_ARTIFACT and RAR3_FILTER_COVERAGE_NOTE in disrobe_binfmt::containers::rar. Encrypted members, multivolume continuation, and solid state carried from an earlier entry are refused by name.
  • Inno Setup follows finite version profiles from the loader through both metadata blocks, file and data records, solid groups, filters, and checksums. Unsupported profiles and encrypted content without a secret refuse by name.
  • ARC methods 10-11 are refused.
  • StuffIt 5 containers are parsed and their forks decode in tree, but extraction still carves the archive verbatim rather than writing each fork, so disrobe auto recovers a StuffIt 5 archive as one blob. Classic StuffIt forks are unaffected and extract per fork.
  • OTP-AES Airoha firmware is an information-theoretic wall and is carved verbatim.
  • A minidump only contains the memory it captured. Truncated and never-captured ranges are reported with a reason per gap instead of being filled in.

CLI overview

Ecosystem command families usually nest an action:

disrobe <pass> <action> <input> [--out <path>] [flags]

Other operations take their arguments directly. Examples include auto, scan, query, taint, extract, webview, and report. Use disrobe --help for the complete surface compiled into your binary; the list changes when optional build features change.

Discovering the surface

disrobe --help                # every subcommand
disrobe <pass> --help         # actions and flags for one pass, e.g. `disrobe py --help`
disrobe passes                # direct recovery families plus auto-chain pass IDs
disrobe catalog [ecosystem]   # supported families and recovery tiers
disrobe explain DR-CLI-0030   # look up any error code

Subcommand inference is enabled: unambiguous prefixes work (disrobe dec ... resolves if only one subcommand starts with dec).

Output formats

The CLI accepts these global output flags. A command can reject a format that does not fit its output contract:

FlagOutput
(default)Human-readable text
--jsonA single structured JSON document
--ndjsonNewline-delimited JSON (streaming)
--sarifSARIF 2.1.0, for GitHub code scanning and other SARIF consumers
disrobe scan firmware.bin --sarif > findings.sarif

The standard recovery loop

disrobe auto input.bin --out recovered/ --capture-stages   # recover
disrobe status                                              # what landed in ./out/
disrobe context --out recovered/                           # per-pass verdict + confidence
disrobe verify recovered/final/*.dr                        # check envelope integrity

The next pages cover global flags in full, the complete command reference, the diff and guard tooling, and the daemon surface.

Global flags

These flags are accepted on every subcommand (they are declared global = true on the root parser).

Output and verbosity

FlagEffect
-v, -vv, -vvvIncrease log verbosity.
-q, --quietSuppress non-error output.
--color <auto|always|never>Control ANSI color in terminal output.
--jsonEmit a structured JSON document instead of human text.
--ndjsonEmit newline-delimited JSON (streaming).
--sarifEmit SARIF 2.1.0 (GitHub code scanning, etc.).
--progress <auto|always|never>Progress-bar rendering. auto renders only on a TTY.

Execution control

FlagEffect
--config <PATH>Load a .disrobe.toml config file. Without it, disrobe walks up from the CWD to discover one. See project configuration.
--in-placeRewrite the input file in place.
--forceOverwrite existing outputs without prompting.
-j, --threads <N>Worker thread-pool size (defaults to detected CPU count).
--no-cacheBypass the .dr envelope cache. Output is identical with or without this; it is a performance toggle, not a correctness one.
--dry-runReport what would happen without writing any output.

Debug tracing

VariableEffect
DISROBE_DEBUG=<area>Stream every offset, size, candidate, and classification a pass walked to stderr. Takes all or a comma-separated area list.
DISROBE_DEBUG_FORMAT=jsonEmit one JSON object per debug event instead of human text.

Debug tracing is sample-sensitive. Only values emitted through explicitly guarded key/value events pass through the masking guard. After trimming surrounding whitespace, it masks a value only when the value is at least 20 characters, contains only ASCII letters, digits, _, -, +, /, or =, and mixes at least two of uppercase letters, lowercase letters, and digits. When the guard triggers, it preserves the first four trimmed characters and emits …<redacted N chars>, using a single Unicode ellipsis and the full trimmed character count. Ordinary messages, unguarded values, labels, and byte previews do not pass through this guard and may expose sample-derived data in either format. Review debug logs before sharing them.

This does not change scanner output: scan and frisk report full matched values by default. Pass --redact to replace detected secrets with stable truncated SHA-256 tokens in shareable output. The same setting can be stored as output.redact = true in .disrobe.toml.

Metadata sidecar flags

The metadata bundle flags are also global. They are accepted everywhere, but only the commands listed in which commands write a bundle act on them; every other subcommand ignores them, and disrobe auto rejects them with DR-CLI-0843. See metadata sidecar and provenance for the complete pack/category model. Summary:

FlagEffect
--llmCompatibility alias for --metadata-pack-4 (full bundle, auth-gated categories included).
--metadata-pack-1Pack-1: ast + disasm + symbols + strings.
--metadata-pack-2Pack-2: pack-1 + cfg + types + imports + provenance.
--metadata-pack-3Pack-3: pack-2 + dfg + signatures + constants + roundtrip + sourcemap + manifest.
--metadata-pack-4Pack-4: pack-3 + confidence + opcode-coverage + pii-map + decryption-keys. Only decryption-keys needs --i-have-authorization.
--ast, --disasm, --cfg, --dfgAdd individual AST / disassembly / CFG / DFG categories.
--symbols, --strings, --types, --importsAdd symbols / strings / recovered-types / imports categories.
--constants, --signatures, --provenanceAdd constants / function-signatures / provenance categories.
--roundtrip-verdict, --source-map, --manifest-catAdd roundtrip-verdict / source-map / manifest categories.
--confidence, --opcode-coverage, --pii-mapAdd confidence-scores / opcode-coverage / pii-map categories.
--decryption-keysAdd decryption-keys category (requires --i-have-authorization).
--metadata-include <cats> / --metadata-exclude <cats>Toggle comma-separated categories after applying a pack preset.
--metadata-out <PATH>Override the bundle output path (default: <stem>.disrobe.llm.json next to the primary output).
--metadata-format <json|jsonl|cbor|msgpack>Bundle serialization format (default json).
--llm-briefsAlso emit AGENTS.md and SKILL.md reconstruction briefs next to the bundle.
--i-have-authorizationUnlocks auth-gated metadata categories and recovery paths that expose this gate.

The authorization gate

--i-have-authorization is the explicit assertion used by legally sensitive paths that expose an authorization gate. The decryption-keys metadata category refuses without it (DR-CLI-0420); language-specific commercial-protector paths document their own gate behavior. Passing the flag is your assertion that you are authorized to analyze the input under the statutory framing in LEGAL.md.

Command reference

The authoritative source is always disrobe <command> --help for the binary you are running. This page maps the public command surface. Output paths and --emit support are command-specific; see the global flags for the options parsed at the top level.

Python

CommandPurpose
disrobe py decompile <pyc>Decompile a .pyc to source. --backend native (the only supported value). --no-roundtrip skips the recompile-equivalence check.
disrobe py disasm <pyc>Per-instruction disassembly (1.0-3.15 + PyPy/MicroPython/Jython/IronPython/Brython).
disrobe py deob <src>Peel a source obfuscator. --cleanup runs a ruff-AST fold.
disrobe py extract <archive>Extract a wheel / sdist / egg / .whl / .zip / any archive.
disrobe py sourcedefender <pye>Decrypt a SourceDefender .pye envelope.
disrobe pyarmor unpack <py>Unpack PyArmor v6-v9-pro. --allow-dynamic permits the dynamic-hook fallback (trusted/sandboxed samples only). --dynamic-timeout <SECS>. --mode auto|standard|super. --target <PYVER>. --allow-bcc. --strict. --no-cextract / --cextract-only. --all-emits writes stubs for all 12 emit kinds. --cache <DIR>.
disrobe pyinstaller extract <exe>Extract a PyInstaller build (2.x-6.20+, AES decrypt).
disrobe pyinstaller detect <exe>Report cookie / Python version / TOC offsets without extracting.
disrobe pyfreeze extract <exe>Extract cx_Freeze / py2exe / shiv / pex / PyOxidizer (experimental, unvalidated) / Briefcase.
disrobe pyfreeze detect <exe>Identify the freezer without extracting.
disrobe nuitka detect|extract|symbols|decompile|const <input>Nuitka flavor detect, --onefile extract, symbol scan, constants decompile, single .const decode.

JavaScript / WebAssembly

CommandPurpose
disrobe js deob <js>Deobfuscate (obfuscator.io, JS-Confuser, Jscrambler, esoteric encoders).
disrobe js unbundle <js>Split a bundle into per-module sources; the unbundler routes 11 catalogued bundler families.
disrobe js v8 <blob>Inspect V8 .jsc / Node SEA / nexe / nw.js / Electron .asar.
disrobe webview <binary>Statically recover Electron ASAR or embedded Tauri/Wails HTML, JavaScript, CSS, and assets to --out <DIR>. The standard CLI exposes this as a direct command rather than an auto pass. See Webview desktop frontends.
disrobe wasm decompile <wasm>Lift to --target json|rust|ts|wat|c.
disrobe wasm deob <wasm>Reverse Wasm obfuscator families.
disrobe wasm component <wasm>Parse a Component Model envelope.
disrobe wasm types <wasm>Recover the GC type graph.
disrobe wasm lift-gc <wasm>Lift the recovered GC type graph to typed Rust + TypeScript struct / array source.

JVM / Android / .NET

CommandPurpose
disrobe jvm decompile <class|jar|dex|apk>Run the in-house classfile, jar, DEX, or APK recovery path and optionally invoke an installed backend selected by --backend auto|cfr|vineflower|procyon|jd|krakatau|jadx|dex2-jar. auto chooses an applicable installed backend when one is available. Jar, DEX, and APK inputs emit in-house source by default; classfiles require --emit source. For a standalone DEX, --format ghidra|ida|json also writes its class, method, and field identifiers through the shared symbol exporter. The script formats find each original owner, member name, and descriptor in the loaded DEX database before applying the replacement name. IDAPython class renames require IDA 9.4 or later; method and field renames use older DEX record APIs.
disrobe jvm dex2-jar <dex>Translate a standalone DEX to deterministic .class files and classes.jar with Disrobe's in-house translator. This command does not invoke d2j-dex2jar; disrobe jvm decompile <dex> --backend dex2-jar remains the separate external-backend route.
disrobe jvm extract <jar|apk>Extract container + dump classfile inventory.
disrobe jvm backendsReport JVM/Android backends on PATH.
disrobe jvm retraceRetrace an obfuscated stack frame back to class/method/line through a ProGuard/R8 mapping.txt (--mapping, --class, --method, --line).
disrobe jvm jni <class|jar|dex|apk|aab|aar|apks|oat>Link declared native methods across the DEX/classfile <-> .so/.dll/.dylib JNI boundary: static Java_ symbol matching, RegisterNatives triple recovery, and C prototype emission. --native <LIB> supplies a native library, or a split .apk/.apks, for a bare class/jar/dex/oat (repeatable); a self-contained apk/aab/aar/apks carries its own. An .aar scans jni/<abi>/*.so and its nested classes.jar. A raw .oat file locates its single embedded dex through the OAT header; a multi-dex .oat is refused rather than guessed. --json for machine output.
disrobe apk <apk>Decode the binary AndroidManifest.xml, map resource ids to names, dump each signer certificate's SHA-256, and link the embedded DEX against its embedded native libraries (the same JNI surface jvm jni prints). --out <DIR> writes the decoded manifest and resource table to disk.
disrobe dotnet decompile <dll|exe>Run the in-house CIL decompiler and optionally invoke --backend auto|ilspy|dnspy|dnspy-ex|de4dot. If a requested tool is unavailable, the current selector falls back to the first installed backend in the auto order. --language csharp|fsharp|vbnet selects the in-house surface language.
disrobe dotnet deobfuscate|peel <dll|exe>Detect the .NET protector and peel it: decrypt resources, recover constants/strings, classify renamable identifiers, strip watermarks. --protector <name> forces one.
disrobe dotnet analyze <dll>PE/CLR metadata, protector detection, R2R + NativeAOT probe.
disrobe dotnet native-aot <image>Recover NativeAOT names, types, method boundaries and managed signatures. Takes a PE, ELF or Mach-O image, not a managed assembly.
disrobe dotnet backendsReport .NET backends on PATH.

Native

CommandPurpose
disrobe native decompile <bin>In-tree x86-64 -> C/Rust decompile and AArch64, ARM32 and MIPS32 -> pseudo-C decompile, default (--backend native --format c|rust). The manifest names the architecture it routed to in its architecture field. Only AArch64 has a whole-program engine and an image-leaf engine; ARM32 and MIPS32 recover each function through the p-code lift alone, so their functions_whole_program and functions_image_leaf counts stay zero. AArch64 whole-program call resolution is limited to linked ELF inputs; relocatable objects fail before output on every p-code architecture. --format rust and the types.json sidecar are x86-64 only. C output is graded against real gcc/clang and x86-64 Rust output against rustc. On the AArch64 path a symbolic devirtualizer folds proven-dead conditional arms before structuring (on by default; --no-devirt disables it). --backend ghidra drives ghidra-headless instead: --emit source,disasm,ast,cfg,ir,manifest,sourcemap,symbols,strings,imports,signatures,report. Every function recovered through the p-code lifter carries a decode object in the JSON result, and the run as a whole carries the same shape as decode_coverage. Both give decoded_instructions, the machine instructions the decoder split the code into; modelled_instructions, how many of those the lifter gave semantics to; unmodelled_instructions, the remainder; matched_instructions with matched_percent, counting every instruction the decoder recognised, which is the sum of the supported, callother and unsupported statuses; semantic_percent, the share that is supported alone; instructions_emitting_callother with its percent, counting instructions whose lifted body contains a callother operation, which is a different measure from the callother status and can differ from it in both directions; by_status, one entry per decode status with an instruction count and a percent of decoded, always listing all seven statuses so one that never occurs reads as zero rather than being absent; and unlifted_mnemonics, a map from mnemonic to occurrence count. matched_percent and semantic_percent do not sum to one hundred, and counts_note restates why in the output itself. A high matched figure beside a low semantic figure means the decoder recognised the instructions but the recovered source describes less of the function than its length suggests. The whole-run block adds lowest_covered_functions, the functions whose semantic share falls below the whole-run figure, worst first and tied names in alphabetical order, capped at ten with lowest_covered_functions_omitted counting any the cap dropped, so a high whole-binary figure cannot hide one badly covered function. Every percent is a string carrying exactly two fractional digits, so the human and JSON output agree digit for digit and no float printer can reformat them.
disrobe native symbols <bin>Dump symbols, sections, segments, imports, and debug info.
disrobe native identify <bin>Fingerprint compiler / packer / protector / installer, each routed to its pass.
disrobe native unpack [bin]Detect + unpack the 12 Implemented-tier families (Donut, sRDI, UPX, ASPack, Petite, MPRESS, FSG, PECompact, Yoda's Crypter, NSPack, MEW, kkrunchy) via in-house decoders + x86 stub emulator. Input is optional; --list shows all supported packers (the full detect catalog is 27 packers and protectors; Yoda's Protector needs the original image for a diff-based carve, and the commercial protector tier is reported without static recovery). When the image matches a known layering, such as UPX repacked inside ASPack, the command says so before it unpacks, names the layers in order with the confidence of the match, and prints why that combination is used. The notice appears even when the unpack itself cannot finish, which is the case where knowing the image is double-packed matters most. After a successful unpack it names the layers still present, because one pass removes one layer; run the command again on the recovered image to take the next one.
disrobe native devirt <bin>Devirtualize the bytecode-VM tier: recover the handler table, lift to a re-executable IR + pseudo-code.
disrobe native export <bin>Unpack, recover symbols, and export a backend-ready bundle: a rebuilt loadable PE + a Ghidra post-script / IDAPython / JSON symbol map. --format ghidra|ida|json (default ghidra).
disrobe native disasm <bin>Per-function listing / --emit cfg-dot CFG / --emit json / --raw linear sweep (--syntax nasm|intel|att|masm). Accepts a .dr envelope.
disrobe native callgraph <bin>Whole-program call graph as Graphviz DOT.
disrobe native patch <bin>Rewrite bytes at a VA (or nop a span) and revalidate the image.
disrobe native sigmaker <bin>Wildcarded byte signature from a function, uniqueness-tested.
disrobe native diff <a> <b>Match functions across two builds by content and CFG fingerprint. Native images only; for other languages use disrobe semdiff. Text output lists at most 25 added, removed, and changed rows by default. --limit <N> sets that shared listing limit for text, JSON, NDJSON, and SARIF; --limit 0 preserves the exact totals while withholding every row. Machine output remains complete when --limit is omitted. The listing object records the effective limit plus the exact shown and withheld counts.
disrobe native match <a> <b>Name the counterpart of every function across two stripped binaries: anchor on shared data references, then on a control-flow fingerprint, then propagate along the call graph. Each pair carries the stage and the evidence that produced it, and a refusal is reported with its candidates. --out <FILE>, or -o, writes the report as JSON. --limit <N> bounds the text listing, 25 by default, and also bounds the report when you give it; --limit 0 prints the counts and withholds every row. --stage data-reference|control-flow|propagation|refused selects one stage; without it the listing shows every matched row from A and the ambiguous rows from B, and the report keeps every verdict. --function <ADDRESS> prints one function's correspondences from both sides with their full evidence; it accepts decimal or 0x hex, cannot be combined with --stage, ignores --limit, and fails with DR-NATIVE-0208 when neither input carries that address. Every report row names the side it came from, and the listing block records the selection in force with the number of rows shown and withheld. This command is reachable only as a subcommand and is deliberately absent from disrobe auto: it answers a pairwise question about two inputs, while auto drives a chain over one sample, so there is no input for a second binary to arrive through. Use disrobe semdiff when the two builds are of the same program and you want correspondence by what each function computes.
disrobe native entropy <bin>4KB sliding-window Shannon entropy; ASCII heat-strip + byte histogram + packed-region runs. --format text|json|svg (default text), --svg <out> for a dark-theme entropy map with section overlays.
disrobe native signatures <bin>Crypto-constant fingerprints (AES, SHA, ChaCha20). --flirt <sig> to match a FLIRT DB.
disrobe native fingerprint <bin>Aggregate crypto-constant + FLIRT + string-xref sidecar at .disrobe/fingerprints/<stem>.json. --flirt <sig>.
disrobe native sbom <bin>Emit a CycloneDX 1.5 SBOM from embedded cargo-auditable metadata. Pass --format spdx --timestamp YYYY-MM-DDTHH:MM:SSZ for SPDX 2.3 JSON. Both formats use the same component discovery result. SPDX output records the application and Cargo packages, their checksums and package URLs, and CONTAINS relationships. Unknown supplier and licence fields are omitted.
disrobe native graph <bin>Import/export table as Graphviz DOT.
disrobe native pdb <pdb>Read build provenance out of a Windows PDB: DBI stream version, GUID and age, and for every module the compiler records the linker wrote, with language, target machine, frontend and backend version, and the security-check, hot-patch and profile-guided optimization flags. Also lists the tool path, working directory, source path, command line, object path and environment strings each module recorded, keeping the raw bytes beside the text so a non-UTF-8 value is still readable. Writes <out>/<stem>.pdb.json.
disrobe query <bin|.dr> <q...>Queryable IR: functions, calls-to <sym>, xrefs-to <sym>, string-decoders, complexity-over <n>, capability <network|crypto|filesystem|process>. Accepts a raw binary or a Disasm- or Mir-rung .dr envelope.
disrobe capabilities <bin|.dr>Rule engine over the IR, mapping behaviors to MITRE ATT&CK + MBC with per-match evidence.
disrobe taint <input>Track a value from source calls to sink calls across the normalized IR (native / wasm / JVM / Dalvik / CIL / ABC / Ruby / Lua / BEAM / .pyc / .dr). --source <SYM> / --sink <SYM> override the built-in source/sink sets (repeatable). With --cfg, --dfg or a pack that names them it also writes the control-flow and data-flow metadata categories; see metadata sidecar.
disrobe semdiff <base> <other> Pair the functions of two builds by what they compute, not by name. Lifts both inputs to the normalized IR, matches leaf functions on an exact structural signature, then on a symbolic summary, then propagates along the call graph. Reports each pair with the tier that produced it and each refusal with a typed reason: no-candidate, ambiguous, round-budget-exhausted, function-count-cap-exceeded, duplicate-address, source-language-mismatch. Accepts every input the lifters accept (native / wasm / JVM / Dalvik / CIL / ABC / Ruby / Lua / BEAM / .pyc / .dr), which is the difference from native diff and native match: those read native images only. Inputs that lift to different source languages are refused per function with source-language-mismatch. Every native image lifts under one source language whatever its architecture, so that per-function guard cannot separate them; two native images of different machine architectures are instead refused up front with DR-CLI-0874. Pass --lineage with two or more OTHER builds to track each base function across all of them at once: the report groups correspondences into families, one per base function, each carrying a verdict for every variant, and says how many families are present in every variant. Without --lineage exactly one OTHER is allowed and a second is refused with DR-CLI-0872 rather than ignored; --lineage accepts at most 32 variants and refuses more with DR-CLI-0873 rather than dropping any. --limit <N> bounds the rows listed per section, 40 by default; totals stay exact. Inputs over 256 MiB are refused with DR-CLI-0871.
disrobe vulnmatch <input>Match reachability-aware vulnerability rules against a PE, ELF, Mach-O, COFF, or Disasm/Mir-rung .dr envelope while preserving reachable, reachability-unknown, present, and confirmed states. Pass --openvex --author <IDENTITY> --timestamp YYYY-MM-DDTHH:MM:SSZ to emit OpenVEX 0.2.0 JSON. Reachable and confirmed findings map to affected; a proven unreachable finding maps to not_affected; all unresolved states map to under_investigation. The command never emits fixed because static reachability analysis does not prove remediation.

Other languages

CommandPurpose
disrobe go recover|info <bin>Go symbol recovery / build fingerprint.
disrobe lua decompile|deobfuscate|detect <chunk>Lua decompile / obfuscator peel / dialect detect.
disrobe php decode|deobfuscate|extract <input>Encoder decode / eval-chain peel / Phar extract.
disrobe shell deob|detect <input>PowerShell / Bash / Batch / VBA deobfuscate (Invoke-Obfuscation, Invoke-Stealth, Bashfuscator, ...) and dialect / family detect.
disrobe ruby decompile|detect <input>Ruby artifact analysis / flavor detection.
disrobe beam parse|lift|disasm <beam>BEAM chunk parse / Core Erlang lift / Code disasm.
disrobe pickle disasm|decompile|safety|trace|polyglot|ml-detect <input>Pickle static analysis suite.
disrobe swift classdump|shield-undo|xor-decrypt <input>Swift/ObjC class-dump, SwiftShield mapping parser, explicit-key XOR blob decode.
disrobe macho dump|classdump|fat <input>Mach-O dump including .ipa; raw thin/fat Mach-O class-dump and slice inspection.
disrobe as3 disasm|tags <swf>AS3 DoABC disasm / SWF tag list.
disrobe hermes decompile|disasm|info <bundle>Hermes JS-surface lift / disasm / header.
disrobe flutter dump|decompile|kernel|disasm|map|inventory|inventory-standalone <input>Flutter Dart AOT + kernel inspection. flutter dump --format ghidra|ida|json emits recovered function names through the shared symbol exporter. --engine-symbol-map <path> adds externally supplied engine names only after the map's GNU build ID and bounded addresses match the ELF input. inventory and inventory-standalone recover the full library/class/method/field declaration graph on a pinned Dart snapshot version.
disrobe mobile detect|extract|hermes|flutter|recon <input>Mobile runtime pipeline.

Chain, envelope, and forensics

CommandPurpose
disrobe detect <input>Run every obfuscator/packer catalog detector against a file and report each hit (pass, obfuscator, confidence, markers).
disrobe identify <input>Fingerprint the compiler / linker / packer / protector / installer of a PE / ELF / Mach-O with structural evidence and the pass that handles each (top-level shortcut for native identify; alias die). --coverage adds a byte-accounting map of the file.
disrobe identify <input> --coverageAccount for every byte of a PE / ELF / Mach-O against the structures its format declares. Reports claimed_bytes (bytes a declared structure covers), slack_bytes (alignment padding between structures), unclaimed_bytes (bytes no structure covers, which is where an appended overlay shows up), truncated_bytes (bytes a structure declares past the end of the file), and coverage_ratio, which is claimed_bytes over the file length. complete is false when any byte is unclaimed or missing. The regions list tiles the whole file, so the region lengths sum to the file length. overlaps names each pair of structures that claim the same bytes. unbacked names a structure that declares a size the file does not store, and truncated names one whose declared end is past the end of the file.
disrobe catalog [ecosystem]List the supported obfuscator, packer, protector, freezer, and bundler registry by ecosystem. The default full build's live binary reports 170 families across 15 ecosystems. Builds that include the chain feature report the registry compiled into that binary. Filter with python, js, jvm, dotnet, native, go, wasm, ruby, lua, php, beam, as3, mobile, swift, or shell. --json emits { family_count, ecosystem_count, ecosystems[] }.
disrobe auto <input>Auto-detect + chain. --max-depth <N> (default 8), --capture-stages, --emit recovery, --dry-run, --redact. A run writes chain.json, recovery.json, anti-analysis.json, and report.json, the citable forensic summary that disrobe report <out-dir> renders. For a standalone DEX root, --format ghidra|ida|json adds exports/dalvik/symbols.ghidra.java, symbols.ida.py, or symbols.json without counting that presentation file as a recovered chain artifact. The request fails if the successful Dalvik node belongs to a nested container instead of the original input. --dry-run creates no export. --redact scrubs detected secrets from machine output and report files without changing recovered artifacts. A directory input is batch-processed recursively (--include <GLOB>, --exclude <GLOB>, --batch-max-depth <N>, --jobs <N>) into an aggregate manifest.json, and each per-file out directory holds its own report.json.
disrobe chain <input>Explicit pipeline. --chain 'auto:8' or 'pyarmor+py-decompile', --chain-pin <ver>, --capture-stages.
disrobe diff <left> <right>Structurally diff two chain.json documents (passes, stage BLAKE3 hashes, sizes, verdicts).
disrobe guard verify <subject> --reference <ref>Verify a subject chain.json's per-stage output hashes against a committed reference.
disrobe guard check <path> [--root <subtree>...]Deny writes to ground-truth stage paths (out/**/stages, out/**/final, .disrobe-stage-lock). --root adds extra protected subtrees (repeatable).
disrobe envelope create|inspect|verify|diff|migrate-check <dr>.dr envelope operations; migrate-check takes source and target envelopes.
disrobe verify <dr>Alias for disrobe envelope verify.
disrobe scan <path>Scan one file for secret values. --redact replaces detected values in text, JSON, NDJSON, and SARIF output. Raw values remain the default.
disrobe frisk <path> [--format text|json|sarif]Scan files, directories, APKs, and recovered source for secrets, endpoints, buckets, manifest exposure, and IOCs. Rule packs use --pattern <FILE>, suppressions use --suppress <SUBSTR>, and baselines use --emit-baseline / --baseline <FILE>. --redact replaces detected secret values after suppression and baseline filtering.
disrobe report <target>Render text, JSON, Markdown, HTML, or SARIF from a chain or batch result. --redact scrubs detected secrets throughout the rendered document.
disrobe prowl [target...]Harvest URLs and IOCs from public archives and threat-intel feeds. Inputs can come from arguments, --targets-file, --stdin, or --recon-input. Sources are wayback, commoncrawl, otx, urlscan, crtsh, urlhaus, threatfox, and virustotal (vt alias). Filters include --subs, --blacklist, --from, --to, --mc, --fc, --mt, --ft, --ioc, --fp, and --no-iocs. Network controls include --proxy, --timeout, --concurrency, --per-host-rps, --max-pages, --max-urls, --max-iocs, and --retries. Keys resolve from --api-key provider=key, provider env vars, a permissions-checked TOML file, or disrobe prowl keyring set|get|rm|list <provider>.
disrobe ioc <path> [--format text|json|sarif] [--defang]Extract indicators of compromise (URLs, IPs, domains, emails, paths, registry keys, wallets, crypto constants); decodes one base64/hex layer.
disrobe indicators <json...> [--targets-only] [--format text|json]Merge frisk, ioc, and prowl JSON artifacts into disrobe.indicators/v0, deduplicate by class and value, retain source provenance, or print only network targets for prowl --targets-file.
disrobe strings <path> [--min-len N] [--no-decode]Cross-format string extraction: ASCII + UTF-16LE, with single-byte XOR / base64 / ROT-n / stack-string deobfuscation.
disrobe behavior <path>Behavior / capability summary across 7 categories, tagged with MITRE ATT&CK technique ids. --effects adds a per-effect instruction census.
disrobe hermes disasm <input> --function <index-or-name>Print the instructions of one function by its zero-based index or exact name, decoded through the opcode table for the bundle's own bytecode version. Add --json for the same instruction list as a structured document. Duplicate names require an index. Without --function, the command keeps writing the whole-bundle summary document. An index past the end is refused with the number of functions the bundle declares.
disrobe behavior <path> --effectsLift the input to the intermediate representation and report, for each hard effect, how many instructions carry it. Covers memory reads and writes, stack and register and flag writes, syscalls, import calls, indirect calls and jumps, returns, exception raise and catch, atomic read-modify-write, fences, and privileged instructions. Each count is paired with the evidence that assigned it: encoding when the instruction itself says so, resolved-import or resolved-syscall when a resolved target says so, and unknown when nothing did. instructions and functions give the denominators, effect_free counts instructions with no hard effect, and unmodelled counts instructions the effect model does not cover, so a low unmodelled is what makes the other counts meaningful. Accepts any input the intermediate representation accepts, which today is WebAssembly, JVM class files, Dalvik, .NET, ActionScript, Ruby, Lua, BEAM and Python bytecode. An input that does not lift is refused with an error rather than reported as having no effects.
disrobe yara parse <path>Parse a YARA ruleset into a typed AST (read-only, no matching).
disrobe yara generate <input> [--name N] [--sha256 H] [--date D]Generate a candidate YARA rule from an artifact; output round-trips through the parser.
disrobe statusSummarize ./out/: per-stage counts, sizes, manifests.
disrobe context --out <dir>Summarize a recovery report (status, confidence, verdict, provenance).
disrobe context --out <dir> --fail-on <threshold>Grade the worst chain verdict under the directory and exit non-zero when it reaches never, incomplete, failed or any.
disrobe report <dir-or-input> [--format text|json|markdown|html|sarif]Consolidate a completed run (or raw input) into a forensic summary: identity, topology, per-stage verdicts/scores, artifact inventory, timings, the layers that stopped short with the input each one lacks, a cited byte range and blake3 digest for every artifact, and the steps to re-check them. --format html emits a self-contained, offline, dark-theme report (inline SVG bars, IOC + ATT&CK tables, XSS-escaped). --format sarif emits the same content as a SARIF 2.1.0 log that validates against the published schema: run.artifacts carries each digest, every result region cites a start offset plus a length in the artifact it indexes, and run.properties carries the disrobe document, a STIX 2.1 bundle, MAEC 5.0 behavior objects, the capability report, and the aggregated indicators. Text, json, markdown and html output is byte-identical across runs over one target; the sarif render differs only in generated_at, which follows SOURCE_DATE_EPOCH when that variable is set.

Workspace and meta

CommandPurpose
disrobe init [--ide <flavor>] [--force]Scaffold a .disrobe/ workspace and optional editor settings.
disrobe config [show]Print the resolved .disrobe.toml config (honors --json). See project configuration.
disrobe config init [--out <path>] [--force]Write a documented .disrobe.toml template.
disrobe annot refresh|regenerateRebuild a symbol annotation file.
disrobe rename <old> <new> [--note]Record an append-only rename.
disrobe passesPrint direct recovery-family summaries followed by the pass IDs reachable from disrobe auto in this build.
disrobe plugin run <component> --trusted-key <pubkey> --out <file> [--input <file>] [--fuel N] [--wall-deadline-ms MS] [--memory-cap-bytes N] [--format text|json]Verify and run a signed WebAssembly component plugin under the disrobe-plugin-host sandbox (fuel, wall-clock, and memory caps; deny-all imports). Reads input from stdin when --input is omitted. CLI-only: a plugin names an explicit local path an operator supplies, not a chain pass reachable from disrobe auto.
disrobe plugin verify <component> --trusted-key <pubkey> [--format text|json]Verify a signed WebAssembly component plugin's signature and capability manifest without running it.
disrobe plugin list <dir> [--trusted-key <pubkey>] [--format text|json]List plugin bundles (a <name>.wasm component beside its <name>.wasm.minisig signature and <name>.toml manifest) in a directory; verifies each bundle when --trusted-key is given.
disrobe explain <code>Look up a DR-* error code and print its description and common fixes.
disrobe doctor [--auto-install] [-y]Probe 46 to 51 optional external tools depending on the platform; report installed, missing, or stale. --auto-install installs every missing tool with a known install action and records every other missing tool as a skip with a typed reason (commercial-or-license-gated, ships-with-another-tool, platform-exclusive, preinstalled-by-the-operating-system, no-manager-models-its-install-path, or no-package-on-any-manager), in both text and --json output.
disrobe install <tool> [--list] [-y] [--dry-run]Install one optional tool via the native package manager.
disrobe install-deps [<dep>] [--all] [--dry-run]Install heavyweight deps (Ghidra) from upstream releases.
disrobe serve [--bind <ADDR>] [--stdio|--mcp|--grpc]Run the daemon. See the daemon.
disrobe completions <shell> [--install] [--rc-file <PATH>]Generate shell completions (bash, zsh, fish, PowerShell, elvish).
disrobe man [--out <dir>]Generate man pages (one .1 per subcommand).
disrobe bug-report [--out <PATH|->]Collect environment, manifests, and tooling versions into a markdown bug report.
disrobe self-update [--check-only] [--dry-run]Print self-update guidance (source-only distribution; no network by default).

Project configuration (.disrobe.toml)

disrobe reads an optional .disrobe.toml so a project can set its own defaults instead of repeating the same global flags on every invocation.

Resolution order

Values are merged from three layers, lowest precedence first:

  1. Built-in defaults: what disrobe does with no config and no flags.
  2. .disrobe.toml: the discovered or explicitly named config file.
  3. CLI flags: anything you type on the command line.

A flag you actually pass always wins over the config file; a flag you leave off falls back to the config value, and only then to the built-in default. "Did the user pass this flag" is decided by clap's value source, so there is no ambiguity between --json being absent and being false.

Discovery

  • With --config <PATH>, that file is loaded. A missing path is a hard error (DR-CLI-0332).
  • Without --config, disrobe walks up from the current working directory looking for .disrobe.toml, exactly the way cargo finds Cargo.toml. The first one found wins. If none exists, the built-in defaults are used.

Malformed TOML (DR-CLI-0330) and unknown keys are hard errors: a typo fails fast instead of being silently ignored.

The config command

CommandPurpose
disrobe config / disrobe config showPrint the resolved effective config and the file it came from. Honors --json.
disrobe config init [--out <PATH>] [--force]Write a fully documented .disrobe.toml template (default ./.disrobe.toml).

Schema

[output]
dir = "out"                  # default output directory for chain/auto runs
emit = ["source", "manifest"] # default --emit kinds where a pass accepts them
json = false                 # default machine-output toggles (CLI flags still override)
ndjson = false
sarif = false
color = "auto"               # auto | always | never
progress = "auto"            # auto | always | never
verbosity = "warn"           # warn | info | debug | trace
quiet = false

[execution]
threads = 8                  # worker pool size (default: detected CPU count)
force = false
in_place = false
no_cache = false
cache_dir = "/var/cache/disrobe"  # content-addressed .dr envelope cache (default: OS cache dir)
dry_run = false
seed = 42                    # RNG seed for non-deterministic backends
max_depth = 8                # default chain depth for `auto`

[backends]
py = "native"                # native (in-tree CPython 1.0..3.15 engine; the only supported value)
jvm = "cfr"                  # cfr | vineflower | procyon | jadx
dotnet = "ilspy"             # ilspy | dnspy | dnspyex | de4dot
wasm = "wat"                 # json | rust | ts | wat | c
lua = "native"

[passes]
enable = ["pyarmor.unpack", "py.decompile"]  # restrict chain runs to these passes
disable = ["native.packer-unpack"]           # never run these passes

All tables and all keys are optional. An empty file is valid and resolves to the built-in defaults.

Batch directory processing

disrobe auto accepts a directory as well as a single file. Point it at a directory and it walks the tree, runs the auto-chain on every file, writes each file's outputs under out/<relative-stem>/, and emits one aggregate out/manifest.json.

Single-file behavior is unchanged: disrobe auto <file> still writes chain.json + recovery.json into a single out dir.

Usage

disrobe auto ./samples
disrobe auto ./samples --out ./out/triage --include '**/*.pyc' --exclude '*_test.*' --jobs 4

If --out is omitted, batch output lands in ./out/<dir-name>-batch/.

Flags (batch-only)

FlagEffect
--batch-max-depth <N>Maximum directory recursion depth (default: unlimited). Depth 0 is the directory itself; depth 1 is its immediate children.
--include <GLOB>Only process files matching this glob. Repeatable. With no include, all files are in scope.
--exclude <GLOB>Skip files matching this glob. Repeatable. Exclude wins over include.
--jobs <N>Bounded worker concurrency. Default is 1, kept conservative because chains can be memory-heavy. Raise it on machines with headroom.

The --max-depth <N> (default 8), --capture-stages, --emit recovery, and global flags continue to apply. --max-depth is the per-file chain depth; --batch-max-depth is the directory recursion depth.

Glob syntax

Globs match against the slash-normalized path relative to the root.

TokenMatches
*Any run of characters within a single path segment (does not cross /).
**Any run including / (spans directories).
?Exactly one non-/ character.
[abc], [a-z], [!0-9]A character class, with !/^ negation and a-z ranges.

A bare pattern with no / (for example *.bin) also matches files in subdirectories, so the common "all .bin files" case works without writing **/.

manifest.json

Schema disrobe.batch.manifest/v1:

{
  "schema": "disrobe.batch.manifest/v1",
  "tool_version": "0.10.5",
  "root": "samples",
  "out_root": "out/samples-batch",
  "chain": "auto:8",
  "jobs": 4,
  "summary": { "processed": 12, "recovered": 9, "detect_only": 2, "errors": 1 },
  "entries": [
    {
      "input": "samples/app.pyc",
      "relative": "app.pyc",
      "size": 4096,
      "detected_format": "Python",
      "chain": ["py.decompile"],
      "verdict": "Complete",
      "recovery_score": 0.67,
      "output_dir": "out/samples-batch/app.pyc",
      "duration_ms": 31,
      "error": null
    }
  ]
}
  • recovery_score is the mean per-pass confidence-tier rank across the chain, normalized to [0, 1] (skeleton 0, partial 0.33, semantic 0.67, exact 1.0), or null when no pass ran.
  • A file that fails (unreadable, or its chain errors) is recorded with a non-null error and counted under summary.errors; one bad file never aborts the batch.
  • Files with no pass in their chain are counted as detect_only.

The human-readable summary line mirrors the manifest: N processed, M recovered, K detect-only, E errors.

Run reports (disrobe report)

disrobe report consolidates a completed run into a single forensic summary: input identity, chain topology, per-stage verdicts and recovery scores, the layers that stopped short, the recovered-artifact inventory, a cited byte range and digest for every artifact the run read, the steps that re-check each one, and timings. It is the read-side companion to auto and chain.

Usage

disrobe report ./out/sample-auto                 # a completed single-file run
disrobe report ./out/samples-batch               # a completed batch run
disrobe report ./malware.bin                     # raw input: runs auto first, then reports
disrobe report ./out/sample-auto --format markdown
disrobe report ./out/sample-auto --format html > report.html    # self-contained, offline
disrobe report ./out/sample-auto --format sarif > report.sarif

--out <DIR> chooses where a derived run is written when the target is a raw input or a raw directory. Without it the derived run lands in ./out/<stem>-auto/ or ./out/<name>-batch/ under the working directory. The flag has no effect when the target is already a completed run.

Target resolution

The single positional argument can be:

TargetBehavior
A directory with manifest.jsonRead it and render a batch report.
A directory with chain.json + recovery.jsonRead them and render a single-run report.
A raw input fileRun auto into ./out/<stem>-auto/ first, then report.
A raw directory (not an out dir)Run a batch into ./out/<dir>-batch/ first, then report.

A run document that is missing, unreadable, or truncated stops the command with a typed error. Nothing is partly rendered.

ConditionCode
The target path does not existDR-CLI-0350
chain.json cannot be readDR-CLI-0351
chain.json is not a valid chain documentDR-CLI-0352
recovery.json cannot be readDR-CLI-0353
recovery.json is not a valid recovery documentDR-CLI-0354
manifest.json cannot be readDR-CLI-0355
manifest.json is not a valid batch manifestDR-CLI-0356
A raw input file cannot be readDR-CLI-0358

The report a run writes

disrobe auto and disrobe chain write report.json into the output directory, beside chain.json, recovery.json, and anti-analysis.json. It holds the single-run document described below, so a run is citable without a second command. The document auto writes and the document disrobe report <out-dir> --format json prints are the same, except that the command's JSON adds a report_kind discriminator.

A batch run writes an aggregate report.json beside manifest.json; it is the same JSON document that disrobe report <batch-out> --format json prints. Each file also leaves its own single-run report.json in its per-file output directory.

Formats

--format text|json|markdown|html|sarif (default text). A global machine-output flag (--json, --ndjson, or --sarif) forces JSON output. --format sarif overrides that and renders SARIF.

  • text: an aligned human report for the terminal.
  • markdown: a report with tables, ready to paste into an issue or PR.
  • json: the machine-readable disrobe.report/v1 document.
  • html: a single self-contained HTML file (printed to stdout; redirect to a .html). CSS is inlined from the shared docs theme token file, with no JavaScript and no external/CDN reference, so it renders offline when double-clicked. Sections include input identity, a chain-topology flow, per-stage verdicts with generated recovery bars, a generated tier histogram, walls and failures, capabilities, recovered artifacts, the evidence table, the reproduction steps, and, when the input is still readable, defanged IOC plus behavior / MITRE ATT&CK tables. Every interpolated value is HTML-escaped, and the renderer uses no clock or randomness, so identical report data produces byte-stable HTML.
  • sarif: a SARIF 2.1.0 log printed to stdout. See SARIF output.

Single-run report contents

  • Input identity: path, size, BLAKE3, detected format chain, final format.
  • Topology and verdict: linear or tree, and the overall chain verdict.
  • Recovery score: the mean per-stage confidence-tier rank normalized to [0, 1], plus a tier label (skeleton / partial / semantic / exact).
  • Tier histogram: exact / semantic / partial / skeleton counts.
  • Per-stage table: index, pass id, confidence, score, duration.
  • Walls: every layer that stopped short, with the input it lacks.
  • Capabilities: ATT&CK- and MBC-tagged rule matches with addresses and evidence scope. Text, JSON, markdown, HTML, and SARIF consume the same result.
  • Failures: every layer that returned an error, with its message.
  • Recovered-artifact inventory: the union of artifact names produced by the stages.
  • Evidence: one cited entry per artifact the report read, with its digest and byte range.
  • Reproduction: the command that rebuilds the report and the steps that re-check it.
  • Notes: detect-only, skeleton-tier, and artifact-walk truncation caveats.

Batch report contents

  • The aggregate counts (processed, recovered, detect-only, errors) and mean recovery score. The mean is null when no file carried a score.
  • A per-file table: file, detected format, score, and status (recovered / detect-only / error).

A batch report aggregates per-file manifests and holds no analysis-target bytes. Its SARIF render therefore reports the STIX, MAEC, capability, and indicator blocks as unavailable, each with that reason.

Walls and failures

A wall is a layer that stopped because a named input is missing. A wall is not a failure and is never rendered as an error. Each wall records its kind, the node id, the stage index when the layer maps to one, the pass, the input format, the BLAKE3 and size of the artifact it could not advance, and a sentence naming what it lacked.

KindThe layer stopped because
no-pass-acceptedNo registered detector claimed the artifact.
empty-pass-outputA pass accepted the format and returned no output bytes.
repeated-artifactThe output repeats an artifact already seen on the branch.
depth-cap-reachedThe chain reached its depth cap.
not-executedThe run was a dry run, so the selected pass was never executed.
branches-incompleteAt least one branch of a fan-out did not reach a recovered format.

A layer that returned an error is recorded as a failure instead. A failure carries the node id, the stage index when the layer maps to one, the pass, the recorded message, and the BLAKE3 and size of its input.

When no individual node recorded a wall or a failure, the overall chain verdict still produces one wall against the root node if that verdict is stalled, cycle, cap-reached, dry-run, or fan-out-partial. A run that recovered nothing reports the wall rather than an empty success.

Evidence and digests

Every report cites the artifacts it read. An evidence entry carries a role, an artifact URI, a byte offset, a byte length, a BLAKE3 digest, and where that digest came from.

The roles are analysis-target, stage-input, stage-output, and recovered-artifact.

Digest sourceMeaning
chain-documentThe digest is the one chain.json recorded, for the analysis target or for a stage input or output.
recomputed-from-filedisrobe report opened the file in the run directory and hashed its bytes. The byte length is the file length.
unavailableNo digest could be produced. The entry carries an unavailable_reason naming why.

Only recovered artifacts read off disk carry recomputed-from-file. An artifact a dry run would have written, a file that is not on disk under the run directory, and a file that cannot be opened are each cited with unavailable and a reason rather than dropped from the report.

An artifact on disk is cited by a file:// URI, or by its percent-encoded relative path when the recorded path is relative. An intermediate the chain held in memory is cited as ni:///blake3;<digest>. That names the artifact a byte range indexes; it is not a file you can open.

Every evidence entry starts at byte offset 0 and spans the whole artifact. Sub-ranges inside the analysis target come from the indicator results in the SARIF render.

The recovered-artifact inventory combines the artifact names the stages recorded with the files under extracted/ in the run directory. That walk skips symbolic links, stops at directory depth 32, and stops after 4096 files. Either stop is recorded as a note in the report, naming what is not cited.

Reproduction steps

A single-run report carries the command that rebuilds it and the steps a third party follows to re-check it:

  1. Hash the analysis target with BLAKE3 and compare it with input.blake3.
  2. Hash each evidence entry marked recomputed-from-file and compare each digest with the recorded one.
  3. Read each ni:///blake3; evidence entry as the digest of an intermediate the chain held in memory.
  4. Re-run the reported command and compare the output.
  5. Set SOURCE_DATE_EPOCH to make the SARIF generated_at field byte-identical too.

When any entry carries no digest, a further step counts those entries and points at their unavailable_reason.

SARIF output

--format sarif prints a SARIF 2.1.0 log to stdout. Its $schema is the stable OASIS 2.1.0 Errata 01 URI, and the CLI test suite validates the emitted log against the matching schema vendored under crates/disrobe-cli/tests/schemas/.

run.artifacts is the artifact table. Each entry carries its URI, a description, the byte length when the report knows it, its SARIF roles, and a blake3 hash when the report has one. An entry with no digest carries no hash. The roles follow the evidence roles: the analysis target becomes analysisTarget, a recovered artifact becomes resultFile, and a stage input or output becomes unmodified.

Every result location names an artifact URI and its index in run.artifacts. A region cites a byte offset and a byte length. A span whose offset plus length would leave the artifact is dropped rather than cited, so a cited range always reads back inside the artifact it indexes.

Results carry one of these rule ids. A rule is declared in tool.driver.rules only when the run produced at least one result for it.

Rule idOne result per
disrobe.stageExecuted chain layer.
disrobe.wallLayer that stopped because a named input is missing.
disrobe.failureLayer that returned an error.
disrobe.evidenceCited artifact.
disrobe.indicatorValue read out of the analysis target.
disrobe.behaviorBehavior category matched in the analysis target.
disrobe.batch-fileFile of a batch run.

Only two results are reported as level: error with kind: fail: a failure, and a batch entry whose manifest recorded an error. A wall is level: none with kind: review, so a layer that stopped short never reaches a code-scanning gate as an error. A behavior is kind: review. An evidence entry whose digest is unavailable is kind: review. Every other evidence entry, every stage, every indicator, and every batch entry that ran is kind: informational.

An indicator result records the offset and length of the value inside the analysis target, and a range_within_target flag. When a recorded offset lies outside the target, the result is kept, the flag is false, and the message says the range lies outside the analysis target.

run.invocations[0].commandLine holds the reproduction command. executionSuccessful is false when a single run recorded a failure, or when a batch manifest recorded an error.

run.properties

KeyContents
generated_atThe one timestamp value the document uses.
disrobeThe disrobe.report/v1 document.
stixA STIX 2.1 bundle, or available: false with a reason.
maecA MAEC 5.0 package of behavior objects, or available: false with a reason.
capabilitiesThe capability report for the analysis target, or available: false with a reason.
indicatorsThe aggregated indicator bundle, or available: false with a reason.
reproductionThe command and the steps. Single-run reports only.
standardsThe standards this render targets, and the ones it excludes.

The STIX bundle carries an identity object for the tool and a malware-analysis object for the run. Its result field stays unknown, because disrobe performs static recovery and does not classify a sample. Identifiers are derived from the first 16 bytes of BLAKE3 over a stable seed, stamped with the RFC 9562 version 4 and variant bits, so repeated runs over one input produce one identifier.

URL, domain, IPv4, IPv6, email, and registry indicators become STIX indicator objects. Hash, ASN, wallet, path, secret, and other indicators have no STIX pattern object path. They are counted by class in standards.stix.unmapped_indicator_classes and stay in the SARIF results only.

The standards block records SARIF 2.1.0, STIX 2.1, MAEC 5.0, and CycloneDX 1.5, and names OpenIOC 1.1 and CybOX 2.x as excluded with a reason for each.

What the enriched blocks need

The STIX, MAEC, capability, and indicator blocks read the original analysis target. The report opens it at the path chain.json recorded, resolved against the working directory. Capability analysis is limited to 256 MiB and verifies the bytes against the recorded BLAKE3 before attaching results. If the chain document records no path, the sample has moved or changed, the target exceeds the limit, or you run the command from another directory, the affected block reports available: false and names the reason. The rest of the report still renders.

Determinism

Text, JSON, markdown, and HTML output is byte-identical across runs over one target.

generated_at is the only wall-clock field in the SARIF render, and every timestamp in that document holds its value. Two SARIF renders over one target therefore differ only in that value. Set SOURCE_DATE_EPOCH to a Unix timestamp to fix it. The SARIF render is then byte-identical too, and standards.timestamp.source reads source-date-epoch instead of system-clock.

Analysis-depth commands

A set of static-analysis primitives that make disrobe useful as a triage and reverse-engineering tool, not only a decompiler. Each one operates on raw bytes and (where relevant) on the strings/source a chain has already recovered, so they compose with the rest of the pipeline. None of them execute the sample.

CommandWhat it does
disrobe scanScan raw bytes for leaked credentials and secrets.
disrobe identifyFingerprint compiler, packer, protector, and installer.
disrobe prowlHarvest URLs and IOCs from public archives and threat-intel feeds.
disrobe iocPull indicators of compromise out of an artifact.
disrobe indicatorsMerge frisk, ioc, and prowl JSON into one versioned bundle.
disrobe stringsCross-format string extraction with single-byte XOR / base64 / ROT brute-forcing.
disrobe yara generateSynthesize a candidate YARA rule from an artifact.
disrobe behaviorSummarize what a binary does, tagged with MITRE ATT&CK technique IDs.

Credential scan

disrobe scan firmware.bin
disrobe scan firmware.bin --json
disrobe scan firmware.bin --sarif > findings.sarif

disrobe scan scans the target's raw bytes for leaked credentials: cloud provider keys (AWS, GCP, Azure, GitHub, Stripe, and others), VCS tokens, JWTs, PEM and SSH private keys, and other high-confidence secret patterns.

Unlike disrobe ioc, scan focuses exclusively on secrets that represent an immediate credential exposure rather than general network or host indicators. Output is text (one finding per line) or SARIF 2.1.0.

Format identification

disrobe identify sample.exe
disrobe identify sample.exe --json

disrobe identify fingerprints what built or packed a PE, ELF, or Mach-O binary. It reports the compiler, linker, packer, protector, and installer if detected, with structural evidence and the disrobe pass that handles each detected layer. The output is the same as disrobe native identify but works as a top-level command without routing through the native subcommand tree.

Prowl harvest

disrobe prowl example.com --subs --sources wayback,commoncrawl,urlscan --format json > prowl.json
disrobe prowl --targets-file targets.txt --proxy http://127.0.0.1:8080 --max-urls 50000
disrobe prowl --recon-input frisk.json --ioc domain,ipv4,email
disrobe prowl keyring set virustotal

disrobe prowl is the explicit network recon command. It queries Wayback, Common Crawl, OTX, urlscan, crt.sh, URLhaus, ThreatFox, and VirusTotal. Inputs can be command-line targets, a targets file, stdin, or a prior disrobe recon/IOC JSON report. The output schema is disrobe.prowl/v0 and contains the requested targets, selected sources, URL records, IOC records, and per-provider status.

The source labels are wayback, commoncrawl, otx, urlscan, crtsh, urlhaus, threatfox, and virustotal (vt alias). API keys resolve in this order: --api-key provider=key, provider environment variable (PROWL_<PROVIDER>_API_KEY or the provider's conventional variable), OS keyring, then a permissions-checked TOML config. Required-key providers without a key are skipped with a provider status rather than failing the whole harvest.

Filters are bounded and deterministic: --blacklist drops extensions, --from/--to constrain Wayback dates, --mc/--fc include or exclude HTTP status codes, --mt/--ft include or exclude MIME substrings, --ioc keeps selected IOC classes, --fp collapses URLs that differ only by query values, and --no-iocs suppresses derived IOC extraction. --timeout, --concurrency, --per-host-rps, --max-pages, --max-urls, --max-iocs, and --retries cap network and memory use.

IOC extraction

disrobe ioc suspicious.bin
disrobe ioc suspicious.bin --format json
disrobe ioc suspicious.bin --defang        # hxxp://, 1[.]2[.]3[.]4 for safe reporting
disrobe ioc malware.exe --format sarif      # GitHub code-scanning ingest

disrobe ioc scans the target's bytes and any UTF-16 / ASCII text inside it for:

  • Network: URLs (http/https/ftp/ftps/smb/file), bare domains, IPv4, IPv6, email addresses.
  • Host artifacts: Windows file paths, registry keys (HKLM\..., HKEY_CURRENT_USER\...), Unix paths under well-known roots (/etc, /usr, /var, /Users, ...).
  • Crypto wallets: Bitcoin (legacy 1/3 and bech32 bc1), Ethereum (0x...40), Monero (4...).
  • Crypto constants: AES S-box and inverse S-box, MD5 / SHA-1 / SHA-256 / SHA-512 init vectors, ChaCha20 sigma/tau, and the standard/URL base64 alphabets.

When the input is a native PE/ELF/Mach-O binary, the import table (library!symbol) is folded into the scan so DLL- and symbol-borne indicators surface too.

Encoding recursion

Base64 and hex blobs in the input are decoded and re-scanned one level deep. An indicator found inside a decoded blob is tagged with its encoding (base64 or hex) so you can tell a plaintext URL from one that was hidden behind a layer of encoding. The recursion is intentionally single-level to keep the scan bounded.

Output

  • Text (default): one indicator per line, kind<TAB>encoding<TAB>@offset<TAB>value, followed by a count.
  • JSON (--format json or the global --json): the disrobe.ioc/v0 document, { schema, uri, byte_len, total, indicators[] }, each indicator carrying kind, value, offset, encoding, and an optional context window.
  • SARIF (--format sarif or the global --sarif): SARIF 2.1.0 with one result per indicator and a DR-IOC-<KIND> rule id, for GitHub code scanning.

--defang rewrites URLs, domains, IPs, and emails into a non-clickable form (hxxp://, 1[.]2[.]3[.]4, user@host[.]tld) in every format.

Safety and determinism

Every pattern is bounded (explicit upper repetition counts) so adversarial input cannot trigger catastrophic regex backtracking, and the indicator set is deduplicated and offset-sorted, so the same bytes always produce the same report. The library logic lives in disrobe_core::ioc and is reused by the daemon and by disrobe report.

Indicator aggregation

disrobe indicators frisk.json ioc.json prowl.json --format json > indicators.json
disrobe indicators frisk.json prowl.json --targets-only > targets.txt
disrobe prowl --targets-file targets.txt --format json > expanded-prowl.json

disrobe indicators ingests disrobe.recon/v0, disrobe.ioc/v0, and disrobe.prowl/v0 JSON. It normalizes every URL, domain, IP, email, hash, ASN, path, and registry value into disrobe.indicators/v0, deduplicates by (class, value), and preserves the source list that produced each value.

--targets-only prints bare domains and IPs, one per line, so an offline frisk/IOC pass can seed an explicit prowl --targets-file run.

String extraction

disrobe strings sample.bin
disrobe strings sample.bin --min-len 6
disrobe strings sample.bin --no-decode      # plain ASCII / UTF-16 only
disrobe strings sample.bin --json

An in-house FLOSS-style extractor. It pulls printable ASCII and UTF-16LE runs at or above --min-len (default 4), then runs a set of deobfuscation passes and tags each result by how it was recovered:

TagMeaning
plain / plain:widePrintable ASCII run / UTF-16LE run.
xor:0xKKRecovered by brute-forcing single-byte XOR key KK over a printable run; kept only when the decoded text clears a printable-ratio bar and hits at least two dictionary words.
base64A base64 token whose decoded bytes are printable text.
rot:NA run that, rotated by N (ROT13 and other ROT-n), becomes dictionary-rich text.
stack-stringA run reconstructed from interleaved-NUL / fragmented bytes characteristic of compiler-built stack strings.

The XOR, ROT, and stack-string heuristics are deliberately conservative: they require dictionary hits, trading recall for precision so the output stays signal, not noise. Results are deduplicated by (value, tag) and offset-sorted.

Output is text (tag<TAB>@offset<TAB>value) or the disrobe.strings/v0 JSON document via --json. The library logic lives in disrobe_core::strings.

YARA rule generation

disrobe yara generate sample.bin
disrobe yara generate sample.bin --name Trojan_Foo_2026
disrobe yara generate sample.bin --sha256 <hash> --date 2026-06-10
disrobe yara generate sample.bin --json

Synthesizes a candidate YARA rule from an artifact. It selects high-signal strings (long, multi-character-class, non-dictionary, and any that were XOR/base64/ROT-recovered get a scoring bonus), detects the file's magic / format header, and emits a leading $magic hex pattern, producing a well-formed:

rule <name> : disrobe generated {
    meta:
        generated_by = "disrobe <version>"
        schema = "disrobe.yara.generated/v0"
        format = "pe"
        sha256 = "..."        // only when --sha256 is given
        date = "..."          // only when --date is given
    strings:
        $magic = { 4D 5A 90 00 ... }
        $s0 = "..." ascii
        ...
    condition:
        $magic at 0 and N of ($s*)
}

The condition combines an anchored magic check (when a format was recognized) with an "N of" string threshold (half the selected strings, rounded up).

Provenance

disrobe has no wall clock available to its analysis core, so the rule is not stamped with the current date automatically. Pass --sha256 and --date to embed those values in the meta block; otherwise they are omitted rather than fabricated.

Self-verification

Every generated rule is parsed back through the in-house YARA parser (the same one behind disrobe yara parse) before it is returned. If the emitter ever produced something the parser could not read, generation fails loudly with DR-YARAGEN-0001 rather than emitting a broken rule. The library logic lives in disrobe_core::yara_gen.

Rule matching

Beyond generating and parsing rules, disrobe_core::yara_match evaluates a parsed ruleset against a byte buffer. It compiles the rule strings into an Aho-Corasick atom prefilter, then checks each rule's condition. It supports text strings (nocase, fullword, ascii, wide), hex strings (nibble wildcards, jumps, and alternation), a subset of regular expressions, and the common condition forms ($s at an offset or in a range, #s counts, N of, all / any / none of, filesize, and boolean and arithmetic operators). A rule that uses a feature outside that set is returned as unevaluated with a reason rather than reported as a false match or a false miss. The matcher is a library capability today with no CLI verb; it is graded by differential testing against the real yara command-line tool across a battery of text, hex, regex, and condition cases (yara_match_oracle.rs).

Behavior summary

disrobe behavior sample.exe
disrobe behavior sample.exe --json

disrobe behavior answers "what does this binary do?" by classifying it across seven categories:

CategoryCovers
networkSockets, WinHTTP/WinINet, DNS lookups, downloads.
filesystemFile create/read/write/delete, directory enumeration.
process_execProcess / command execution, remote-thread injection.
registry_persistenceRegistry writes, Run keys, scheduled tasks, services.
cryptoCryptoAPI / BCrypt / embedded crypto constants.
anti_analysisDebugger checks, timing checks, sandbox evasion.
dynamic_codeLoadLibrary/GetProcAddress, VirtualProtect, dlopen, RWX memory.

Signals come from three sources, each tagged in the evidence list:

  • import: the native import table (PE/ELF/Mach-O), parsed when the input is a binary.
  • string: API / symbol names found in the extracted strings (including XOR/base64-recovered ones), so signals survive light obfuscation.
  • ioc: network/host/crypto indicators from the IOC extractor.

MITRE ATT&CK mapping

Confident matches are tagged with a MITRE ATT&CK technique id (for example LoadLibrary -> T1129, IsDebuggerPresent -> T1622, a Run key -> T1547.001). The mapping is a small, hand-curated static table: only techniques that follow directly from the signal are emitted, never a probabilistic guess. The aggregate attack_ids list at the end of the report is the union across all categories, ready to paste into a triage ticket. The library logic lives in disrobe_core::behavior and is reusable by disrobe report.

Scope

This is a static summary: disrobe never executes the sample. A signal means the capability is present in the binary's imports/strings, not that it necessarily fires at runtime. Treat it as a lead, not a verdict.

Diff and guard tooling

disrobe treats recovered artifacts as a forensic baseline you can diff across versions and protect against tampering. Two command families support this: disrobe diff and disrobe guard.

disrobe diff: structural chain diff

disrobe diff left/chain.json right/chain.json

Structurally diffs two chain.json documents, the topology descriptors written by disrobe auto / disrobe chain. It compares the passes that ran, each stage's BLAKE3 output hash, byte sizes, and per-stage verdicts. This is how you answer "did upgrading the PyArmor pass change what we recover from this sample?" without eyeballing two output trees.

A parallel disrobe envelope diff works on two .dr envelopes directly. It compares version, rung, flags, root hash, producer, capability set, and provenance.

disrobe guard: ground-truth protection

In a recovery workspace, the exact stage records (out/**/stages, out/**/final) are provenance inputs. Do not edit them in place because that would corrupt the recorded chain. disrobe guard enforces this boundary.

guard verify: hash verification

disrobe guard verify subject/chain.json --reference reference/chain.json

Verifies that a subject chain.json's per-stage output hashes match a committed reference. Use it in CI to assert that a recovery is reproducible: re-run the chain, then verify the new chain.json against the checked-in reference.

guard check: edit denial

disrobe guard check out/final/module.py
disrobe guard check some/path --root extra/protected/subtree --root other/protected

Decides whether a path about to be written or edited is inside a protected ground-truth subtree. It denies writes to out/**/stages, out/**/final, and any .disrobe-stage-lock-marked path, and allows writes elsewhere. --root adds extra protected subtrees; it is repeatable and also accepts comma-separated values.

This is the command wired into the agent settings hook that disrobe init --ide claude generates: a PreToolUse hook calls disrobe guard check and denies edits to the 01-*/ and 02-*/ stage directories, so a coding agent working in a recovery workspace cannot accidentally rewrite the ground truth it is supposed to be analyzing.

The daemon: HTTP, gRPC, LSP, MCP

disrobe serve runs disrobe as a long-lived service over four protocols. The core capability is the same (analyze bytes, return recovered artifacts), exposed through whichever transport fits the caller.

disrobe serve                              # HTTP on 127.0.0.1:7373
disrobe serve --bind 0.0.0.0:7373          # HTTP on all interfaces (emits a warning banner)
disrobe serve --grpc                       # HTTP on :7373, gRPC on :7374
disrobe serve --stdio                      # LSP over stdio
disrobe serve --mcp                        # MCP companion over stdio (rmcp)

Flags

FlagDefaultEffect
--bind <ADDR>127.0.0.1:7373HTTP bind address.
--stdiooffServe LSP JSON-RPC over stdin/stdout instead of HTTP.
--mcpoffServe the MCP companion over stdio instead of HTTP/LSP.
--grpcoffExpose the gRPC surface alongside HTTP (binds to <bind-ip>:<bind-port+1>).
--cors-origin <ORIGIN>(none)Additional CORS origin to allow. Repeatable. With no origins, all origins are allowed.
--max-body-size <N>52428800 (50 MiB)Maximum request body size in bytes.

HTTP

The default surface. Binds to loopback (127.0.0.1:7373) by default; a non-loopback bind emits a warning at startup.

The API is versioned: /v1/* and /v2/* are aliases. The spec is served at /openapi.json.

MethodPathDescription
GET/v1/healthServer liveness ({ status, version }).
GET/v1/versionTool name, version, and API level ({ name, version, api }).
GET/v1/passesList registered passes ({ passes: [{ name, description }] }).
POST/v1/analyzeClassify and route bytes. Body: { bytes_b64, hint? }. Returns: { routed_action, bytes_read, blake3_hash, reason, candidates }.
POST/v1/explain/{code}Look up a DR-* error code. Returns: { code, known, title?, description?, crate_path? }.
POST/v1/envelope/verifyVerify a .dr envelope. Body: { bytes_b64 }. Returns: { verified, version, rung, hot_bytes, cold_bytes, root_hash_blake3 }.
POST/v1/envelope/createWrap raw bytes into a Raw-rung .dr envelope. Body: { bytes_b64, source_label?, produced_by?, detected_format? }. Returns: { envelope_b64, envelope_bytes, bytes_in, root_hash_blake3, source_hash_blake3 }.
WS/v1/streamWebSocket NDJSON stream (upgrade with Sec-WebSocket-Protocol: disrobe-stream.v1). Bytes are sent bytes_b64-only; the server never reads from disk.
GET/openapi.jsonOpenAPI 3 spec for the HTTP surface.

gRPC

Exposed alongside HTTP with --grpc. The gRPC server binds to the same IP as --bind but at <port> + 1 (default 127.0.0.1:7374). Includes health and reflection services.

LSP over stdio

--stdio speaks JSON-RPC over stdin/stdout using the LSP framing and initialize handshake. It does not implement the standard textDocument surface. It advertises two custom methods under capabilities.experimental.disrobe:

MethodDescription
disrobe/analyzeParams: { bytes_b64, label? }. Returns the same classification payload as POST /v1/analyze.
disrobe/explainParams: { code }. Returns the error-code lookup payload.

Unknown fields in params are rejected (hard error). A path field is specifically rejected; all bytes are transmitted inline.

MCP companion

--mcp runs the rmcp-based Model Context Protocol companion, exposing disrobe's analysis, workspace, and navigation capabilities as MCP tools. Navigation includes call_graph, xrefs, function_summary, and neighborhood over content-bound function ids and bounded paginated responses. The standalone disrobe-mcp binary is equivalent. See the dedicated MCP server integration page for the full tool catalog and client registration.

Security posture

HTTP, gRPC, LSP, and WebSocket analysis requests carry sample bytes inline and do not accept a sample path. The MCP analysis tools use the same bytes_b64 model. Its workspace tools are deliberately path-aware: annot reads a client-selected target only after resolving it beneath the current workspace, then writes its sidecar under .disrobe/annotations/; rename reads and writes the fixed .disrobe/notes/renames.json file. JSON request structs reject unknown fields via deny_unknown_fields. Escaping those workspace bounds, or making an inline-byte analysis surface read a client-selected path, is a high-severity vulnerability under the security policy. Run the daemon on loopback unless you have a specific reason not to.

Signed WebAssembly plugins

disrobe plugin runs a third-party analysis component through the disrobe-plugin-host sandbox. A plugin never runs with disrobe's own privileges: it is a WebAssembly component, signed by a key you name explicitly, executed under a fuel budget, a wall-clock deadline, and a memory cap, with every import denied unless a manifest grants it.

disrobe plugin verify my-plugin.wasm --trusted-key operator.pub
disrobe plugin run my-plugin.wasm --trusted-key operator.pub --out result.bin < input.bin
disrobe plugin list ./plugins --trusted-key operator.pub

Bundle shape

A plugin bundle is three sibling files sharing one stem:

FilePurpose
<name>.wasmThe signed WebAssembly component.
<name>.wasm.minisigA minisign signature over the component bytes.
<name>.tomlThe manifest: declared name, version, and the capability set the component may import.

There is no registry and no distribution mechanism. --trusted-key always names an operator- supplied minisign public key file, never one disrobe embeds.

Commands

CommandPurpose
disrobe plugin run <component> --trusted-key <pubkey> --out <file> [--input <file>] [--fuel N] [--wall-deadline-ms MS] [--memory-cap-bytes N] [--format text|json]Verify and run the component. Reads input from stdin when --input is omitted; writes output bytes to --out.
disrobe plugin verify <component> --trusted-key <pubkey> [--format text|json]Verify the signature and capability manifest without running the component.
disrobe plugin list <dir> [--trusted-key <pubkey>] [--format text|json]List every bundle in a directory. Each bundle is also signature- and capability-verified when --trusted-key is given.

--fuel, --wall-deadline-ms, and --memory-cap-bytes each override the sandbox's default, and are always clamped to its compiled-in ceiling regardless of what is requested.

Guest contract

A component exports one function, run: func(list<u8>) -> list<u8>. It receives the input bytes and returns output bytes; nothing else crosses the boundary. The component linker stays empty, so a manifest capability grant permits validation but never actually supplies a host function to call.

Provenance: authenticated versus declared

--format json reports two kinds of fields, and labels which is which:

  • Authenticated, derived from the verified bytes: the component's BLAKE3 hash and the trusted signing key's id.
  • Declared, not authenticated: the manifest's name and version. The signature covers the component bytes only, never the manifest, so an attacker who can place files next to a validly signed component can edit its declared name or version without invalidating the signature. run's JSON output carries manifest_version_authenticated: false alongside those fields rather than presenting them as fact.

Rejections

Every rejection is a distinct typed error surfaced before the guest ever executes: unsigned, wrong key, over-size component, over-size signature, non-UTF-8 signature, missing manifest, malformed manifest, an ungranted capability, a missing run export, or a wrongly typed one. At runtime, the fuel, wall-clock, and memory caps each independently terminate a runaway guest.

Scope

CLI-only today: a plugin invocation names an explicit local path an operator supplies, unlike disrobe's own passes, so it is not reachable from disrobe auto, the MCP surface, or the python bindings. See the security policy for the full trust model, including what resource sandboxing does and does not guarantee about a plugin's output correctness.

Use it as a library

disrobe embeds as well as it runs from a shell. The CLI integrates the same crates exposed to library consumers. There are three primary entry points: the Rust crates, the Python bindings, and the daemon.

Rust

Ecosystem recovery code is split across dedicated crates over shared artifact and IR types, so a library consumer can select only the surfaces it needs. A crate may expose multiple direct operations or chain passes. The pass registry and chain state machine live in disrobe-core; disrobe-passes assembles the feature-selected registry used by disrobe auto.

CrateWhat you get
disrobe-coreShared types: Artifact, Rung, confidence tiers, error codes, the chain registry and state machine, the Pass/Detector traits, and the obfuscator-catalog traits.
disrobe-irThe five-rung IR ladder, the .dr envelope (rkyv hot payload + postcard cold sidecar + BLAKE3 root), and the transcode registry.
disrobe-nir, disrobe-nir-liftNormalized MIR and bytecode front ends for AVM2, BEAM, CIL, Dalvik, JVM, Lua, Python, WebAssembly, and YARV.
disrobe-binfmtContainer detection, in-tree format extractors, recursive carving, and shared extraction quotas.
disrobe-passesThe single construction site for a feature-selected PassRegistry.
disrobe-prowlTyped URL and IOC harvest reports, source filters, bounded async provider fan-out, and API-key resolution for the prowl CLI.
disrobe-pass-py-decompile, disrobe-pass-jvm, disrobe-pass-native, disrobe-pass-dotnet, ...One crate per ecosystem, each exposing a typed Pass plus direct entry points (for example the Python decompiler's PY_DECOMPILE_PASS and roundtrip_native).
disrobe-pass-webviewStatic Electron ASAR and embedded Tauri/Wails frontend recovery with typed reports and extraction quotas.
disrobe-query, disrobe-capabilitiesThe queryable-IR layer and the ATT&CK/MBC rule engine over the disassembled native code.
disrobe-taintSource-to-sink flow analysis over normalized native, WebAssembly, JVM, Dalvik, and .dr inputs.

Add the crates you want to a workspace member or an external project that pins the published versions:

[dependencies]
disrobe-core = "0.10"
disrobe-ir = "0.10"
disrobe-pass-py-decompile = "0.10"

Each pass implements the shared Pass trait: it exposes a Detector that scores how confidently it recognizes an input, and a run method that takes an Artifact at one rung and returns an Artifact one or more rungs higher. Driving a pass directly looks like this (illustrative):

use disrobe_core::pass::Pass;
use disrobe_core::{Artifact, Rung};
use disrobe_pass_py_decompile::chain_detector::PY_DECOMPILE_PASS;

fn recover(pyc: Vec<u8>, root: [u8; 32]) -> disrobe_core::Result<Artifact> {
    let input: Artifact = Artifact::new(Rung::Raw, pyc, root);
    let recovered: Artifact = PY_DECOMPILE_PASS.run(&input)?;
    let surface: &[u8] = recovered.envelope.as_slice();
    println!("rung={:?} bytes={}", recovered.rung, surface.len());
    Ok(recovered)
}

The Python decompiler additionally exposes roundtrip_native, which recompiles recovered source on the matching interpreter and returns a RoundtripOutcome carrying the PERFECT/SEMANTIC/CODE_DIFF verdict, so the same recompile-equivalence check the CI gate runs is available in-process.

Because every chain pass speaks the same Artifact dialect, PassRegistry can re-detect the current bytes after each stage and select the highest-confidence, highest-precedence verdict without a per-pair compatibility table. The registry contains only the passes compiled and registered by the caller. The standard CLI uses disrobe-passes as its assembly point; disrobe passes shows what that build exposes to auto. The shape of the Pass trait and the selection mechanism is in Passes and pass selection.

Python

import disrobe is a pyo3 abi3 module (Python 3.9+, shipping a full .pyi and py.typed), built with maturin from crates/disrobe-python. Bytes in, concrete typed report objects out, deterministic, and the bindings never touch the filesystem so the caller owns all I/O.

import disrobe
from disrobe import Capabilities, CanonicalSource, ChainReport, CodeObject, Instruction, Symbol

with open("sample.bin", "rb") as f:
    chain: ChainReport = disrobe.auto(f.read())
print(chain.spec, chain.pass_count, chain.terminated)

with open("module.pyc", "rb") as f:
    recovered: CanonicalSource = disrobe.decompile("python-bytecode", f.read())
source: str | None = recovered.source

with open("packed.exe", "rb") as f:
    caps: Capabilities = disrobe.capabilities(f.read())
print(caps.format, caps.match_count)

with open("module.dr", "rb") as f:
    obj: CodeObject = CodeObject.from_dr(f.read())
obj.add_symbol(Symbol(0x401000, "decrypt_config"))
obj.add_instruction(Instruction(0x401000, "xor", ["eax", "eax"]))
patched_dr: bytes = obj.to_dr()

The surface spans auto, typed entry points for every major ecosystem, a generic disasm/parse/compile/decompile dispatch, a mutable CodeObject you load from a .dr envelope, edit, and re-serialize, and a register_pass/register_consumer registry for your own stages. The full function list and conventions are in the Python-bindings reference.

Daemon

disrobe serve speaks HTTP, gRPC, and LSP, taking base64 bytes and returning structured JSON, so any language can drive it over a socket. disrobe serve --mcp exposes the same operations as Model Context Protocol tools for automation clients. The wire surface is documented in The daemon.

Python bindings

disrobe ships a typed Python library that mirrors the full CLI surface. The importable disrobe module is built from crates/disrobe-python with pyo3 (abi3, Python 3.9+) and wraps the same Rust library the CLI uses. Bytes go in; a concrete typed report object comes out. Output is deterministic: the same input produces the same report bytes.

The library does not read or write the filesystem; the caller owns all I/O. Wheels are not published to PyPI; build from source.

Install

git clone https://github.com/1-3-7/disrobe
cd disrobe/bindings/python
pip install maturin
maturin develop --release

For a redistributable wheel:

maturin build --release
pip install target/wheels/disrobe-*.whl

The pyproject.toml pins maturin>=1.5,<2.0, sets module-name = "disrobe.disrobe", and points at crates/disrobe-python/Cargo.toml. On Windows the crate's build.rs searches PYO3_PYTHON, an active VIRTUAL_ENV, and standard install locations; set PYO3_PYTHON=<path-to-python.exe> if none is found. A py.typed marker is shipped so pyright and mypy resolve every attribute from the .pyi stub.

import disrobe

version: str = disrobe.__version__

Report model

Every analysis function returns a concrete subclass of _Report. The base carries the full serialization surface every report shares.

_Report

MemberSignatureNotes
raw@property -> dict[str, Any]Full underlying record; no detail dropped
to_json() -> strCompact JSON string
from_json_strclassmethod(text: str) -> SelfRebuild from a to_json string
from_objclassmethod(obj: dict[str, Any]) -> SelfWrap an already-decoded dict

Reports compare equal when their underlying records are equal (== / !=).

_LlmReport

Subclasses _Report. Adds one property:

MemberSignatureNotes
llm@property -> LlmBundle | NonePopulated when metadata output was requested; None otherwise

Metadata-wired passes: py_decompile, py_disasm, py_deob, pyarmor_detect, pyarmor_unpack. Functions that build a metadata bundle accept pack: Pack | None where Pack = Literal["pack-1", "pack-2", "pack-3", "pack-4"].

LlmBundle

A TypedDict(total=False) mirroring the disrobe.metadata.llm.v1 on-disk schema. The type name and schema id are stable API names; the payload is deterministic metadata, not model output. Keys present depend on which pack was requested.

from disrobe import LlmBundle
from typing import Any

bundle: LlmBundle = {
    "schema": "disrobe.metadata.llm.v1",
    "schema_version": "1",
    "generated_at": "2026-06-16T00:00:00Z",
    "tool": {},
    "selection": {},
    "input": {},
    "pipeline": [],
    "categories": {},
}

Literal type aliases

AliasValues
Pack"pack-1", "pack-2", "pack-3", "pack-4"
RoundtripStatus"perfect", "semantic", "code-diff", "no-interpreter", "recompile-failed", "skipped"
PyarmorUnpackStatus"functional", "bcc-partial", "detect-only", "skeleton"
ContainerListing"enumerated", "requires-extraction", "unreadable"
SymbolKind"function", "data", "label", "export", "import"
InstructionFlow"sequential", "call", "indirect-call", "conditional-branch", "unconditional-branch", "indirect-branch", "return", "interrupt"
SourceLanguage"python", "py", "python3"
JsLanguage"javascript", "js", "typescript", "ts"
ByteLanguage"python-bytecode", "pyc", "jvm-class", "class", "dex", "beam", "hermes", "hermes-bundle", "hbc", "wasm"
ParseByteLanguage"go", "swift", "objc", "objective-c", "kotlin", "ruby", "lua", "php"
DisasmByteLanguage"ruby", "ruby-bytecode", "yarv", "mruby", "php", "php-bytecode"
DecompileLanguage"python-bytecode", "pyc", "jvm-class", "class", "java", "kotlin", "lua", "ruby", "php", "php-bytecode", "javascript", "js", "typescript", "ts"

Exception hierarchy

ClassBaseRaised when
DisrobeErrorExceptionAny binding fails
UnsupportedLanguageDisrobeErrordisasm/parse/compile/decompile for a language with no backing implementation; message includes a hint

Module-level functions: full surface

CategoryFunctionReturns
Auto chainauto(input, *, max_depth=8, path_hint=None)ChainReport
Generic dispatchdecompile(language, source)CanonicalSource
disasm(language, source)str
parse(language, source)typed report or dict[str, Any]
compile(language, source, *, version=None)bytes
Custom passregister_pass(name, callable)None
register_consumer(name, callable)None
registered_passes()list[str]
registered_consumers()list[str]
unregister(name)bool
run_pass(name, data)Any
run_chain(names, data)Any
emit(name, result, **context)Any
Analysisstrings_extract(data, *, min_len=4, decode=True)StringsReport
ioc_extract(data)IocReport
behavior_analyze(data)BehaviorReport
identify(data)IdentifyReport
secret_scan(data, *, redact=False)SecretScanReport
capabilities(binary_bytes)Capabilities
extract(data, out_dir)ExtractionResult
extract_recursive(data, *, source_label='inline', max_depth=8)OverlayReport
yara_parse(ruleset_source)YaraReport
yara_generate(data, *, name=None)YaraReport
Nativenative_symbols(data)SymbolsReport
native_disasm(data)DisasmPayload
native_callgraph(data)CallGraph
native_imports_dot(data)str
native_entropy(data)EntropyReport
native_sbom(data)SbomReport
native_fingerprint(data, *, flirt=None)FingerprintReport
native_signatures(data, *, flirt=None)SignatureReport
native_sigmaker(data, at)SigmakerReport
native_diff(a, b)DiffReport
native_patch(data, *, at, replacement=None, nop_start=None, nop_end=None)tuple[bytes, PatchReport]
native_format(binary_bytes)NativeFormat
native_detect(binary_bytes)DetectionList
native_probe_backends()BackendList
native_deobfuscate(code, *, bits=64, base=0, entry=0)NativeDeobfuscation
Query IRquery_functions(dr_bytes)FunctionList
query_calls_to(dr_bytes, target)QueryReport
query_xrefs_to(dr_bytes, symbol)QueryReport
query_string_decoders(dr_bytes)QueryReport
query_complexity_over(dr_bytes, threshold)QueryReport
query_capability_sites(dr_bytes, capability)QueryReport
query_call_graph(dr_bytes)CallGraph
Envelopeenvelope_create(payload, *, source_label='inline', produced_by=None, detected_format=None)bytes
envelope_verify(envelope_bytes)EnvelopeReport
LLM rendersagents_md(result)str
skill_md(result)str
provenance(result)Provenance
Python decompilepy_decompile(pyc_bytes, *, roundtrip=False, pack=None)PyDecompileReport
py_disasm(pyc_bytes, *, pack=None)PyDisasmReport
Python deobfuscatepy_deob(source, *, cleanup=True, pack=None)PyDeobReport
py_deob_detect(source)PyDeobDetection
py_deob_list_passes()list[ObfuscatorPass]
py_deob_detect_pass(source, pass_id)PyDeobDetection
PyArmorpyarmor_detect(source, *, pack=None)PyarmorDetection
pyarmor_unpack(wrapper_bytes, *, pack=None)PyarmorUnpack
pyarmor_classify(source, payload)PyarmorClassification
PyInstallerpyinstaller_extract(image_bytes)PyInstallerArchive
pyinstaller_entry_bytes(image_bytes, entry_name)bytes
Nuitkanuitka_detect(image_bytes)NuitkaDetection
nuitka_extract(image_bytes)NuitkaExtraction
Hermeshermes_disasm(bundle_bytes)HermesDisassembly
hermes_lift(bundle_bytes)HermesLift
hermes_info(bundle_bytes)HermesInfo
Mach-Omacho_dump(macho_bytes)MachoReport
swift_analyze(macho_bytes)SwiftReport
JVM / Androidjvm_parse_class(class_bytes)JvmClass
jvm_parse_dex(dex_bytes)DexFileReport
jvm_decompile_class(class_bytes)JvmDecompiledClass
jvm_decompile_dex(dex_bytes)JvmDecompiledDex
jvm_detect(class_bytes)DetectionList
jvm_backends()JvmBackends
apk_resources(apk_bytes)ApkResources
.NETdotnet_parse_pe(pe_bytes)DotnetPe
dotnet_parse_metadata(pe_bytes)DotnetMetadata
dotnet_detect(pe_bytes)DotnetDetection
dotnet_analyze(pe_bytes)DotnetAnalysis
dotnet_decompile(pe_bytes)DotnetDecompilation
dotnet_recover_decoders(pe_bytes)DotnetDecoders
dotnet_native_aot(image_bytes)DotnetNativeAot
dotnet_backends()BackendList
WebAssemblywasm_analyze(wasm_bytes)WasmAnalysis
wasm_detect(wasm_bytes)WasmDetection
JavaScriptjs_detect(js_source)JsDetection
js_unminify(js_source)JsUnminify
js_unbundle(js_source, *, bundler=None)JsUnbundle
Lualua_detect(bytecode)LuaDetection
lua_decompile(bytecode)LuaDecompilation
lua_deobfuscate(source, *, authorize=False, strict=False)LuaDeobfuscation
Gogo_analyze(binary_bytes)GoAnalysis
go_symbols(binary_bytes)GoSymbols
go_pclntab(binary_bytes)GoPclntab
go_garble(binary_bytes)GarbleReport
Rubyruby_detect(ruby_bytes, *, source_path=None)RubyDetection
ruby_decompile(ruby_bytes, *, source_path=None)RubyAnalysis
PHPphp_detect(php_bytes)PhpDetection
php_scan(php_bytes)PhpScan
php_decode(php_bytes, *, max_depth=None)PhpDecode
Shellbatch_deobfuscate(script, *, args=None)BatchDeobReport
powershell_detect(script)PowershellDetection
powershell_deobfuscate(script)PowershellDeobfuscation
Containerscontainer_detect(container_bytes)ContainerDetection
container_members(container_bytes)ContainerMembers
Picklepickle_disasm(pickle_bytes)str
pickle_decompile(pickle_bytes)PickleDecompilation
pickle_safety(pickle_bytes)PickleSafety
pickle_trace(pickle_bytes)PickleTrace
pickle_polyglot(file_bytes)PicklePolyglot
pickle_ml_detect(file_bytes)PickleMlReport

Auto chain

import disrobe
from disrobe import ChainReport

with open("sample.bin", "rb") as fh:
    chain: ChainReport = disrobe.auto(fh.read(), max_depth=8)

spec: str | None = chain.spec
pass_count: int = chain.pass_count
terminated: bool = chain.terminated
full_plan: dict[str, object] = chain.raw

auto runs the chain detector against raw bytes and returns a ChainReport carrying the full chain.json plan the CLI produces; it does not write stage outputs to disk. max_depth must be 1-16; out-of-range values raise DisrobeError. The registered pass tree covers pyarmor, pyinstaller, nuitka, py-decompile, py-deob, container, js, jvm, dotnet, wasm, mobile, swift-objc, and the native packer detector.

ChainReport accessors

PropertyType
specstr | None
pass_countint
terminatedbool

Generic dispatch

Language-keyed entry points that fan out to the per-language passes.

decompile

def decompile(language: str, source: str | bytes) -> CanonicalSource: ...

Wired families: python/pyc (py-decompile), jvm-class/class/java/kotlin (JVM lifter), lua (register lifter), ruby (YARV/mruby recovery), php/php-bytecode (eval-chain peel/op-array skeleton), javascript/js/typescript/ts (unminify). Binary-only targets (go, swift, wasm) have no single source body; call their structural binding or parse instead.

import disrobe
from disrobe import CanonicalSource

with open("module.pyc", "rb") as fh:
    recovered: CanonicalSource = disrobe.decompile("python-bytecode", fh.read())

source: str | None = recovered.source
language: str | None = recovered.language
produced_by: str | None = recovered.produced_by
confidence: float | None = recovered.confidence

CanonicalSource accessors

PropertyType
sourcestr | None
languagestr | None
produced_bystr | None
confidencefloat | None

disasm

def disasm(language: str, source: str | bytes) -> str: ...

Returns a rendered instruction listing as text. Wired: python/pyc, jvm-class/class, dex, beam, hermes, wasm, ruby/yarv/mruby, and php/php-bytecode. For Lua use decompile('lua', ...) or parse('lua', ...) instead.

parse

def parse(language: str, source: str | bytes) -> (
    dict[str, Any]
    | GoAnalysis
    | SwiftReport
    | JvmClass
    | RubyAnalysis
    | LuaDecompilation
    | PhpDecode
    | JsUnminify
): ...

Returns a typed report for structural-recovery languages: go -> GoAnalysis, swift/objc/objective-c -> SwiftReport, kotlin -> JvmClass, ruby -> RubyAnalysis, lua -> LuaDecompilation, php -> PhpDecode, javascript/js/typescript/ts -> JsUnminify. Container and bytecode formats (pyc, jvm-class, dex, wasm, hermes, beam) return a nested dict[str, Any] because their full parse records have no single typed shape.

import disrobe
from typing import Any

with open("Hello.class", "rb") as fh:
    parsed: dict[str, Any] = disrobe.parse("jvm-class", fh.read())
method_count: int = len(parsed["methods"])

compile

def compile(language: str, source: str, *, version: str | None = None) -> bytes: ...

Implemented for Python only; returns raw marshal.dumps bytes (no .pyc header) via the host interpreter. lua, ruby, and php raise UnsupportedLanguage with a hint pointing at the CLI subcommand or toolchain.

import disrobe

blob: bytes = disrobe.compile("python", "x: int = 1 + 2\n")
listing: str = disrobe.disasm("python", "x: int = 1 + 2\n")

Custom pass plugin protocol

Register and compose named passes and output consumers in the host process.

from typing import Any
import disrobe
from disrobe import Pass, OutputConsumer

def my_pass(data: Any) -> Any:
    return data[::-1]

def my_consumer(result: Any, **context: Any) -> Any:
    print(result, context)

disrobe.register_pass("reverse", my_pass)
disrobe.register_consumer("print", my_consumer)

names: list[str] = disrobe.registered_passes()
consumer_names: list[str] = disrobe.registered_consumers()

output: Any = disrobe.run_pass("reverse", b"hello")
chained: Any = disrobe.run_chain(["reverse", "reverse"], b"hello")
disrobe.emit("print", chained, source="example")

removed: bool = disrobe.unregister("reverse")

Pass and OutputConsumer protocols

Both are @runtime_checkable protocols.

ProtocolSignature
Pass__call__(self, data: Any) -> Any
OutputConsumer__call__(self, result: Any, **context: Any) -> Any

Analysis

strings_extract

def strings_extract(data: bytes, *, min_len: int = 4, decode: bool = True) -> StringsReport: ...

Extracts ASCII and UTF-16 strings from a binary blob.

ioc_extract

def ioc_extract(data: bytes) -> IocReport: ...

Harvests indicators of compromise from bytes and recovered strings.

behavior_analyze

def behavior_analyze(data: bytes) -> BehaviorReport: ...

Behavioral summary by category with MITRE ATT&CK IDs.

identify

def identify(data: bytes) -> IdentifyReport: ...

Compiler/linker/packer/protector/installer fingerprint.

secret_scan

def secret_scan(data: bytes, *, redact: bool = False) -> SecretScanReport: ...

Set redact=True to replace detected values, previews, messages, and URI credentials with stable redaction tokens. The default report retains the matched values. Redaction preserves the finding count and byte offsets.

Leaked-credential scan over raw bytes.

capabilities

def capabilities(binary_bytes: bytes) -> Capabilities: ...

Capability rule-set matches for a native binary.

extract

def extract(data: bytes, out_dir: str) -> ExtractionResult: ...

Carves container/firmware members to out_dir.

extract_recursive

def extract_recursive(
    data: bytes, *, source_label: str = "inline", max_depth: int = 8
) -> OverlayReport: ...

Recursive multi-magic carve; classifies every chunk by entropy and nesting.

yara_parse / yara_generate

def yara_parse(ruleset_source: str) -> YaraReport: ...
def yara_generate(data: bytes, *, name: str | None = None) -> YaraReport: ...

Parse a YARA ruleset AST or generate a candidate rule from a binary blob.

import disrobe
from disrobe import (
    StringsReport, IocReport, BehaviorReport, IdentifyReport,
    SecretScanReport, Capabilities, OverlayReport, YaraReport,
)

with open("suspect.bin", "rb") as fh:
    data: bytes = fh.read()

strings: StringsReport = disrobe.strings_extract(data, min_len=6)
string_count: int = strings.string_count

iocs: IocReport = disrobe.ioc_extract(data)
indicator_count: int = iocs.indicator_count

behavior: BehaviorReport = disrobe.behavior_analyze(data)
category_count: int = behavior.category_count

ident: IdentifyReport = disrobe.identify(data)
fmt: str | None = ident.format
finding_count: int = ident.finding_count

caps: Capabilities = disrobe.capabilities(data)
match_count: int = caps.match_count

overlay: OverlayReport = disrobe.extract_recursive(data, max_depth=4)
chunks_total: int | None = overlay.chunks_total
bytes_carved: int | None = overlay.bytes_carved

rule: YaraReport = disrobe.yara_generate(data, name="suspect")
rule_count: int = rule.rule_count

Analysis report classes

ClassNotable typed accessors
StringsReportstring_count: int
IocReportindicator_count: int
BehaviorReportcategory_count: int
IdentifyReportformat: str | None, finding_count: int
SecretScanReportfinding_count: int
Capabilitiesmatch_count: int, format: str | None
ExtractionResultkind: str | None, entry_count: int, integrity_violation_count: int
OverlayReportmax_depth: int | None, nodes_visited: int | None, chunks_total: int | None, bytes_carved: int | None
YaraReportrule_count: int

Native binary

Functions

FunctionReturnsNotes
native_symbols(data)SymbolsReportSymbols, sections, imports, debug info
native_disasm(data)DisasmPayloadFull disassembly: functions, stream, symbols
native_callgraph(data)CallGraphWhole-program call graph
native_imports_dot(data)strGraphViz DOT of the import graph
native_entropy(data)EntropyReportSliding-window Shannon entropy map
native_sbom(data)SbomReportCycloneDX 1.5 SBOM from cargo-auditable section
native_fingerprint(data, *, flirt=None)FingerprintReportCrypto-constant + FLIRT + string-xref sidecar
native_signatures(data, *, flirt=None)SignatureReportCrypto-primitive signatures and FLIRT matches
native_sigmaker(data, at)SigmakerReportWildcarded byte signature for a VA
native_diff(a, b)DiffReportFunction-level diff of two binaries
native_patch(data, *, at, ...)tuple[bytes, PatchReport]Rewrite bytes and revalidate
native_format(binary_bytes)NativeFormatFormat: kind, bitness, subsystem
native_detect(binary_bytes)DetectionListPacker/protector detection hits
native_probe_backends()BackendListProbe for installed external tools
native_deobfuscate(code, *, bits=64, base=0, entry=0)NativeDeobfuscationx86 OLLVM/Tigress deflattening
import disrobe
from disrobe import (
    SymbolsReport, DisasmPayload, CallGraph, EntropyReport,
    SbomReport, FingerprintReport, SignatureReport, SigmakerReport,
    DiffReport, PatchReport, NativeFormat, DetectionList,
    BackendList, NativeDeobfuscation,
)

with open("binary.elf", "rb") as fh:
    data: bytes = fh.read()

syms: SymbolsReport = disrobe.native_symbols(data)
symbol_count: int = syms.symbol_count
section_count: int = syms.section_count
import_count: int = syms.import_count

disasm_payload: DisasmPayload = disrobe.native_disasm(data)
instruction_count: int = disasm_payload.instruction_count
source_hash: str | None = disasm_payload.source_hash

entropy: EntropyReport = disrobe.native_entropy(data)
mean: float | None = entropy.mean

sig: SigmakerReport = disrobe.native_sigmaker(data, at=0x1000)
ida_pattern: str | None = sig.ida_pattern

patched_bytes: bytes
patch_report: PatchReport
patched_bytes, patch_report = disrobe.native_patch(data, at=0x1234, nop_start=0x1234, nop_end=0x1240)
revalidated: bool = patch_report.revalidated

deob: NativeDeobfuscation = disrobe.native_deobfuscate(data, bits=64, base=0x400000)
recovered_blocks: int | None = deob.recovered_blocks
covered_states: int | None = deob.covered_states
dispatcher_states: int | None = deob.dispatcher_states
fully_recovered: bool = deob.fully_recovered

Native report classes

ClassNotable typed accessors
SymbolsReportsymbol_count: int, section_count: int, import_count: int
DisasmPayloadinstruction_count: int, symbol_count: int, source_hash: str | None
CallGraphnode_count: int, edge_count: int
EntropyReportwindow_count: int, mean: float | None, min: float | None, max: float | None
SbomReportcomponent_count: int, bom_format: str | None, spec_version: str | None
FingerprintReportcrypto_hit_count: int
SignatureReportsignature_count: int
SigmakerReportida_pattern: str | None, byte_count: int
DiffReportadded: int, removed: int, changed: int
PatchReportat: int | None, bytes_written: int | None, revalidated: bool
NativeFormatkind: str | None, bits: int | None, subsystem: str | None
DetectionListcount: int
BackendListcount: int, available_count: int
NativeDeobfuscationbits: int | None, recovered_blocks: int | None, original_blocks: int | None, dispatcher_states: int | None, covered_states: int | None, fully_recovered: bool

Query IR

The query functions operate on a Disasm- or Mir-rung .dr envelope (raw bytes). See Editable IR objects for how to produce and consume .dr envelopes programmatically.

FunctionReturnsNotes
query_functions(dr_bytes)FunctionListAll recovered functions
query_calls_to(dr_bytes, target)QueryReportCall sites targeting a symbol name
query_xrefs_to(dr_bytes, symbol)QueryReportData/code cross-references to a symbol
query_string_decoders(dr_bytes)QueryReportFunctions with string-decode patterns
query_complexity_over(dr_bytes, threshold)QueryReportFunctions with cyclomatic complexity above threshold
query_capability_sites(dr_bytes, capability)QueryReportSites exercising a named capability
query_call_graph(dr_bytes)CallGraphWhole-program call graph from IR
import disrobe
from disrobe import FunctionList, QueryReport, CallGraph

with open("module.dr", "rb") as fh:
    dr: bytes = fh.read()

functions: FunctionList = disrobe.query_functions(dr)
fn_count: int = functions.count

callers: QueryReport = disrobe.query_calls_to(dr, "malloc")
match_count: int = callers.match_count

complex_fns: QueryReport = disrobe.query_complexity_over(dr, threshold=20)
graph: CallGraph = disrobe.query_call_graph(dr)
edge_count: int = graph.edge_count

Query report classes

ClassNotable typed accessors
FunctionListkind: str | None, count: int
QueryReportkind: str | None, match_count: int
CallGraphnode_count: int, edge_count: int

Envelope

envelope_create wraps a payload as a Raw-rung .dr envelope and returns the encoded bytes. envelope_verify decodes and verifies, returning an EnvelopeReport.

import disrobe
from disrobe import EnvelopeReport

envelope: bytes = disrobe.envelope_create(
    b"payload",
    source_label="inline",
    produced_by="my-tool",
    detected_format="elf64",
)
report: EnvelopeReport = disrobe.envelope_verify(envelope)
ok: bool = report.verified
root_hash: str | None = report.root_hash
rung: str | None = report.rung
hot_bytes: int | None = report.hot_bytes
cold_bytes: int | None = report.cold_bytes
version: int | None = report.version

EnvelopeReport accessors

PropertyType
verifiedbool
rungstr | None
versionint | None
hot_bytesint | None
cold_bytesint | None
root_hashstr | None

The sidecar DrEnvelope TypedDict (bindings/python/dr-envelope.pyi) mirrors the raw on-disk header shape: magic, version, rung, flags, hot_len, cold_len, root_hash.

Metadata renders

agents_md and skill_md render the AGENTS.md and SKILL.md reconstruction briefs for a report with metadata attached (or a bare bundle dict), returning a str. provenance extracts tool/selection/input metadata as a typed Provenance. Passing a report whose llm slot is None raises DisrobeError.

import disrobe
from disrobe import PyDecompileReport, Provenance

with open("module.pyc", "rb") as fh:
    report: PyDecompileReport = disrobe.py_decompile(fh.read(), pack="pack-2")

agents_brief: str = disrobe.agents_md(report)
skill_brief: str = disrobe.skill_md(report)

prov: Provenance = disrobe.provenance(report)
generated_at: str | None = prov.generated_at
schema: str | None = prov.schema
schema_version: str | None = prov.schema_version

Provenance accessors

PropertyType
schemastr | None
schema_versionstr | None
generated_atstr | None

Python passes

See also Python decompiler for the full decompiler design.

py_decompile

Decompiles a .pyc (with header) to source. Full CPython 3.14 stdlib coverage is

95.09% per-code-object recompile equivalence (17378 of 18276); the pinned

200-module corpus is 96.53% (6068 of 6286, CI floor 96.51%). Legacy CPython 1.0-3.7: 78.5% proven-correct (CI floor 150 of 191; 166 of 191 with the full interpreter zoo present).

import disrobe
from disrobe import PyDecompileReport, RoundtripStatus

with open("module.pyc", "rb") as fh:
    report: PyDecompileReport = disrobe.py_decompile(fh.read(), roundtrip=True)

source: str | None = report.source
marshal_version: str | None = report.marshal_version
decompile_version: str | None = report.decompile_version
recovered_directly: bool = report.recovered_directly
fallback_reason: str | None = report.fallback_reason
status: RoundtripStatus | None = report.roundtrip_status
roundtrip_detail: str | None = report.roundtrip_detail
interpreter_path: str | None = report.interpreter_path
interpreter_version: str | None = report.interpreter_version

if status == "perfect":
    print("recompiled bytecode matched")

Round-tripping (when roundtrip=True) shells out to a matching host interpreter; it is the one binding that may run an external python.

py_disasm

import disrobe
from disrobe import PyDisasmReport

with open("module.pyc", "rb") as fh:
    result: PyDisasmReport = disrobe.py_disasm(fh.read())

marshal_version: str | None = result.marshal_version
instruction_count: int = result.instruction_count
text: str | None = result.text

py_deob, py_deob_detect, py_deob_list_passes, py_deob_detect_pass

import disrobe
from disrobe import ObfuscatorPass, PyDeobDetection, PyDeobReport

obfuscated: str = "exec(__import__('base64').b64decode('cHJpbnQoMSk='))\n"

deob: PyDeobReport = disrobe.py_deob(obfuscated, cleanup=True)
peeled_source: str | None = deob.peeled_source
cleanup_source: str | None = deob.cleanup_source
layer_count: int = deob.layer_count

detection: PyDeobDetection = disrobe.py_deob_detect(obfuscated)
match_count: int = detection.match_count

passes: list[ObfuscatorPass] = disrobe.py_deob_list_passes()
first_id: str | None = passes[0].id if passes else None

per_pass: PyDeobDetection = disrobe.py_deob_detect_pass(obfuscated, "base64-exec")

py_deob_detect_pass raises DisrobeError for an unknown pass_id.

Python pass report classes

ClassNotable typed accessors
PyDecompileReportsource, marshal_version, decompile_version, recovered_directly, fallback_reason, roundtrip_status, roundtrip_detail, interpreter_path, interpreter_version, llm
PyDisasmReportmarshal_version: str | None, instruction_count: int, text: str | None, llm
PyDeobReportpeeled_source: str | None, cleanup_source: str | None, layer_count: int, llm
PyDeobDetectionmatch_count: int, llm
ObfuscatorPassid: str | None

PyArmor

pyarmor_detect

Parses a PyArmor wrapper from source text.

import disrobe
from disrobe import PyarmorDetection

detection: PyarmorDetection = disrobe.pyarmor_detect(open("wrapped.py").read())
version: str | None = detection.version
protection: str | None = detection.protection
confidence: str | None = detection.confidence
serial: str | None = detection.serial
python_major: int | None = detection.python_major
python_minor: int | None = detection.python_minor
payload_offset: int | None = detection.payload_offset
payload_size: int | None = detection.payload_size

pyarmor_unpack

Statically unpacks a PyArmor wrapper image. On the 72 / 72 manifest-named v8/v9 default-trial wrappers, the static path decrypts each body and decodes one complete root CodeObject from its header-anchored marshal stream. This structural check does not compare source, emitted .pyc bytes, semantic or execution behavior, or external-tool output. The bindings expose only the static path; there is no --allow-dynamic equivalent.

import disrobe
from disrobe import PyarmorUnpack, PyarmorUnpackStatus

with open("wrapper.pyc", "rb") as fh:
    unpacked: PyarmorUnpack = disrobe.pyarmor_unpack(fh.read())

status: PyarmorUnpackStatus | None = unpacked.status
pyarmor_version: str | None = unpacked.pyarmor_version
protection_kind: str | None = unpacked.protection_kind
plaintext_len: int | None = unpacked.plaintext_len
digest: str | None = unpacked.plaintext_blake3_hex
bcc_blob_count: int | None = unpacked.bcc_blob_count
inner_cipher_recovered_co: int | None = unpacked.inner_cipher_recovered_co

pyarmor_classify

import disrobe
from disrobe import PyarmorClassification

with open("payload.bin", "rb") as fh:
    payload: bytes = fh.read()

classification: PyarmorClassification = disrobe.pyarmor_classify(open("wrapped.py").read(), payload)
script_type: str | None = classification.script_type
bootstrap_import: str | None = classification.bootstrap_import
disposition: str | None = classification.disposition
rft_enabled: bool = classification.rft_enabled
ecc_enabled: bool = classification.ecc_enabled

The sidecar PyarmorDetection TypedDict (bindings/python/pyarmor-detection.pyi) names the confidence and protection Literal values used in the raw dict.

PyArmor report classes

ClassNotable typed accessors
PyarmorDetectionversion, protection, confidence, serial, python_major, python_minor, payload_offset, payload_size, llm
PyarmorUnpackstatus, pyarmor_version, protection_kind, plaintext_len, plaintext_blake3_hex, bcc_blob_count, inner_cipher_recovered_co, llm
PyarmorClassificationscript_type, bootstrap_import, disposition, rft_enabled, ecc_enabled

PyInstaller and Nuitka

pyinstaller_extract / pyinstaller_entry_bytes

import disrobe
from disrobe import PyInstallerArchive

with open("app.exe", "rb") as fh:
    image: bytes = fh.read()

archive: PyInstallerArchive = disrobe.pyinstaller_extract(image)
entry_count: int = archive.entry_count
encrypted: bool = archive.encrypted
encryption_key_present: bool = archive.encryption_key_present
python_major: int | None = archive.python_major
python_minor: int | None = archive.python_minor

entries: list[dict[str, object]] = archive.raw["entries"]
main_payload: bytes = disrobe.pyinstaller_entry_bytes(image, str(entries[0]["name"]))

nuitka_detect / nuitka_extract

import disrobe
from disrobe import NuitkaDetection, NuitkaExtraction

with open("app.exe", "rb") as fh:
    image: bytes = fh.read()

det: NuitkaDetection = disrobe.nuitka_detect(image)
flavor: str | None = det.flavor
version: str | None = det.version
wheel_marker: str | None = det.wheel_marker
onefile_payload_offset: int | None = det.onefile_payload_offset
onefile_payload_compressed: bool = det.onefile_payload_compressed

extraction: NuitkaExtraction = disrobe.nuitka_extract(image)
variant: str | None = extraction.variant

The sidecar FreezerManifest TypedDict (bindings/python/freezer-manifest.pyi) describes the manifest schema for cx-freeze/py2exe/shiv/pex/py-oxidizer/briefcase freezer families; reach it via report.raw.

PyInstaller/Nuitka report classes

ClassNotable typed accessors
PyInstallerArchiveentry_count: int, encrypted: bool, encryption_key_present: bool, python_major: int | None, python_minor: int | None, llm
NuitkaDetectionflavor: str | None, version: str | None, wheel_marker: str | None, onefile_payload_offset: int | None, onefile_payload_compressed: bool, llm
NuitkaExtractionvariant: str | None, llm

Hermes (React Native)

All 8 functions in the committed hermesc-built HBC v96 sample lift at 100% op-coverage with 0 fallback ops. A 122,633-function production React Native bundle parses with no module-parse failure in the local scale harness.

import disrobe
from disrobe import HermesDisassembly, HermesLift, HermesInfo

with open("index.android.bundle", "rb") as fh:
    bundle: bytes = fh.read()

disasm_result: HermesDisassembly = disrobe.hermes_disasm(bundle)
function_count: int = disasm_result.function_count
identifier_count: int = disasm_result.identifier_count
string_count: int = disasm_result.string_count

lift: HermesLift = disrobe.hermes_lift(bundle)
function_surface_count: int = lift.function_surface_count

info: HermesInfo = disrobe.hermes_info(bundle)
version: int | None = info.version
header_size: int | None = info.header_size

Hermes report classes

ClassNotable typed accessors
HermesDisassemblyfunction_count: int, identifier_count: int, string_count: int, llm
HermesLiftfunction_surface_count: int, string_count: int, identifier_count: int, llm
HermesInfoversion: int | None, function_count: int | None, string_count: int | None, header_size: int | None, llm

Mach-O and Swift

import disrobe
from disrobe import MachoReport, SwiftReport

with open("universal.dylib", "rb") as fh:
    data: bytes = fh.read()

macho: MachoReport = disrobe.macho_dump(data)
kind: str | None = macho.kind
fat_entry_count: int = macho.fat_entry_count
slice_count: int = macho.slice_count

swift: SwiftReport = disrobe.swift_analyze(data)
container: str | None = swift.container
swift_fat_entry_count: int = swift.fat_entry_count
swift_slice_count: int = swift.slice_count

Mach-O report classes

ClassNotable typed accessors
MachoReportkind: str | None, fat_entry_count: int, slice_count: int, llm
SwiftReportcontainer: str | None, fat_entry_count: int, slice_count: int, llm

JVM and Android

131 of 131 JVM methods recompile error-free under javac (CI floor 131 of 131, JDK 25). 100% of verifier-presented committed DEX classes pass -Xverify:all, with 37 of 155 classes link-skipped and never reaching the verifier.

import disrobe
from disrobe import (
    JvmClass, DexFileReport, JvmDecompiledClass, JvmDecompiledDex,
    DetectionList, JvmBackends, ApkResources,
)

with open("Hello.class", "rb") as fh:
    cls: JvmClass = disrobe.jvm_parse_class(fh.read())
major_version: int | None = cls.major_version
minor_version: int | None = cls.minor_version
method_count: int = cls.method_count
field_count: int = cls.field_count
constant_pool_count: int = cls.constant_pool_count

with open("classes.dex", "rb") as fh:
    dex: DexFileReport = disrobe.jvm_parse_dex(fh.read())
class_count: int = dex.class_count
dex_method_count: int = dex.method_count

with open("Hello.class", "rb") as fh:
    decompiled: JvmDecompiledClass = disrobe.jvm_decompile_class(fh.read())
source: str | None = decompiled.source
fully_lifted_methods: int = decompiled.fully_lifted_methods
fallback_methods: int = decompiled.fallback_methods

with open("classes.dex", "rb") as fh:
    decompiled_dex: JvmDecompiledDex = disrobe.jvm_decompile_dex(fh.read())
dex_source: str | None = decompiled_dex.source
source_count: int = decompiled_dex.source_count
dex_fully_lifted_methods: int = decompiled_dex.fully_lifted_methods
dex_fallback_methods: int = decompiled_dex.fallback_methods

detections: DetectionList = disrobe.jvm_detect(open("obf.class", "rb").read())
detection_count: int = detections.count

backends: JvmBackends = disrobe.jvm_backends()
jvm_count: int = backends.jvm_count
android_count: int = backends.android_count

with open("app.apk", "rb") as fh:
    apk: ApkResources = disrobe.apk_resources(fh.read())
package: str | None = apk.package
manifest_xml: str | None = apk.manifest_xml
resource_entry_count: int = apk.resource_entry_count
certificate_count: int = apk.certificate_count
dex_count: int = apk.dex_count

jvm_backends and dotnet_backends probe the host for installed external tools but never shell out to them. Counts are informational only.

JVM/Android report classes

ClassNotable typed accessors
JvmClassmajor_version: int | None, minor_version: int | None, method_count: int, field_count: int, constant_pool_count: int, llm
DexFileReportstring_count: int, type_count: int, class_count: int, method_count: int, llm
JvmDecompiledClasssource: str | None, method_count: int, field_count: int, fully_lifted_methods: int, fallback_methods: int
JvmDecompiledDexsource: str | None, source_count: int, class_count: int, method_count: int, fully_lifted_methods: int, fallback_methods: int
DetectionListcount: int
JvmBackendsjvm_count: int, android_count: int, llm
ApkResourcespackage: str | None, manifest_xml: str | None, resource_entry_count: int, certificate_count: int, dex_count: int, llm

.NET

import disrobe
from disrobe import (
    DotnetPe, DotnetMetadata, DotnetDetection,
    DotnetAnalysis, DotnetDecompilation, DotnetDecoders, DotnetNativeAot, BackendList,
)

with open("Sample.dll", "rb") as fh:
    pe_bytes: bytes = fh.read()

pe: DotnetPe = disrobe.dotnet_parse_pe(pe_bytes)
bitness: str | None = pe.bitness
machine: int | None = pe.machine
section_count: int = pe.section_count
entry_point_rva: int | None = pe.entry_point_rva

metadata: DotnetMetadata = disrobe.dotnet_parse_metadata(pe_bytes)
version: str | None = metadata.version
major_runtime_version: int | None = metadata.major_runtime_version
stream_count: int = metadata.stream_count

detection: DotnetDetection = disrobe.dotnet_detect(pe_bytes)
primary: str | None = detection.primary
match_count: int = detection.match_count

analysis: DotnetAnalysis = disrobe.dotnet_analyze(pe_bytes)
pe_bitness: str | None = analysis.pe_bitness
native_aot: bool = analysis.native_aot
primary_protector: str | None = analysis.primary_protector
opcode_spec_coverage_pct: int | None = analysis.opcode_spec_coverage_pct

decompilation: DotnetDecompilation = disrobe.dotnet_decompile(pe_bytes)
module_name: str | None = decompilation.module_name
methods_decompiled: int | None = decompilation.methods_decompiled
methods_bodyless: int | None = decompilation.methods_bodyless
methods_failed: int | None = decompilation.methods_failed

decoders: DotnetDecoders = disrobe.dotnet_recover_decoders(pe_bytes)
pure_decoders_found: int | None = decoders.pure_decoders_found
constants_recovered: int = decoders.constants_recovered

with open("HelloAppAot.exe", "rb") as fh:
    image_bytes: bytes = fh.read()

native_aot: DotnetNativeAot = disrobe.dotnet_native_aot(image_bytes)
runtime_label: str | None = native_aot.runtime_label
method_count: int = native_aot.method_count
methods_with_code_range: int = native_aot.methods_with_code_range
managed_signature_count: int = native_aot.managed_signature_count
register_signature_count: int = native_aot.register_signature_count

backend_list: BackendList = disrobe.dotnet_backends()
available_count: int = backend_list.available_count

dotnet_native_aot reads an ahead-of-time compiled .NET image rather than a managed assembly, so it accepts PE, ELF and Mach-O input. It never raises on a file that is not a NativeAOT image: is_native_aot is then false and every count is zero.

managed_signature_count and register_signature_count partition the method bodies disrobe recovered. A body counts as managed when its prototype was reattached from the image's own managed signature. It counts as registers when reattachment abstained and the body kept the types the machine-code lifter inferred from register use, which happens for generics, varargs, a hidden struct return, and the other cases the pass declines rather than guesses. The two counts separate a prototype disrobe sourced from metadata from one it could only infer, so read them as a pair.

.NET report classes

ClassNotable typed accessors
DotnetPebitness: str | None, machine: int | None, section_count: int, entry_point_rva: int | None, llm
DotnetMetadataversion: str | None, major_runtime_version: int | None, stream_count: int, llm
DotnetDetectionprimary: str | None, match_count: int, llm
DotnetAnalysispe_bitness: str | None, clr_runtime_version: str | None, native_aot: bool, primary_protector: str | None, opcode_spec_coverage_pct: int | None, llm
DotnetDecompilationmodule_name: str | None, methods_decompiled: int | None, methods_bodyless: int | None, methods_failed: int | None, llm
DotnetDecoderspure_decoders_found: int | None, constants_recovered: int, llm
DotnetNativeAotis_native_aot: bool, runtime_label: str | None, recovered_symbol_count: int, recovered_name_count: int, eager_class_constructors: int | None, type_count: int, method_count: int, methods_with_entrypoint: int, methods_with_code_range: int, methods_with_body: int, managed_signature_count: int, register_signature_count: int, llm

WebAssembly

1034 of 1034 opcodes lowered across the 38 parseable corpus modules (133 of 133 functions), against an instruction inventory wasm-tools produced rather than disrobe. 57 of 57 execution-eligible functions are execution-equivalent under wasmtime.

import disrobe
from disrobe import WasmAnalysis, WasmDetection

with open("module.wasm", "rb") as fh:
    wasm_bytes: bytes = fh.read()

analysis: WasmAnalysis = disrobe.wasm_analyze(wasm_bytes)
import_count: int = analysis.import_count
export_count: int = analysis.export_count
func_count: int | None = analysis.func_count
code_size_bytes: int | None = analysis.code_size_bytes
has_dwarf: bool = analysis.has_dwarf

detection: WasmDetection = disrobe.wasm_detect(wasm_bytes)
obfuscator: str | None = detection.obfuscator
confidence: float | None = detection.confidence
has_name_section: bool = detection.has_name_section
function_count: int | None = detection.function_count

WebAssembly report classes

ClassNotable typed accessors
WasmAnalysisimport_count: int, export_count: int, func_count: int | None, code_size_bytes: int | None, has_dwarf: bool, llm
WasmDetectionobfuscator: str | None, confidence: float | None, has_name_section: bool, function_count: int | None, llm

JavaScript

Detects 11 bundlers. The explicit target hint accepts auto, webpack4, webpack5/webpack, vite, rollup, esbuild, turbopack, and bun. An unrecognized hint string raises DisrobeError.

import disrobe
from disrobe import JsDetection, JsUnminify, JsUnbundle

source: str = open("main.js").read()

detection: JsDetection = disrobe.js_detect(source)
family: str | None = detection.family
confidence: float | None = detection.confidence
marker_count: int = detection.marker_count

unminified: JsUnminify = disrobe.js_unminify(source)
recovered_source: str | None = unminified.source

bundle_source: str = open("bundle.js").read()
unbundled: JsUnbundle = disrobe.js_unbundle(bundle_source)
module_count: int = unbundled.module_count
bundler: str | None = unbundled.bundler

unbundled_hinted: JsUnbundle = disrobe.js_unbundle(bundle_source, bundler="webpack5")

JavaScript report classes

ClassNotable typed accessors
JsDetectionfamily: str | None, confidence: float | None, marker_count: int, llm
JsUnminifysource: str | None, llm
JsUnbundlemodule_count: int, bundler: str | None, llm

Lua

Detects, decompiles, and deobfuscates 14 Lua obfuscator families, with 16 catalog entries once Luau and GLua dialect detectors are included. IronBrew2 2.7.0 is reversed against real committed output with a Lua execution differential.

import disrobe
from disrobe import LuaDetection, LuaDecompilation, LuaDeobfuscation

with open("chunk.luac", "rb") as fh:
    bytecode: bytes = fh.read()

det: LuaDetection = disrobe.lua_detect(bytecode)
lua_format: str | None = det.format

decompiled: LuaDecompilation = disrobe.lua_decompile(bytecode)
decompiled_source: str | None = decompiled.source
fidelity: str | None = decompiled.fidelity
warning_count: int = decompiled.warning_count

deob: LuaDeobfuscation = disrobe.lua_deobfuscate(open("obf.lua").read(), authorize=True)
obfuscator: str | None = deob.obfuscator
deobfuscated: str | None = deob.deobfuscated
fully_recovered: bool = deob.fully_recovered
passes_run_count: int = deob.passes_run_count
recovered_string_count: int = deob.recovered_string_count

Lua report classes

ClassNotable typed accessors
LuaDetectionformat: str | None, llm
LuaDecompilationsource: str | None, fidelity: str | None, warning_count: int, llm
LuaDeobfuscationobfuscator: str | None, deobfuscated: str | None, fully_recovered: bool, passes_run_count: int, recovered_string_count: int, llm

Go

85%+ type-name recovery on stripped go1.26 fixtures; 838 of 838 measured, pinned by the gate.
import disrobe
from disrobe import GoAnalysis, GoSymbols, GoPclntab, GarbleReport

with open("binary", "rb") as fh:
    go_bytes: bytes = fh.read()

analysis: GoAnalysis = disrobe.go_analyze(go_bytes)
image_kind: str | None = analysis.image_kind
pclntab_version: str | None = analysis.pclntab_version
buildversion: str | None = analysis.buildversion
ptr_size: int | None = analysis.ptr_size

symbols: GoSymbols = disrobe.go_symbols(go_bytes)
version_label: str | None = symbols.version_label
function_count: int = symbols.function_count
source_file_count: int = symbols.source_file_count
package_count: int = symbols.package_count

pclntab: GoPclntab = disrobe.go_pclntab(go_bytes)
version: str | None = pclntab.version
func_count: int | None = pclntab.func_count

garble: GarbleReport = disrobe.go_garble(go_bytes)
quality: str | None = garble.quality
detection_score: int | None = garble.detection_score
seed_recoverable: bool = garble.seed_recoverable
seed_hash: str | None = garble.seed_hash
recovered_string_count: int = garble.recovered_string_count

Go report classes

ClassNotable typed accessors
GoAnalysisimage_kind: str | None, pclntab_version: str | None, buildversion: str | None, ptr_size: int | None, llm
GoSymbolsversion_label: str | None, function_count: int, source_file_count: int, package_count: int, llm
GoPclntabversion: str | None, ptr_size: int | None, func_count: int | None, image_kind: str | None, llm
GarbleReportquality: str | None, detection_score: int | None, seed_recoverable: bool, seed_hash: str | None, recovered_string_count: int, llm

Ruby

import disrobe
from disrobe import RubyDetection, RubyAnalysis

with open("hello.rb.enc", "rb") as fh:
    ruby_bytes: bytes = fh.read()

det: RubyDetection = disrobe.ruby_detect(ruby_bytes, source_path="hello.rb.enc")
flavor: str | None = det.flavor

analysis: RubyAnalysis = disrobe.ruby_decompile(ruby_bytes, source_path="hello.rb.enc")
ruby_flavor: str | None = analysis.flavor
source_path: str | None = analysis.source_path
input_len: int | None = analysis.input_len

Ruby report classes

ClassNotable typed accessors
RubyDetectionflavor: str | None, llm
RubyAnalysisflavor: str | None, source_path: str | None, input_len: int | None, llm

PHP

import disrobe
from disrobe import PhpDetection, PhpScan, PhpDecode

with open("obfuscated.php", "rb") as fh:
    php_bytes: bytes = fh.read()

det: PhpDetection = disrobe.php_detect(php_bytes)
kind: str | None = det.kind
confidence: str | None = det.confidence
open_tag_offset: int | None = det.open_tag_offset
has_halt_compiler: bool = det.has_halt_compiler

scan: PhpScan = disrobe.php_scan(php_bytes)
hit_count: int = scan.hit_count
family_count: int = scan.family_count

decoded: PhpDecode = disrobe.php_decode(php_bytes, max_depth=10)
php_source: str | None = decoded.source
layer_count: int = decoded.layer_count
residual_eval: bool = decoded.residual_eval

PHP report classes

ClassNotable typed accessors
PhpDetectionkind: str | None, confidence: str | None, open_tag_offset: int | None, has_halt_compiler: bool, llm
PhpScanhit_count: int, family_count: int, llm
PhpDecodesource: str | None, layer_count: int, residual_eval: bool, llm

Shell

import disrobe
from disrobe import BatchDeobReport, PowershellDetection, PowershellDeobfuscation

batch_script: str = open("dropper.bat").read()
batch_result: BatchDeobReport = disrobe.batch_deobfuscate(batch_script, args=["/run"])
output: str | None = batch_result.output
embedded_payload_count: int = batch_result.embedded_payload_count
decrypted_stage_count: int = batch_result.decrypted_stage_count
commands_emulated: int | None = batch_result.commands_emulated

ps_script: str = open("obf.ps1").read()
ps_det: PowershellDetection = disrobe.powershell_detect(ps_script)
obfuscator: str | None = ps_det.obfuscator
ps_confidence: float | None = ps_det.confidence
marker_count: int = ps_det.marker_count

ps_deob: PowershellDeobfuscation = disrobe.powershell_deobfuscate(ps_script)
ps_output: str | None = ps_deob.output
level: str | None = ps_deob.level
transformation_count: int = ps_deob.transformation_count

Shell report classes

ClassNotable typed accessors
BatchDeobReportoutput: str | None, embedded_payload_count: int, decrypted_stage_count: int, commands_emulated: int | None, llm
PowershellDetectionobfuscator: str | None, confidence: float | None, marker_count: int, llm
PowershellDeobfuscationoutput: str | None, level: str | None, transformation_count: int, llm

Containers

102 container families are detected. Of these, 101 carry a generic in-tree extractor and LUKS1 carries a bounded raw-volume-key extraction route. 41 of them are driven to member

bytes by an input this repository commits. See container docs for the full family list.

import disrobe
from disrobe import ContainerDetection, ContainerMembers, ContainerListing

with open("archive.zip", "rb") as fh:
    container_bytes: bytes = fh.read()

det: ContainerDetection = disrobe.container_detect(container_bytes)
detected: bool = det.detected
kind: str | None = det.kind
is_zip_family: bool = det.is_zip_family

members: ContainerMembers = disrobe.container_members(container_bytes)
fmt: str | None = members.format
size: int | None = members.size
listing: ContainerListing | None = members.listing
entry_count: int = members.entry_count

Container report classes

ClassNotable typed accessors
ContainerDetectiondetected: bool, kind: str | None, is_zip_family: bool, llm
ContainerMembersformat: str | None, size: int | None, listing: ContainerListing | None, entry_count: int, llm

Pickle

Nothing is ever unpickled; the VM is symbolic.

import disrobe
from disrobe import (
    PickleDecompilation, PickleSafety, PickleTrace,
    PicklePolyglot, PickleMlReport,
)

with open("model.pkl", "rb") as fh:
    pkl: bytes = fh.read()

listing: str = disrobe.pickle_disasm(pkl)

decompilation: PickleDecompilation = disrobe.pickle_decompile(pkl)
pkl_source: str | None = decompilation.source

safety: PickleSafety = disrobe.pickle_safety(pkl)
severity: str | None = safety.severity
finding_count: int = safety.finding_count
import_count: int = safety.import_count
reduce_count: int | None = safety.reduce_count

trace: PickleTrace = disrobe.pickle_trace(pkl)
protocol: int | None = trace.protocol
memo_count: int | None = trace.memo_count
max_stack_depth: int | None = trace.max_stack_depth
global_ref_count: int = trace.global_ref_count
trace_reduce_count: int | None = trace.reduce_count

polyglot: PicklePolyglot = disrobe.pickle_polyglot(pkl)
is_pickle: bool = polyglot.is_pickle
is_polyglot: bool = polyglot.is_polyglot
kind_count: int = polyglot.kind_count

with open("model.pt", "rb") as fh:
    ml_report: PickleMlReport = disrobe.pickle_ml_detect(fh.read())
fmt: str | None = ml_report.format
framing: str | None = ml_report.framing
embedded_count: int = ml_report.embedded_count

Pickle report classes

ClassNotable typed accessors
PickleDecompilationsource: str | None, llm
PickleSafetyseverity: str | None, finding_count: int, import_count: int, reduce_count: int | None, llm
PickleTraceprotocol: int | None, memo_count: int | None, max_stack_depth: int | None, global_ref_count: int, reduce_count: int | None, llm
PicklePolyglotis_pickle: bool, is_polyglot: bool, kind_count: int, llm
PickleMlReportformat: str | None, framing: str | None, embedded_count: int, llm

Editable IR objects

CodeObject, Instruction, and Symbol let you load a Disasm- or Raw-rung .dr envelope, modify it in Python, and write a fresh integrity-hashed .dr.

Instruction

from disrobe import Instruction, InstructionFlow

instr: Instruction = Instruction(
    offset=0,
    mnemonic="mov",
    operands=["rax", "rbx"],
    bytes=b"\x48\x89\xd8",
)
instr.branch_target = None
flow: InstructionFlow = instr.flow
text: str = instr.text()
MemberTypeNotes
offsetintMutable
mnemonicstrMutable
operandslist[str]Mutable
bytesbytesMutable
branch_targetint | NoneMutable
flow@property InstructionFlowRead-only
text()-> strRendered disassembly line

Symbol

from disrobe import Symbol, SymbolKind

sym: Symbol = Symbol(address=0x1000, name="entry", kind="function")
sym.name = "main"
sym.kind = "export"
MemberTypeNotes
addressintMutable
namestrMutable
kindSymbolKindMutable

CodeObject

import disrobe
from disrobe import CodeObject, Instruction, Symbol

with open("module.dr", "rb") as fh:
    co: CodeObject = CodeObject.from_dr(fh.read())

instruction_count: int = co.instruction_count
symbol_count: int = co.symbol_count
source_hash: str = co.source_hash
produced_by: str = co.produced_by

instrs: list[Instruction] = co.instructions
syms: list[Symbol] = co.symbols
metadata: dict[str, str] = co.metadata
capabilities: list[str] = co.capabilities
llm_bundle: dict[str, object] | None = co.llm

new_sym: Symbol = Symbol(address=0x2000, name="renamed_fn", kind="function")
co.add_symbol(new_sym)
co.set_metadata("analysis", "patched")
co.add_capability("NETWORK_CONNECT", 1)

fresh_dr: bytes = co.to_dr()
with open("module_patched.dr", "wb") as fh:
    fh.write(fresh_dr)

CodeObject.from_dr parses a Disasm- or Raw-rung .dr envelope. to_dr produces a fresh envelope with a recomputed integrity hash. The set_instructions and set_symbols methods replace the full list; add_instruction / add_symbol append. set_metadata(key, value) sets a single string key; clear_metadata resets all. set_llm(sidecar) attaches or removes the metadata sidecar dict.

MemberSignatureNotes
from_drstaticmethod(dr_bytes: bytes) -> CodeObjectParse existing envelope
instructions@property -> list[Instruction]
set_instructions(instructions: list[Instruction]) -> NoneReplace
add_instruction(instruction: Instruction) -> NoneAppend
symbols@property -> list[Symbol]
set_symbols(symbols: list[Symbol]) -> NoneReplace
add_symbol(symbol: Symbol) -> NoneAppend
instruction_count@property -> int
symbol_count@property -> int
source_hashstrMutable attribute
produced_bystrMutable attribute
metadata@property -> dict[str, str]
set_metadata(key: str, value: str) -> None
clear_metadata() -> None
capabilities@property -> list[str]
add_capability(name: str, major: int) -> None
llm@property -> dict[str, Any] | None
set_llm(sidecar: dict[str, Any] | None) -> None
to_dr() -> bytesProduce fresh integrity-hashed envelope

Scope

  • No file or directory handling: no --out trees, no --capture-stages, no container extraction to disk. auto returns the plan document only.
  • External backend tools (jvm_backends, dotnet_backends, native_probe_backends) are probed for availability but never executed.
  • AS3, Flutter, BEAM (beyond disasm/parse), and the freezer family beyond PyInstaller/Nuitka have no dedicated bindings in this release.
  • No SARIF/NDJSON emitters and no serve daemon; drive the CLI or daemon directly.

The browser playground

The playground runs disrobe in your browser. The analysis passes are compiled to WebAssembly (the disrobe-wasm crate) and execute entirely client-side: you drop in a file, it is analyzed in the page, and nothing is uploaded. There is no server, no telemetry, and no account.

What it does

The playground exposes the read-only analysis surface, the operations that are safe to run on an untrusted file without writing to disk or executing the sample:

InputOperations
Python .pycDisassemble and decompile to source.
Python pickle (.pkl, .pt)Static disasm, symbolic trace, safety grading (scan for a REDUCE against a code-execution sink), and polyglot detection. Never unpickles.
WebAssembly .wasmModule summary and obfuscator detection.
Lua chunkDialect detection and decompile.
PyArmor-wrapped sourceVersion detection and protection-mode classification.
Any binaryString extraction, IOC scan (defanged), and a behavior summary.

Everything is the same code the CLI runs, compiled to a different target. A pickle graded overtly_malicious in the playground grades the same way under disrobe pickle safety on the command line.

Why it is safe

The playground inherits disrobe's static, deterministic posture. The pickle suite is symbolic: it walks the opcode stream and reconstructs the object graph without importing a module or calling __reduce__. The Python and WASM paths parse and lower bytecode without running it. The one thing the browser build deliberately omits is any code-execution path (the PyArmor dynamic hook and the BCC native lift), so there is nothing in the page that can run the sample.

Because the build is deterministic, a given file produces the same result on every load, which is what makes the playground usable as a quick triage step rather than a toy. For the full pass set, the chain runner, and the writeable emits, use the CLI or the library.

Forensics and malware-safety posture

Analysts run disrobe against hostile input that must not detonate. Everything below states what executes and what does not, so you can decide what to run inside a sandbox.

The default is static analysis, no sample execution

By default, disrobe does not execute the sample. Every default path is pure static analysis: it parses bytes, decodes bytecode, walks structures, and emits derived artifacts. It does not unpickle, does not call __reduce__, does not run a packed binary, does not invoke a sample's entry point.

This holds for the entire pickle suite in particular. disrobe pickle trace runs a symbolic VM: it walks the opcode stream and builds the object graph without instantiating a single real object or resolving a single real global. disrobe pickle safety grades danger statically. You can audit a downloaded .pt or .pkl for what it would do on load without ever letting it load.

Opt-in gated paths

Only --allow-dynamic executes sample code. --allow-bcc enables additional static analysis. Neither path is enabled by default.

PathGateWhat it does
PyArmor v6/v7 dynamic-hook--allow-dynamicRuns the obfuscated wrapper in a watched subprocess to capture marshal streams. Watchdog timeout via --dynamic-timeout (default 60s).
PyArmor BCC native-body analysis--allow-bccParses extracted BCC objects in-process and attempts x86-64 pseudo-C analysis. It does not execute the sample or invoke Ghidra.

Once wrapper and runtime discovery succeeds, omitting --allow-bcc makes a detected BCC unpack return DR-PYARM-0050 before version-specific unpacking. Native builds use the Microsoft x64 ABI for Windows x86-64, the System V ABI for Linux x86-64, and AAPCS64 for Darwin ARM64. Unknown architecture IDs produce a typed refusal instead of selecting an ABI. Wasm builds record that native lifting is unavailable. The dedicated command and path-aware automatic extraction publish the same bounded recovery JSON, pseudo-C, and recovered Python skeleton. Each unmodeled function retains its native disassembly and typed reason.

If you must use --allow-dynamic, do it inside an isolated sandbox (a disposable VM or container with no network and no access to anything you care about). disrobe gives you the watchdog timeout and a captured-marshal manifest, but a dynamic hook is, by definition, executing adversarial code. The non-BCC v8/v9 paths remain static and need no execution opt-in. BCC analysis is also static but separately gated.

Subprocess backends

The optional external backends (Ghidra, CFR, Vineflower, jadx, ILSpy, dnSpy, de4dot, Rizin) run as subprocesses over the artifact, not by executing the sample's own logic. Command lines are constructed from configuration and sometimes user input; command injection or argument smuggling there is in scope for the security policy.

Hardened parsing surface

disrobe parses adversarial binary input constantly, so the parsing surface is hardened deliberately:

  • Format decoders avoid unsafe. The remaining unsafe code is limited to audited boundary code such as C interop, WASM exports, archive/io shims, build/install helpers, and native-loader interfaces. Any panic or abort on adversarial input that is not a clean Result::Err is a bug.
  • Zip-bombs, decompression bombs, container-recursion bombs, and malformed-length-field bombs are defused by the shared quota machinery in crates/disrobe-binfmt/src/quota.rs, whose per-entry ratio the phar reader mirrors in-crate rather than calling (per-entry cap, aggregate cap, recursion-depth cap).
  • zip-slip and equivalent path traversals are sanitized on every container extraction path.
  • The .dr envelope decoder is fuzzed. Read-past-end, integer overflow, and BLAKE3-mismatch acceptance are all in scope.
  • A depth cap (default 8) and content-hash cycle detection stop a malicious input from making a chain recurse forever.

Reporting

A way to make a default path execute a sample, escape a container, or crash the parser is a security issue. Report it privately, never as a public issue. See Security.

Threat model

This page is the explicit trust-boundary analysis for disrobe. It states what the tool treats as untrusted, where the boundaries are drawn, what each boundary defends against, and what is deliberately out of scope. It complements the operational Security policy and the Forensics and malware-safety posture: the security policy says how to report a problem and what is in scope for a report; this page says what the design assumes an attacker can do and where the walls are.

The single load-bearing assumption is this: the input is hostile. disrobe exists to parse protector output, packed executables, obfuscated bytecode, and exotic encoders. Every byte of every sample is treated as attacker-controlled. The analyst running disrobe is trusted; the artifact they point it at is not.

Trust boundaries at a glance

   ┌─────────────────────────── trusted ───────────────────────────┐
   │  analyst, host filesystem, disrobe binary, configuration       │
   └───────────────▲───────────────────────────────▲───────────────┘
                   │ B1                             │ B2
   ┌───────────────┴───────────────┐   ┌────────────┴───────────────┐
   │  untrusted sample bytes       │   │  untrusted .dr envelope     │
   │  (file / bytes_b64 / stdin)   │   │  (cache hit, peer-supplied) │
   └───────────────────────────────┘   └────────────────────────────┘
                   │ B3                             │ B4
   ┌───────────────┴───────────────┐   ┌────────────┴───────────────┐
   │  network surface              │   │  subprocess backends +      │
   │  (serve: HTTP / gRPC / LSP)   │   │  optional sample execution  │
   └───────────────────────────────┘   └────────────────────────────┘

There are four boundaries. Boundary 1 (sample bytes) and Boundary 2 (envelope bytes) are always present. Boundary 3 (network) is present only when disrobe serve is running. Boundary 4 (subprocess and dynamic execution) is present only when an explicit opt-in flag is passed.

Boundary 1: untrusted sample bytes

Trusted side: the disrobe process, the host, the analyst's intent. Untrusted side: the sample. It arrives as a filesystem path, a bytes_b64 blob over the daemon, or stdin. The parser must assume every length field, offset, opcode, and nested container is chosen by an adversary to break it.

What this boundary defends against, and how:

ThreatDefenseWhere
Memory-corruption via the parserRust-first decoders with unsafe excluded from format parsing; remaining unsafe code is isolated to audited boundary code such as C interop, WASM exports, archive/io shims, build/install helpers, and native-loader interfaces.workspace lint config
Panic / abort on adversarial inputAny non-Result::Err failure on hostile bytes is a bug. Decoders return errors, they do not unwrap.every disrobe-pass-* decoder
Decompression and zip bombsPer-entry cap, aggregate cap, and an observed-ratio ceiling in the shared quota machinery.crates/disrobe-binfmt/src/quota.rs
Path traversal (zip-slip and kin)Every container extraction path routes through sanitize_entry_path and siblings before any write.crates/disrobe-binfmt/src/quota.rs
Container-recursion bombsRecursion-depth cap plus content-hash cycle detection in the chain runner (default depth 8).chain runner
Malformed-length-field bombsLength fields are validated against remaining buffer length before allocation; no length field is trusted to size an allocation.binfmt + envelope decoder
Signature defeat (scrambled magic, renamed sections, corrupted markers)Detection falls back from magic to self-consistent internal structure, which an adversary cannot break without breaking the file's own functionality.crates/disrobe-binfmt/src/structural.rs

The envelope decoder and the container layer are the two most-exposed parsing surfaces and are fuzzed.

Signature defeat and header scrambling

A common evasion against signature-based detectors and unpackers is to scramble the parts a fast scanner keys on: flip the MZ of a PE, zero the \x7fELF of an ELF, mangle the Mach-O / DEX / class-file / wasm magic, rename UPX0/UPX1 and corrupt the UPX! marker. These edits defeat a tool that identifies a format by a leading magic byte or a section name, but they do not change what the file actually is: the loader, the OS, or the runtime still has to find the real structure to execute it, so the structure is the one thing the adversary cannot remove.

disrobe identifies a format by magic on the fast path, then falls back to structural validation when the magic is absent or wrong. The fallback parses far enough into the format's own header tables to confirm they refer to one another consistently, which keeps false positives low (a loose pattern match would not satisfy a full cross-referenced walk):

  • PE. Resolve e_lfanew to a PE\0\0 signature, then a COFF header with a known machine type, a PE32/PE32+ optional header, and a section table that fits the file. A corrupted e_lfanew itself is recovered by scanning for the PE\0\0 whose following headers validate, so a flipped MZ and a mangled e_lfanew together still parse.
  • ELF. Validate the class / endianness / version bytes and confirm the program- and section-header table offsets, entry sizes, and counts are self-consistent against the declared entry sizes and the file length. A zeroed \x7fELF does not move any of those fields.
  • Mach-O. Walk the load-command stream (ncmds / sizeofcmds and each cmdsize) for a single-arch image, or the arch offset/size table for a fat image, accepting only when the run lands exactly at its declared end.
  • Native packers (UPX). Detect and unpack by the decompressor stub's PackHeader (a known method id, self-consistent compressed/uncompressed lengths, a plausible version) located by structural scan rather than by the UPX! marker, and resolve packed-section data through the structural PE header rather than a literal MZ. A renamed-section, corrupted-marker UPX still unpacks byte-identically.
  • ZIP and zip-family archives. Anchor on the End-of-Central-Directory record (the format's authoritative trailer) and confirm its central-directory offset and size land on a record carrying the central-directory-header signature. A scrambled first local header does not move the EOCD.
  • DEX. Confirm header_size == 0x70, a legal endian tag, and string / type / proto / method / class section sizes and offsets self-consistent against file_size and the byte length; a zeroed dex\n0XX\0 magic still parses, defaulting the version when the version triple is unreadable.
  • JVM class file. Confirm a major version in the JVM-known range and walk the constant pool (Utf8 lengths, long/double double-slots) to its end; a scrambled 0xCAFEBABE still parses.
  • wasm. Confirm a version word of 1 and that the section id/size LEB128 stream validates end to end, terminating exactly at end of file; a scrambled \0asm still lifts.

The structural detector is shared (identify_by_structure) so the central sniffer (classify.rs), the container detector, and the native packer and identity passes all benefit from the same validated logic, and every validator is bounds-checked against deliberately malformed input. Python .pyc / marshal detection is handled on a separate path and is not part of this fallback. The behavior is proven by adversarial tests that take real committed corpus samples, scramble their magic bytes, section names, and markers, and assert disrobe still detects the correct format and, where it unpacks or parses, still produces the correct recovered output.

Boundary 2: untrusted .dr envelopes

The .dr envelope is content-addressed (BLAKE3-rooted, rkyv hot payload + postcard cold sidecar). A cache hit, a peer-supplied envelope, or a downstream stage all cross this boundary. An envelope is not trusted merely because it claims a hash.

What this boundary defends against:

  • Read-past-end. The zero-copy rkyv access path is bounds-checked at decode; an envelope whose declared lengths exceed its actual bytes is rejected, not read past.
  • Integer overflow in length math. Offset and length arithmetic is checked; an envelope cannot induce a wrapping add that yields an in-bounds-looking slice.
  • BLAKE3-mismatch acceptance. The root hash is recomputed over the payload and compared; an envelope whose content does not match its claimed root is rejected. This is the property that makes --no-cache an optimization toggle and not a correctness toggle: a cache hit is provably the same bytes.

The decoder lives in crates/disrobe-ir/src/envelope.rs and is fuzzed against exactly these three attacks.

Boundary 3: the network surface (disrobe serve)

When the daemon runs, HTTP, gRPC, and LSP-over-stdio each cross a trust boundary. The governing rule is that the server never opens a file based on a client-controlled string.

  • HTTP, gRPC, and the LSP disrobe/analyze method accept bytes_b64 only, never a path. There is no client-reachable code path that turns a request field into a filesystem read.
  • All request bodies reject unknown fields via #[serde(deny_unknown_fields)], closing field-smuggling and forward-compat-confusion attacks.
  • A non-loopback HTTP bind emits a tracing::warn! banner at startup, so an operator who exposes the daemon beyond localhost is told so explicitly.

The daemon is intended for localhost / trusted-network use; it is not an authenticated multi-tenant service, and exposing it publicly is an operator decision the warning banner flags.

Boundary 4: subprocess backends and optional sample execution

This is the boundary an analyst can choose to not cross at all. Two distinct sub-cases:

Subprocess backends over the artifact (not the sample's logic). Optional external tools (Ghidra, CFR, Vineflower, jadx, ILSpy, dnSpy, de4dot, Rizin) run as subprocesses over the derived artifact. They never execute the sample's own entry point. The exposure here is command-line construction: command lines are built from configuration and sometimes from user input, so command injection and argument smuggling are the in-scope threats, mitigated by constructing argument vectors directly rather than shelling out through a string.

Dynamic execution of the sample. One path can run adversarial code. It is not enabled by default and sits behind a named flag:

PathGateWhat runs
PyArmor v6/v7 dynamic-hook--allow-dynamicThe obfuscated wrapper, in a watched subprocess, to capture marshal streams. Watchdog via --dynamic-timeout (default 60s).

--allow-bcc is a separate static-analysis gate and does not cross this execution boundary. Once wrapper and runtime discovery succeeds, omitting the flag makes the unpack return DR-PYARM-0050 before version-specific unpacking. With the flag, native builds analyze extracted BCC objects in process without executing the sample or invoking Ghidra. Windows x86-64 uses the Microsoft x64 ABI, Linux x86-64 uses the System V ABI, and Darwin ARM64 uses AAPCS64. Unknown architecture IDs and unavailable native targets produce typed refusals. The dedicated command and path-aware automatic extraction publish the same bounded recovery JSON, pseudo-C, and recovered Python skeleton. Unmodeled functions remain present with native disassembly and a typed reason.

The default static paths (the pickle symbolic VM and non-BCC v8/v9 PyArmor peels) need no execution opt-in: they parse and walk, they do not detonate. BCC analysis is also static but separately gated. When dynamic execution is unavoidable, run it inside a disposable, network-isolated sandbox. disrobe gives you a watchdog and a captured-marshal manifest, but a dynamic hook is, by definition, executing attacker code.

Non-execution stance (restated as an invariant)

The default-static stance is a design invariant, not a configuration default that can drift:

  • disrobe does not unpickle. disrobe pickle trace walks the opcode stream symbolically, building the object graph without instantiating a single real object or resolving a single real global; disrobe pickle safety grades danger statically.
  • disrobe does not call __reduce__, does not run a packed binary, does not invoke a sample's entry point on any default path.
  • Any way to make a default path execute a sample is a vulnerability, in scope for the Security policy.

Plugin and WASM isolation

Where disrobe loads analysis logic as data rather than as native code, that logic runs sandboxed: WASM-hosted analysis executes inside a wasmparser-validated, memory-bounded interpreter with no ambient filesystem or network capability, so a malicious or malformed module can consume bounded compute and nothing more. This keeps the extensibility surface from becoming a fresh native-code execution boundary.

Supply chain

The integrity of the binary the analyst runs is its own boundary:

  • No untrusted bytecode in the public corpus. The repository does not ship third-party copyrighted obfuscated bytecode; fixtures are either self-generated by corpus/generate.{sh,ps1} or referenced by BLAKE3 hash only. Every shipped fixture is pinned by hash in corpus/native/packers/MANIFEST.toml and sibling registries, and tests verify byte-identity before the parser ever sees the bytes.
  • Signed releases. Release artifacts are signed with cosign keyless OIDC, and every cosign signature is recorded in the Rekor public transparency log. Verification commands are in the Security policy.
  • Dependency hygiene. cargo deny (advisories / bans / licenses / sources) runs on every push and weekly; cargo audit runs weekly. The clippy gate (-D warnings) is required for every commit on main.
  • History hygiene. CI runs on every push, and the local verification chain (clippy -D warnings, fmt, tests, cargo deny) is the enforced pre-push gate; commit authorship uses the GitHub noreply form so personal email never enters history.

Explicitly out of scope

The threat model deliberately does not defend against:

  • Decompilation-output correctness on adversarial bytecode. disrobe will sometimes emit wrong source for hostile input; the round-trip metric exists to flag this. A non-byte-perfect decompile is correctness work, not a security boundary.
  • Compute exhaustion via legitimate input. Decompiling a 66 MiB Hermes bundle is genuinely expensive; a slow-but-bounded decompile of real input is not a vulnerability. (Adversarial amplification, a tiny input that forces unbounded work, is in scope under Boundary 1.)
  • Vulnerabilities inside wrapped third-party tools. Ghidra, jadx, CFR, and friends have their own security channels; we forward where we can identify the upstream.
  • Trusting the analyst. disrobe assumes the operator is authorized and acting in good faith; paths that expose --i-have-authorization require that assertion, but the tool does not, and cannot, adjudicate authorization.

Reporting

If you find a way to cross a boundary that this model claims is sealed (make a default path execute a sample, escape a container, accept a hash-mismatched envelope, or make the daemon read a file from a client string), that is a security issue. Report it privately, never as a public issue. See the Security policy.

GitHub Action

disrobe ships a composite GitHub Action that downloads the matching release binary, runs a scan over a path or glob, and uploads the result to GitHub code scanning as SARIF. It runs entirely in the runner shell (no Docker image, no build step) so it starts in seconds.

Quick start

Add a workflow that scans build artifacts on every push and surfaces findings in the Security -> Code scanning tab.

name: disrobe-scan
on:
  push:
  pull_request:

permissions:
  contents: read
  security-events: write   # required for the SARIF upload

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: 1-3-7/disrobe@v0.10.5
        with:
          path: dist/
          command: auto
          fail-on: failed

The security-events: write permission is what lets the action publish SARIF to code scanning; without it the upload step is skipped by GitHub.

What it does

  1. Resolves the runner OS/arch to a release target triple (x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-pc-windows-msvc, and the rest of the release matrix).
  2. Downloads disrobe-<version>-<target>.tar.zst (or .zip on Windows) plus SHA256SUMS from this repository's Releases, and verifies the archive against SHA256SUMS before extracting. A checksum mismatch fails the step.
  3. Runs disrobe <command> <path> <args> --sarif --out <out-dir>, capturing the SARIF document.
  4. Uploads the SARIF to code scanning and the recovered-artifact directory as a workflow artifact.

Inputs

InputDefaultDescription
path(required)File, directory, or glob to analyze. Passed verbatim to the command.
commandautodisrobe subcommand (auto, scan, behavior, ...).
args""Extra arguments inserted after the command and before the path (for example --max-depth 12).
versionaction ref, then latestRelease tag to download (v0.10.5, latest).
fail-onneverFail the step at or above a verdict: never, incomplete, failed, any.
sarif-filedisrobe.sarifPath the action writes the SARIF to.
out-dirdisrobe-outDirectory disrobe writes recovered artifacts into.
upload-sariftrueUpload SARIF to GitHub code scanning.
upload-artifacttrueUpload the recovered-artifact directory.
token${{ github.token }}Token used to download the release asset.

Outputs

OutputDescription
sarifPath to the SARIF file the action produced.
verdictWorst verdict observed (ok, incomplete, failed).
summaryOne-line human-readable run summary.

Pinning the version

Pin a tag for reproducible CI:

      - uses: 1-3-7/disrobe@v0.10.5
        with:
          path: suspect.bin
          version: v0.10.5

Leaving version unset downloads the release matching the action ref, falling back to the rolling latest release. Pin a tag in production so a new release cannot change your scan results silently.

Gating a pull request

Block a merge when a packed or obfuscated artifact slips into the tree:

      - uses: 1-3-7/disrobe@v0.10.5
        with:
          path: "build/**/*"
          command: auto
          args: --max-depth 12
          fail-on: failed

fail-on: failed fails only when the chain itself errors; fail-on: incomplete is stricter and also fails when disrobe reports findings it could not fully resolve. The action reports what disrobe detects; it does not invent verdicts.

Security posture

The action verifies every download against SHA256SUMS before extracting it, and every release archive additionally carries a cosign signature bundle you can verify out of band. disrobe itself performs pure static analysis by default; see the forensics and malware-safety posture.

pre-commit hook

disrobe ships a pre-commit.com hook that scans staged files and fails the commit when a packed or protected artifact is detected. Use it to stop someone from accidentally (or maliciously) committing a UPX-packed binary, a PyArmor-protected module, a PyInstaller one-file build, or a SourceDefender/Nuitka/PyFreeze blob.

Setup

Add the hook to a consuming project's .pre-commit-config.yaml:

repos:
  - repo: https://github.com/1-3-7/disrobe
    rev: v0.10.5
    hooks:
      - id: disrobe

Then install it:

pre-commit install

The hook requires the disrobe binary on PATH (install from the Releases page), or point it at an explicit path with the DISROBE_BIN environment variable. It also needs python3 available to parse the chain report.

What it detects

For each staged file the hook runs disrobe auto <file> --json against a throwaway output directory and inspects the chain's chosen detector picks. By default it blocks only the high-precision packer/protector detectors, which key off unambiguous structural magic and do not false-positive on ordinary source:

Detector passBlocks
native.packer-unpackUPX, Petite, kkrunchy, and other native packers
pyarmor.unpackPyArmor-protected Python
pyinstaller.extractPyInstaller one-file / one-dir builds
sourcedefender.decryptSourceDefender-encrypted Python
nuitka.extractNuitka-compiled binaries
pyfreeze.extractFrozen-Python blobs

This is deliberately conservative: it blocks exactly the formats above, nothing more.

Tuning

Three environment variables tune the gate:

VariableDefaultEffect
DISROBE_BINdisrobePath to the disrobe binary.
DISROBE_BLOCK_PASSESthe six passes aboveComma-separated detector pass-ids to block.
DISROBE_BLOCK_FAMILIES(empty)Comma-separated detector families to additionally block.

The family override is broader but noisier. disrobe's source-level obfuscation classifiers (js.deob, lua.deob, py.deob) are tuned to attempt recovery aggressively, so they can fire at high confidence on ordinary text and markdown. Enabling DISROBE_BLOCK_FAMILIES=obfuscator-wrapper,packer-archive will catch source-level obfuscation but expect false positives on benign files; scope it with the hook's files:/exclude: patterns.

repos:
  - repo: https://github.com/1-3-7/disrobe
    rev: v0.10.5
    hooks:
      - id: disrobe
        files: '\.(exe|dll|so|dylib|pyc|pyz|bin)$'

Bypassing

A legitimately-committed protected artifact can skip the hook for one commit:

SKIP=disrobe git commit -m "vendor signed third-party binary"

Security posture

The hook runs disrobe auto, which performs pure static analysis by default; it does not execute the staged file. See the forensics and malware-safety posture. The scan writes recovered artifacts only into a temporary directory that the hook deletes on exit; your working tree is never modified.

MCP server

disrobe ships a Model Context Protocol server so MCP clients can drive deobfuscation and decompilation directly as tools. It speaks MCP over stdio via rmcp.

Analysis tools take inline base64 bytes and return structured JSON. Workspace and provenance tools take structured metadata such as symbol names or provenance maps. The server never reads sample bytes from a client-controlled filesystem path. This is the same hard rule the HTTP/gRPC/LSP transports enforce; see the security posture.

Running it

Two equivalent entry points:

disrobe serve --mcp     # the CLI's MCP companion over stdio
disrobe-mcp             # the standalone MCP binary

Registering with an MCP client

disrobe serve --mcp

Or run the standalone binary:

disrobe-mcp

Most clients accept a JSON server entry. Point command at disrobe (or disrobe-mcp) and run over stdio:

{
  "mcpServers": {
    "disrobe": {
      "command": "disrobe",
      "args": ["serve", "--mcp"]
    }
  }
}

Tool catalog

ToolInputOutput
autobytes_b64, optional max_depthChain verdict, detected formats, and per-pass recovery summary. Auto-detects and chains disrobe's Python + native-packer passes.
secret_scanbytes_b64, optional redactSecret findings with byte offsets. redact: true replaces detected values throughout the response. Raw values remain the default.
decompilebytes_b64, optional max_depthEvery terminal recovered-source artifact (language-keyed text), for example a .pyc decompiled to Python.
iocbytes_b64Indicators of compromise: URLs, domains, IPs, emails, paths, registry keys, wallet addresses, crypto constants (one decode layer of base64/hex).
behaviorbytes_b64, optional importsStatic capability summary across network, filesystem, process-exec, registry-persistence, crypto, anti-analysis, and dynamic-code categories, with MITRE ATT&CK ids.
stringsbytes_b64, optional min_len, decodePrintable ASCII + UTF-16 strings, optionally decoding base64/rot/stack-string obfuscation, tagged with their encoding.
verifybytes_b64Verify a .dr envelope: blake3 root hash, rung, hot/cold sizes.
renameold, new, optional noteAppend a symbol-rename record to .disrobe/notes/renames.json.
annottargetRegenerate and validate an annotation sidecar under .disrobe/annotations/.
provenance_lookupmap_json, lineLook up the provenance entry for a line in a provenance-map document.
call_graphbytes_b64, optional token_budget, optional cursorPage through function summaries and classified calls. Direct calls distinguish function starts, function interiors, non-function symbols, unresolved addresses, ambiguous overlaps, and indirect calls.
xrefsbytes_b64, function_id, optional token_budget, optional cursorReturn cross-references to the function identified by a content-bound id.
function_summarybytes_b64, function_id, optional token_budgetReturn address, extent, export state, instruction and block counts, complexity, and incoming, outgoing, and indirect-call counts.
neighborhoodbytes_b64, entry_ids, depth, optional direction, optional token_budget, optional cursorPage through a cycle-safe caller, callee, or bidirectional neighborhood.

The four navigation tools accept a Disasm- or Mir-rung .dr envelope. A function id binds the envelope source hash to the function address and adds a content-derived discriminator when multiple functions share that address, so an id or cursor from another input is rejected. Each response mirrors its structured JSON in a text content block for MCP client compatibility. The complete tool result enforces a 2,048 to 32,768-byte serialized UTF-8 ceiling and declares the corresponding o200k_base budget. The external tokenizer gate checks both representations for all four tools on a large committed image. A row that cannot fit the selected ceiling returns DR-MCP-0662 instead of replaying the same cursor.

The committed recovery grade covers direct calls in one stripped x86-64 ELF against its distinct unstripped toolchain twin. Other architectures, executable formats, indirect-target recovery methods, and optimized call forms remain unmeasured by that grade. Disasm and Mir envelopes from those sources are accepted as typed input, but the server does not infer a missing format or architecture from the envelope payload.

The auto and decompile tools cover disrobe's Python and native-packer surface (PyArmor, PyInstaller, SourceDefender, Nuitka, PyFreeze, .pyc disassembly + decompilation, native packers, and container formats), the highest-value chain for triaging an unknown blob. For the full language matrix, drive the CLI or the HTTP daemon.

Example call

A client calls decompile with the base64 of a .pyc and receives the recovered Python:

{
  "name": "decompile",
  "arguments": { "bytes_b64": "4w0NCgAAAAA..." }
}
{
  "schema": "disrobe.decompile/v1",
  "verdict": "Complete",
  "recovered": [
    { "pass": "py.decompile", "language": "Python", "formatted": true, "source": "x = a + b\n..." }
  ]
}

Security posture

The server performs pure static analysis by default and never executes the supplied bytes. It rejects empty or malformed base64 with a typed error, and rejects unknown JSON fields on every tool. Analysis tools do not accept filesystem paths for sample input, so a client cannot redirect analysis to an arbitrary local file by passing a path-like string. See the forensics and malware-safety posture and the threat model.

Editor plugins

disrobe ships integration scaffolds for VS Code, IDA Pro, and Ghidra under editors/ in the repository root. Each plugin calls the disrobe binary already on your PATH; there is no language server or extension marketplace publish required.

All files under editors/ are generated by xtask plugins from the live CLI command catalog and are verified byte-identical in CI via xtask sync --check.

Auto-installer

Clone the repository and run the auto-installer script for your platform:

Linux / macOS (bash):

bash editors/install.sh vscode
bash editors/install.sh ida
bash editors/install.sh ghidra
# optional overrides:
bash editors/install.sh ida --ida-dir /path/to/ida/plugins
bash editors/install.sh ghidra --ghidra-scripts ~/my-scripts

Windows (PowerShell):

.\editors\install.ps1 vscode
.\editors\install.ps1 ida
.\editors\install.ps1 ghidra
# optional overrides:
.\editors\install.ps1 ida -IDADir 'C:\IDA\plugins'
.\editors\install.ps1 ghidra -GhidraScripts 'C:\ghidra_scripts'

The scripts require only the repository clone; they copy files into the standard per-editor location without network access.

Visual Studio Code

Requirements: VS Code 1.85 or later. Build disrobe from source and add the binary to your PATH, or set disrobe.executablePath in VS Code settings.

Manual install steps:

  1. Copy the editors/vscode directory to ~/.vscode/extensions/disrobe-vscode (Linux/macOS) or %USERPROFILE%\.vscode\extensions\disrobe-vscode (Windows).
  2. Reload VS Code.

The extension activates automatically and starts disrobe serve --stdio as an LSP daemon when disrobe.lsp.enable is true (the default). All commands appear under the disrobe category in the Command Palette and in the editor right-click context menu.

Settings:

SettingDefaultDescription
disrobe.executablePath"disrobe"Path to the disrobe binary.
disrobe.lsp.enabletrueStart the LSP daemon on activation.
disrobe.lsp.trace"off"LSP trace level: off, messages, or verbose.
disrobe.auto.outDir""Output directory for disrobe auto.

Commands (via Command Palette or right-click context menu):

CommandDescription
disrobe.autoAuto: run full deobfuscation pipeline
disrobe.detectDetect: identify obfuscator / packer
disrobe.stringsStrings: extract and deobfuscate strings
disrobe.iocIOC: extract indicators of compromise
disrobe.behaviorBehavior: summarize binary capabilities (MITRE)
disrobe.identifyIdentify: compiler / packer / protector fingerprint
disrobe.scanScan: leak credentials scanner
disrobe.startServerManually start the LSP daemon
disrobe.stopServerStop the LSP daemon
disrobe.showOutputOpen the disrobe output channel

IDA Pro

Requirements: IDA Pro 7.6 or later with IDAPython 3 backend. disrobe binary on your PATH.

Manual install steps:

  1. Copy editors/ida/disrobe_ida.py to your IDA plugins directory:
    • Linux/macOS: ~/.idapro/plugins/
    • macOS (app): ~/Library/Application Support/hex-rays/ida pro/plugins/
    • Windows: %APPDATA%\Hex-Rays\IDA Pro\plugins\
  2. Restart IDA Pro.

The plugin registers all disrobe subcommands as menu actions under Edit > Plugins > disrobe and assigns default hotkeys. Invoking an action runs disrobe <subcommand> <input_file_path> on the file IDA has open and prints the output to the IDA output window.

Actions and default hotkeys:

ActionHotkey
Auto: run full deobfuscation pipelineAlt-Shift-A
Detect: identify obfuscator / packerAlt-Shift-D
Strings: extract and deobfuscate stringsAlt-Shift-S
IOC: extract indicators of compromiseAlt-Shift-I
Behavior: summarize binary capabilities (MITRE)Alt-Shift-B
Identify: compiler / packer / protector fingerprintAlt-Shift-F
Scan: leak credentials scannerAlt-Shift-C

Ghidra

Requirements: Ghidra 10.3 or later. disrobe binary on your PATH.

Manual install steps:

  1. Copy editors/ghidra/DisrobeAnalyzer.java to a directory listed in Ghidra's Script Manager search paths:
    • Default location: ~/ghidra_scripts/
    • Or add a custom path via Window > Script Manager > Manage Script Directories.
  2. In Ghidra: Window > Script Manager, click the refresh icon to pick up the new script.
  3. Double-click DisrobeAnalyzer in the script list to run it.

When run, the script reads the path of the currently loaded program, shows a dialog to choose a disrobe action, shells out to the disrobe binary, and prints the output to the Ghidra console.

To assign a keybinding, right-click DisrobeAnalyzer in the script list and choose Assign Key Binding.

Contributing

Contributions are welcome; see the contributing guide.

Building and testing

disrobe builds with a single stable Rust 1.95+ toolchain:

cargo build --release
cargo test -p <crate> --features <the crate's test features>   # test a single crate

The JVM decompiler can be memory-intensive on adversarial input. Prefer per-crate test runs over a bare workspace-wide cargo test --workspace when iterating locally.

Name the features a crate hides its tests behind

Some crates keep whole modules behind a feature that is off by default. Every pass crate keeps its chain detector behind a non-default chain feature so the slim build can leave the chain runtime out. The file starts with #![cfg(feature = "chain")], so a per-crate run that names no feature builds none of the chain detector's tests and still prints a passing result for the tests it did build. Name the feature to run them:

cargo test -p disrobe-pass-lua --features chain
cargo test -p disrobe-cli --no-default-features --features chain --test auto_dalvik_feature_gate
cargo test -p disrobe-pass-mobile --features native-image --test flutter_engine_fallback_identity
cargo test -p disrobe-pass-wasm-deob --features chain,sandbox --test linear_memory_local_offset

The second form covers a refusal that exists only when chain is enabled and jvm is disabled. The Flutter engine identity test requires native-image for ELF parsing. The WebAssembly differential requires sandbox for Wasmtime execution and chain for the registered-pass assertion.

cargo run -p xtask -- health enforces this. It reads every crate's default feature set, finds every test-bearing file the default set removes, and fails when a crate hides tests that no entry in HIDDEN_TEST_SURFACE in xtask/src/feature_gated_tests.rs declares. It also reads every per-crate test command written in the README, in docs/src, and in the workflows, and fails when one of them names a test target the command's own feature set compiles away, or names no feature for a crate that hides tests. The failure names the crate and prints the command to use instead.

The quality bar

Every commit on main must pass the workspace clippy gate with zero warnings:

cargo clippy --all-targets -- -D warnings -W unreachable_pub -W missing_debug_implementations -W unused
cargo fmt -p <crate> -- --check

The workspace lints are strict by design: unwrap_used is denied, expect_used is treated as a defect in production paths, and todo! and unimplemented! are denied. New code is fully type-annotated and self-documenting; the codebase carries durable context in dedicated docs rather than inline comments. Unsafe code is restricted to audited boundary code and does not belong in ordinary parsing or recovery paths.

README graphs

The dark-theme benchmark and architecture SVGs in the README are generated, not drawn by hand. The data lives in xtask/data/*.json (every plotted value cites its source gate or harness inline), and xtask renders deterministic SVGs into docs/assets/:

cargo run -p xtask -- graphs            # regenerate docs/assets/*.svg
cargo run -p xtask -- graphs --check    # fail if committed SVGs are stale (CI runs this)

After changing a number in xtask/data/, rerun graphs and commit the regenerated SVGs; the graphs CI job rejects any drift. Numbers come only from a committed test gate or a local measurement harness, never from the tool grading its own output, and no competitor recovery percentage is plotted.

Docs and the wiki

These pages under docs/src are the single source of truth. The GitHub wiki is generated from them by scripts/wiki_sync.py and the wiki-sync workflow, which runs on every push to main that touches docs/. Do not edit the wiki directly; it is overwritten on the next sync. Edit the page here, then preview the generated wiki locally:

python scripts/wiki_sync.py --out ./.wiki-build       # build the wiki tree
python scripts/wiki_sync.py --check --out ./.wiki-build  # fail on drift

Adding a pass

A new ecosystem pass is a new disrobe-pass-<name> crate that:

  1. Implements the shared Pass trait, declaring its required and produced capabilities and its rung transition.
  2. Climbs the five-rung IR ladder rather than jumping rungs.
  3. Ships a pass_run_envelope_roundtrip test and at least one real-fixture integration test in crates/disrobe-cli/tests/.
  4. Wires its standardized emits, returning explicit applicable: false stubs for emits it cannot produce.

Every capability claim must be backed by a fixture in corpus/ and a passing test; nothing aspirational ships as done. Fixtures are baked locally from known-good inputs by corpus/generate.{sh,ps1}; copyrighted third-party obfuscated bytecode is never committed to the public corpus.

No fabrication

A decode that only passes against synthetic, self-generated fixtures is not a feature. Per-pass work is verified against a real corpus and the upstream format spec. Partial recovery carries a confidence tier; detect-only is stated as detect-only. If you are not sure a capability works against real-world input, say so in the PR.

Reporting bugs

Generate an environment report to attach to an issue:

disrobe bug-report --out report.md
disrobe bug-report --out -          # write to stdout

For security issues, do not open a public issue; use the private advisory channel. See Security.

Security

This is the short form. The full security policy lives in SECURITY.md.

Reporting a vulnerability

Do not open a public issue for security reports. Use GitHub's private advisory channel:

Report at: https://github.com/1-3-7/disrobe/security/advisories/new

Include a description and impact, a minimal reproducer (input bytes, command line, expected vs observed), the disrobe --version output, the OS/arch, and whether you have a candidate fix. Reports are acknowledged within 72 hours; high-severity fixes target 30 days, with same-week turnaround for parsing-of-untrusted-input issues. Reporters are credited (with their preferred handle) in the advisory and release notes; anonymous reports are welcome.

In scope

  • Memory safety in the parsing surface. Any panic/abort on adversarial input that is not a clean Result::Err; any heap corruption is high severity.
  • Resource exhaustion. Zip-bombs, decompression bombs, recursion bombs, and malformed-length-field bombs: bypasses of the crates/disrobe-binfmt/src/quota.rs quotas.
  • Path traversal. zip-slip and equivalents on every container extraction path.
  • Server input handling. disrobe serve (HTTP/gRPC/LSP/MCP) accepts bytes_b64 only; any way to make it read a file via a client-controlled string is high severity.
  • Subprocess invocation. Command injection or argument smuggling in backend invocation.
  • .dr envelope handling. Read-past-end, integer overflow, or BLAKE3-mismatch acceptance.
  • Supply chain. Tampering with published binaries, signature bypass, replay, cosign-bundle manipulation, or a forged build-provenance attestation.

Out of scope

  • Decompilation output correctness on adversarial input: that is correctness work flagged by the round-trip metric, not a security bug. File a normal issue.
  • Compute exhaustion from legitimate input (a slow decompile of a 66 MiB bundle is not a vulnerability).
  • Issues in third-party tools disrobe wraps: report to their upstreams.

Hardening posture

The default parsing path is Rust and keeps unsafe out of format decoders. Unsafe blocks are restricted to audited boundary code such as C interop, WASM exports, archive/io shims, build/install helpers, and native-loader interfaces. Strict clippy runs on every commit. cargo deny runs on every push plus weekly; cargo audit runs weekly. Shared container quota machinery, BLAKE3-pinned fixtures, loopback-default servers, and a warning banner on non-loopback binds backstop the runtime surface. Branch protection on main requires review, green CI, linear history, and no force-push.

Verifying release artifacts

Release binaries are signed with cosign keyless OIDC and recorded in the Rekor transparency log:

cosign verify-blob \
  --certificate-identity-regexp '^https://github.com/1-3-7/disrobe/' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  --bundle    disrobe-<version>-<target>.tar.zst.cosign.bundle \
  disrobe-<version>-<target>.tar.zst

Each binary is also built with cargo auditable, which embeds a dependency manifest readable with cargo audit bin disrobe (five of seven targets; the two cross-compiled Linux targets are a disclosed gap, see SECURITY.md). A CycloneDX SBOM ships as a release asset. GitHub build-provenance attestations are verifiable with gh attestation verify disrobe-<version>-<target>.tar.zst --repo 1-3-7/disrobe. .github/workflows/verify-release.yml independently re-checks all of this against every published release.

Legal

Decompilation for security research, interoperability, and recovery of your own source is permitted in most jurisdictions: US DMCA §1201(f), EU Software Directive 2009/24/EC art. 6, UK CDPA §50B/50BA, and equivalents in CA / AU / JP. The full statutory posture, with citations and a takedown channel, is in LEGAL.md.

The authorization gate

Legally sensitive recovery paths that expose --i-have-authorization require the flag before they run. Passing the flag is your assertion that you are authorized to analyze the input under the statutory framing above. Use is your responsibility.

The same flag unlocks the decryption-keys category of the --llm sidecar; without it, requesting that category fails with DR-CLI-0420.

What disrobe will not do

  • It does not ship copyrighted third-party obfuscated bytecode in its public corpus. Fixtures are baked locally from known-good inputs.
  • Grey-zone protectors ship recognizers first; escalation to a full peel only happens after a written legal-posture review committed to docs/legal/<protector>-stance.md.
  • It does not phone home. The only documented network endpoint is disrobe self-update --check-only, and the binary is distributed source-and-release-only.

Per-protector stances on file

License

disrobe is licensed under the Elastic License 2.0. Companies and security researchers may use, copy, modify, and distribute it for free; attribution is required, so keep the author, copyright, and licensing notices intact. You may not provide disrobe to third parties as a hosted or managed service, and you may not remove or obscure any licensing, copyright, or other notices. The "disrobe" name and marks are reserved; the license grants no trademark rights. See LICENSE and NOTICE.