Python

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

The Python decompiler is implemented in Rust. The command uses this engine; pycdc, PyLingual, decompyle3, and uncompyle6 appear in comparison benchmarks.

At a glance

LayerCoverage
Bytecode disassemblyCPython 3.8-3.15, PyPy, MicroPython .mpy v0-v6, Jython, IronPython, Brython
DecompilationCPython 3.8-3.15 with per-version opcode dispatch. Normalized opcode-structure agreement on CPython 3.14.5: 17396 of 18276 code objects in a fixed core population; 6139 of 6286 in its pinned 200-module subset. See the measurement definition below.
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 19 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, 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

The engine reconstructs nested source constructs from the CPython 3.11+ exception table before walking instructions. Its round-trip checker recompiles recovered source when a matching interpreter is available, then compares the resulting code objects. Reports distinguish exact matches, normalized matches, code differences, compilation failures, and an unavailable interpreter. A normalized match is evidence about the compared representation, not a proof of identical behavior.

Measured opcode structure

The benchmark compiles each original module and its recovered source with CPython 3.14.5 at optimization level zero. It compares normalized opcode streams and three argument-count fields for each original code object. Normalization removes or merges selected instructions and omits jump targets and most operands. The comparison excludes __annotate__ code objects and does not penalize additional recovered code objects. These results measure opcode structure, not bytecode identity or program equivalence.

The fixed core population contains 574 modules and excludes idlelib and turtledemo: 17396 of 18276 code objects agree, or 95.18%. Its pinned 200-module subset yields 6139 of 6286 (97.66%). The committed module lists define both populations.

full_stdlib_recompile_gate.rs measures the 574-module population on explicit invocation. CI runs a 115-module subset and runs the 200-module gate on tags and scheduled builds, with a 97.66% regression threshold for the latter.

The recorded run includes the executable and interpreter hashes, comparison rules, exact counts, and reproduction command. Its unmatched objects identify all 880 code differences, missing objects, and collisions by module, qualified name, and occurrence.

Per-interpreter bands

Each band uses the same normalized opcode-structure comparison and pinned module list, compiling both sources on the listed interpreter. Modules absent from that interpreter are excluded. The different denominators prevent a direct ranking between bands.

BandInterpreterRecoveredRateModulesEnforced on
3.8CPython 3.8.204599 / 5088 code objects90.38%154tag, schedule
3.9CPython 3.9.255032 / 5233 code objects96.15%157tag, schedule
3.10CPython 3.10.205279 / 5458 code objects96.72%161push, tag, schedule
3.11CPython 3.11.155504 / 5638 code objects97.62%172tag, schedule
3.12CPython 3.12.135484 / 5659 code objects96.90%177push, tag, schedule
3.13CPython 3.13.145793 / 5966 code objects97.10%190push, tag, schedule
3.14CPython 3.14.56139 / 6286 code objects97.66%200no band gate, mirrored
3.15CPython 3.15.0b46296 / 6480 code objects97.16%199tag, schedule

The recorded results and interpreter versions are in xtask/data/recovery.json.

The 3.10, 3.12, and 3.13 bands run on each push to main. Tag builds and weekly scheduled builds also run the remaining band gates. The dedicated 3.10, 3.12, 3.13, 3.14, and 3.15 checks require their pinned interpreters. The 3.8 and 3.9 counts were measured on Windows x86-64; their gates run in the weekly test job, which installs both interpreters, and skip when the interpreter is absent.

The 3.14 row reads differently from the rest: it reports the pinned 200-module subset of the accepted 574-module measurement; both use the same source hashes and comparison rules.

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).

disrobe auto claims a Cython extension through the python.cython-surface pass and writes two files: <module>.pyi, a stub that declares every recovered function, class and method with its parameters, defaults, Python-typed annotations and docstring, and cython-surface.json, the full recovered record. The stub of the committed fixture is parsed by CPython's ast and compared with the authored .pyx (cython_stub_reference.rs): every function and method is declared with the authored parameter names and defaults, and a dropped default turns the comparison red. A stripped module keeps every function and method name, grouped under its class by the qualified signature Cython embeds; parameters come only from those embedded signatures, and a class whose type table did not survive stripping has no __init__ declaration.

Limits

  • The pyc-zipper real-output grader covers 30 pinned outputs against CPython 3.12. Compressed payloads recover their opcode structure; obfuscation can permanently delete variable names and line information. Twenty-five source recoveries print the same bytes as the authored programs; the five generator cases retain named partial status. Eighteen outputs also have broken loaders in the tool itself, recorded separately from static recovery.
  • The pyobfus real-output grader covers 15 generated sources. Recovery removes the generator header and preserves the body exactly, including generated names, literals and reflection lookups. All 15 re-execute like the authored inputs. Original identifier names are unavailable, so recovery remains partial; three other requested cases failed in the tool and produced no fixtures.
  • A Cython module's Python source is gone once compiled. Only the import surface described above is recoverable, not the .pyx bodies.
  • 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.