python - 1-3-7/disrobe GitHub Wiki

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

Layer Coverage
Bytecode disassembly CPython 3.8-3.15, PyPy, MicroPython .mpy v0-v6, Jython, IronPython, Brython
Decompilation CPython 3.8-3.15 with per-version opcode dispatch. Normalized opcode-structure agreement on CPython 3.14.5: 17637 of 18276 code objects in a fixed core population; 6139 of 6286 in its pinned 200-module subset. See the measurement definition below.
Modern constructs match, walrus, f-strings and PEP 750 t-strings, exception groups, PEP 695/696/709
Control flow try/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
Freezers PyInstaller 2.x-6.20+, Nuitka, cx_Freeze, py2exe, shiv, pex, PyOxidizer (experimental, unvalidated), Briefcase, SourceDefender
Protectors PyArmor 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
disrobe py deob remapped.pyc --opcode-reference stock.pyc --out clean.py

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.

Source obfuscators, graded by re-execution

The strict re-execution grader peels each committed obfuscator output and runs the recovered source and the authored original under CPython 3.14. A driver prints the program's output, every module-level name with its kind and signature, and the results of calls into the functions the program defines. A case counts as recovered only when both runs print the same lines and exit the same way. Renamed identifiers, leftover junk assignments and a still-wrapped payload therefore all fail. The grader never runs a recovery that still calls exec, eval, compile, marshal, __import__ or importlib more often than the original does.

Family Result What is left
Berserker every graded case
BlankOBF every graded case
Kramer/Specter every case with a recorded original one walrus capture has no recorded original
PlusOBF every graded case
Pypacker every graded case reproductions of the transform, not tool output
pyobfus (reproductions) every graded case
online obfuscator family every graded case
pyobfuscate.com hello only functions, classes and parameters renamed without a mapping
ObfuXtreme cases whose names survive, including a renamed TypeVar random v_ names on other module variables; one capture where the tool renamed class attributes but not their reads; one walrus capture has no recorded original
python-obfuscator hello only identifiers renamed without a mapping; the 3.10 band output has empty class patterns and does not compile
pyminifier the compression, prepend, no-minify and import-method variants and the hello-world case generated names; pyminifier 2.1 also renames f-string and bytes prefixes and misses f-string fields
Patchwork the no-rename capture identifiers renamed without a mapping
Manglify every case with a recorded original one walrus capture has no recorded original
PyObfuscator (mauricelambert) hello only every identifier renamed without a mapping, attribute names included; the tool rewrites strings in case patterns into calls, so its match outputs do not compile

The test pins the cases that fail, with the reason for each, and the list may only shrink. When the named reason is that the tool renamed identifiers and nothing else, a second check parses the recovery and the original with CPython and requires them to match once identifiers are mapped one to one (docstrings and other bare string statements are ignored, and an else after a return counts as dedented). A leftover junk statement, a changed constant or a lost f-string fails it, so a regression in a renamed family shows up even though the case already fails the strict run.

python-obfuscator recovery folds bytes.fromhex(...).decode('utf-8') constants back to literals, removes statements after return, raise, break and continue, and drops the junk assignments the tool inserts. It removes only assignments of a 79-letter string, an integer below 1000 or another name, and only when nothing reads the name. It keeps every assignment when the program reads its own namespace through globals, locals, vars, dir, eval or exec. Pypacker and the pyobfus marshal form hand the marshalled module to the decompiler, which infers the CPython version from the bytecode. Jawbreaker is reported as detected and refused: its loader fetches the program from a remote paste at run time, and deob never fetches it.

PyObfuscator (mauricelambert) wraps the program in a gzip layer whose inner module opens with a tuple assignment that binds random names to every builtin, then defines an XOR string decoder and an import helper. deob replaces each alias with its builtin when no scope rebinds that builtin, folds the int, chr and ord arithmetic that spells constants, applies the XOR decoder to every bytes literal it is called on and folds the following decode('utf-8'), turns getattr with a constant name into attribute access (not for private names, which Python would mangle, or keywords), turns X = __import__('m') and the helper's getattr(H('m', 'x'), 'x') back into import m as X and from m import x when the module name has no dot, and removes the decoder and the import helper once nothing calls them. The result is reported Full only when the program binds no names, because the tool renames every identifier without keeping a mapping.

ObfuXtreme v4 also flattens function bodies into a state machine: a counter starts at 0, and a while counter < N loop runs if counter == k: blocks that each end in counter += 1. deob puts the blocks back in sequence when the counter is used nowhere else, the states run in order 0 to N-1, each ends in the increment or in a return or raise, and no block holds a break or continue of that loop; otherwise the loop stays. A string the tool moved out of docstring position stays out of it, because the obfuscated function has no docstring. A module variable renamed to a v_ name that is assigned TypeVar('T'), ParamSpec, TypeVarTuple or NewType takes back the name it was declared with when that name is free and only one declaration claims it.

pyminifier binds builtins and imported module functions to short names (A = print, s = asyncio.sleep) and reuses the same short names for locals. deob folds an alias back only where a load reaches the module binding: a function, lambda or comprehension that binds the same name keeps its own, a class body that binds it keeps the alias, and a builtin shadowed at the use site keeps it too. An alias bound twice, declared global anywhere, or pointing at a module attribute outside the import prologue is left in place.

Patchwork's Abyss VM lowers f-strings to ''.join([...]) over format(value, spec) calls. The cleanup pass turns that shape back into an f-string, with repr, str and ascii arguments as !r, !s and !a conversions, when none of format, repr, str or ascii is rebound in the module.

Manglify 0.1.1 hides the program in a table of hex chunks and lets its Engine class try every key in a loop range (1000 to 9998 in every capture) at run time. Each chunk is XORed with the decimal digits of the key and then decoded by the method the first digit of its table key names: hex, decimal code points, 8-bit binary, ROT13 or Atbash. The loader executes whatever compiles. deob reads the loop bounds from the file, decodes the table under every key, and keeps the key whose text parses and opens with the loader's integrity check against the banner tuple; the check and the tool's pass; prefix are removed. Wrong keys can also decode to Python that compiles. deob accepts such a key only when its first statement loads a name that contains a digit, appears in neither the loader nor the payload, and so raises NameError before doing anything. Otherwise it refuses the file, as it does when no key or more than one key unlocks the payload, when the loader runs a top-level statement outside its known shape, or when the loader shadows a builtin it calls. Nested Engine layers are unlocked in turn, up to 8; the key loop is limited to 100,000 keys and the table to 32 MiB. The 3.8 band capture does not compile under CPython (the tool lists a star import in a list display and places from __future__ after its banner), so that file never ran; deob still recovers the payload it carries and says so in its notes.

Custom interpreters with a permuted opcode table

Some applications ship a CPython build whose opcode numbers are shuffled, so stock disassembly shows unknown or wrong instructions. For CPython 3.11 and later, deob detects this from invariants every code object keeps: the first instruction after the cell and generator prologue is RESUME, and the inline-cache slots after cache-bearing instructions hold CACHE. When those fail under the stock table but every plain code object starts on the same opcode with a zero operand, the input is refused as cpython-opcode-remap (DR-PYDEOB-0016) instead of being decompiled with the wrong table. py deob --opcode-reference stock.pyc and py disasm --opcode-reference stock.pyc recover the table from a stock-compiled .pyc of a module the file also contains: it aligns code objects with the same name, constants, names and length, requires identical operand bytes, and refuses a conflicting or non-injective mapping. Opcodes the reference never uses are refused by value (DR-PYDIS-0206). The grader compiles an authored program with CPython 3.12, permutes its opcodes, and checks that the refusal fires, that the recovered table decompiles to source that prints what the original prints, and that a partial reference and a changed operand are both refused.

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: 17637 of 18276 code objects agree, or 96.5%. 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.

Band Interpreter Recovered Rate Modules Enforced on
3.8 CPython 3.8.20 4893 / 5088 code objects 96.16% 154 tag, schedule
3.9 CPython 3.9.25 5032 / 5233 code objects 96.15% 157 tag, schedule
3.10 CPython 3.10.20 5279 / 5458 code objects 96.72% 161 push, tag, schedule
3.11 CPython 3.11.15 5504 / 5638 code objects 97.62% 172 tag, schedule
3.12 CPython 3.12.13 5484 / 5659 code objects 96.90% 177 push, tag, schedule
3.13 CPython 3.13.14 5793 / 5966 code objects 97.10% 190 push, tag, schedule
3.14 CPython 3.14.5 6139 / 6286 code objects 97.66% 200 no band gate, mirrored
3.15 CPython 3.15.0b4 6296 / 6480 code objects 97.16% 199 tag, 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.
⚠️ **GitHub.com Fallback** ⚠️