PyDeob Static
A static-first recursive Python deobfuscation workbench. The sample is treated as bytes and syntax-tree data: the tool never imports, executes, compiles-to-a-running-process, or launches the sample. It performs bounded constant evaluation using an allow-list of literal operations and pure decoders. It recovers escaped source wrappers, literal-key Fernet stages, nested exec/eval/compile arguments, nested source strings, bytecode, container contents, and full unredacted indicators. Remote-stage retrieval is available only through an explicit command-line option; fetched bytes remain inert data.
Install
Python 3.10 or newer is required.
py -m venv .venv
.\.venv\Scripts\Activate.ps1
py -m pip install -e ".[all]"
For the standard-library-only build:
py -m pip install -e .
To uninstall the installed command/package, use the same Python environment used for installation:
py -m pip uninstall pydeob-static
If you installed it inside an activated virtual environment, keep that environment activated for the uninstall command. pip uninstall removes the package entry point but not the source project directory or optional dependencies. Delete the project folder separately when it is no longer needed.
Optional cryptography backends are installed with .[crypto]; optional Zstandard, Brotli, and LZ4 decoders are included in .[fast-compression].
Run
py -m pydeob.cli .\sample.py --out .\analysis-output
Or, after installation:
pydeob .\sample.py -o .\analysis-output --max-depth 12 --max-artifacts 500
# Analyze a locally saved response body as an additional stage
pydeob .\url-loader.py -o .\analysis-output --stage-file .\captured-response.txt
# Explicitly fetch only a literal URL consumed by exec/eval/compile, then analyze its response
pydeob .\url-loader.py -o .\analysis-output --fetch-remote-stages
# Explicit one-URL retrieval as inert data, followed by analysis
pydeob fetch-stage "https://example.org/paste?userid=1" -o .\remote-analysis
Useful controls:
pydeob .\sample.py -o .\out --max-depth 14 --max-artifacts 700 --max-seconds 180 --max-total-bytes 536870912 --max-steps 4000000 --max-input-bytes 1073741824
pydeob .\sample.py -o .\out --no-xor
pydeob .\sample.py -o .\out --no-crypto
--max-input-bytes caps the whole file read before loading it. Large native inputs beyond --max-bytes skip the general text-transform pass and use the container frontend, avoiding duplicate full-size output layers. --max-seconds, --max-total-bytes, and --max-steps are shared across the primary transform graph, static assignment/AST work, nested marshal recovery, and container extraction. The default shared limits are 90 seconds, 256 MiB of aggregate byte work, and 2,000,000 evaluation steps. The budget stops further candidate work and is recorded in report.json.
The input is read as data. The analysis time, depth, per-artifact size, and artifact count are bounded. The nested Python pass is enabled by default; pass --no-deep-python to skip it. --no-xor disables single-byte XOR search. --no-crypto disables key-backed cryptographic attempts.
Output
final_payload.py— exact copy of the original input, when the input filename ends in.py.decoded_final_payload.py— highest-ranked Python-source candidate found; falls back todecoded_final_payload.txtor.binwhen no Python source candidate exists.layers/— every retained candidate as a.bin, plus.pyor.txtwhen it can be represented as text. The filename records the layer ID and transform.layers.json— layer IDs, parent links, depth, SHA-256, size, transform, score, reasons, and saved paths.report.json— limits, elapsed time, stop reason, selected final layer, and counts by transform/content type.iocs.json— passive endpoint/indicator extraction from all retained layers.nested_payload_analysis.md— layer chain, static behavior indicators, full IOCs and disassembly status, when a packed Python code object is recovered.nested_payload_inventory.json— nested code-object metadata, names, string constants, size/hash fingerprints, and full IOCs.recovered_strings.txt— unique string constants and derived endpoint strings, verbatim.fetched_stages/— explicitly fetched HTTP responses saved byte-for-byte, withfetches.jsonURL/status/size/hash metadata.container/— read-only native/container metadata, strings, extracted PE resources/PyInstaller assets, and child-analysis folders when applicable.bytecode/— raw recovered marshal payloads (*.marshal), inert disassemblies (*.dis.txt), metadata (*.json), an index, and best-effort current-runtime.pycfiles when a code object can be serialized.source_layers/— escaped-source decodings, locally supplied<pre>response bodies, static content-variable layers, and literal-key Fernet plaintext artifacts.source_layers.json/source_layers.md— Fernet key/token hashes, layer hashes, URL indicators, and recovery statuses.behavior.json— passive source-level behavior indicators for dynamic execution, process launches, package installation, browser/session access, screen capture, file modification, and outbound transfer.
The search is a branching graph, not a single guaranteed linear chain. Many decodings are plausible, so the highest-ranked candidate is a heuristic selection. layers.json keeps the other branches for manual comparison.
The deep static pass also examines bytes literals for bounded embedded XZ/gzip/zlib/BZip2 content. For a Python wrapper that ends in a statically resolvable marshal.loads(...) passed to exec(...), including a directly named exec, it evaluates only allow-listed literal transforms and import aliases, then parses the result as an inert code object. It also probes the original input and retained transform artifacts for raw marshal code objects and current-runtime .pyc files. bytecode/ contains dis-based instruction listings, the recovered marshal bytes, a JSON index, and a best-effort .pyc when re-serialization succeeds. A generated .pyc uses the analyzer's current Python magic number; the original producer's version is not inferred, so it may not load correctly in the sample's original runtime. PyDeob never imports or executes the .pyc, wrapper, or recovered code object. Discord webhook URLs, Telegram bot tokens, Fernet keys/tokens, source literals and other recovered strings are not redacted in generated reports. Treat output folders as sensitive analysis data.
When a nested marshalled payload is recovered, decoded_final_payload.py is intentionally replaced with decoded_final_payload.txt, pointing at the static report. The best-effort .pyc under bytecode/ is a serialized code object, not a decompiled .py source file. This matters when bytecode is malformed or built for a different interpreter: the tool will not invent unverified Python source. See nested_payload_analysis.md, nested_payload_inventory.json, bytecode/, and recovered_strings.txt for the recovered evidence.
Static decoding coverage
The recursive pass re-applies available transforms to each new artifact. It includes:
- Base16/hex, Base32, Base64 and URL-safe Base64, Base85, ASCII85, Base91, Base58, decimal byte arrays,
0xNNarrays, and binary bit strings. - ROT/Caesar shifts, ROT47, byte reversal, whitespace removal, Unicode/backslash escapes, URL percent escapes, HTML entities, and single-byte XOR search.
- Safe AST folding for literal concatenation/arithmetic, slices, f-strings, selected
chr/ord/bytesexpressions, small literal comprehensions, direct literal lambdas, constant-onlymap/filter, bounded interpretation of pure helper functions, symbolic dynamic-import recovery, readable renaming of_0x...identifiers, Base decoders, URL decoding, HTML decoding, and bounded decompression calls. - Gzip, zlib-wrapped DEFLATE, raw DEFLATE, BZip2, XZ/LZMA, ZIP/TAR members, and optional Zstandard/Brotli/LZ4 decoding.
- Optional key-backed Fernet, RSA private-key decryption, AES ECB/CBC/CFB/OFB/CTR/GCM/CCM/EAX/OCB/SIV/XTS, ChaCha20 / ChaCha20-Poly1305, and common legacy RC4, Salsa20, Blowfish, DES, and Triple DES attempts.
- Repeating-key XOR when literal key material is recovered from source assignments.
All transforms are bounded. Decoder output exceeding the configured artifact limit is discarded. Archive members are read into memory with per-entry and aggregate size caps; archive names are labels only and are never used as filesystem paths.
Crypto limits
The crypto pass combines literal assignment recovery with static call-site/data-flow roles inferred from recognized APIs such as AES.new, Fernet, ChaCha20, PBKDF2, and decrypt_and_verify. A variable can supply key, IV, nonce, ciphertext, AAD, tag, or private-key material even when its identifier is opaque. This remains a bounded source-level inference: unknown custom APIs and runtime-derived values cannot be assumed. It tries a small, bounded PBKDF2 candidate set for literal passwords and salts. Authenticated modes only produce accepted results after authentication verifies. Unauthenticated decryptions are retained only when the output resembles useful text/source or recognized binary data.
There is no general keyless brute-force path for AES, RSA, Fernet, or ChaCha20. Key recovery depends on the key space and sample details; arbitrary AES keys cannot realistically be searched. RSA decryption requires the matching private key. RSA public-key encryption cannot be reversed from ciphertext and a public key alone.
PyCryptodome and cryptography are optional. Missing crypto libraries only disables their respective decoder branches; the standard-library transforms continue to run.
IOC extraction
iocs.json can include Discord webhook URLs, Telegram bot-token-shaped strings and chat/channel IDs, HTTP(S)/FTP/WebSocket URLs, IP addresses and IP:port pairs, email addresses, domains, SMTP/IMAP/POP3/MX hostnames, plus low-confidence domain_candidate values when a literal has a domain-like shape but an unfamiliar suffix. Matches are passive string indicators, not proof that an endpoint is active or malicious. IOC extraction itself makes no DNS or network requests. An explicit --fetch-remote-stages option can retrieve only literal URLs identified as feeding exec/eval/compile; fetch-stage URL fetches a single analyst-specified URL. Responses are bounded, saved verbatim, and never executed by PyDeob.
Indicator extraction is deliberately performed against every retained layer, since the useful endpoints may only become visible after one or more decoding passes.
Design boundaries
- No
exec,eval,runpy,subprocess,importlibloading, sample import, or execution of the specimen. Network access is disabled unless--fetch-remote-stagesorfetch-stage URLis explicitly invoked. The nested analyzer reads arguments meant forexec/eval/compilewithout invoking those calls. - No invocation of functions defined by the sample. Constant folding is a small interpreter for an allow-listed expression subset only.
- No unpickling and no execution of marshalled code objects. Writing an inert disassembly or best-effort
.pycdoes not run it. - Unsupported expressions, missing keys, bad padding, malformed encodings, invalid tags, and unsupported compression formats fail closed for that candidate.
- A static pass cannot solve every custom VM, dynamically generated key, opaque predicate, environment-derived secret, or algorithm implemented from scratch. It reports the material it can recover without running the specimen.
Tests
py -m pip install -e ".[test]"
py -m pytest -q
Generic source constant propagation and bytecode artifacts (v0.5.0)
The source folder now interprets a deliberately small subset of user-defined Python functions as AST, rather than invoking them. This supports chained string/byte decoders such as bytes.fromhex + single-byte XOR + .decode(), pure helper wrappers, bounded if/for bodies, arithmetic helpers, lambda expressions, list/set/dict comprehensions, symbolic __import__ names, and a small allowlist of math constants and pure functions. It propagates proven constants into straight-line function bodies, renames recognized _0x... helper and variable names by AST shape/content, and renders simple literal __import__('math')-style assignments as ordinary import aliases. Unknown variables remain unchanged unless a conservative fallback name is safe.
The evaluator does not import modules named by the sample and does not call sample functions. Import names are recorded symbolically; only specifically allowlisted pure library operations can be folded. Unknown statements, dynamic inputs, side effects, unsupported crypto, and oversized results fail closed. Existing packed-Python / marshal analysis is retained separately and is still covered by its regression test.
For the attached style of hex-XOR helper obfuscation:
pydeob .\obfuscated.py --out .\analysis-output
Inspect decoded_final_payload.py, all alternatives in layers/, iocs.json, and report.json; the highest-ranked output is not guaranteed to be the only useful layer.
Bytecode output (v0.5.0)
Bytecode output is enabled by default and lands at pydeob-output\bytecode when the default output directory is used. Each recovered code object gets a hash-named .dis.txt, .marshal, and JSON metadata file, plus a best-effort .pyc and an index.json entry. The disassembly recurses over nested code objects and is bounded to 100,000 formatted instructions. Raw marshal blobs and bytecode reports may contain sensitive sample strings; the text disassembly and binary artifacts preserve recovered bytes and strings verbatim for offline analysis. A .pyc is serialized using the current interpreter's magic number, and the code is never executed by this tool. Standalone raw marshal and current-runtime .pyc inputs are also recognized.
Native container triage (v0.5.0)
Inputs identified as PE/Windows (.exe, .dll, .pyd), ELF, Mach-O, ZIP, current-runtime .pyc, or PyInstaller PYZ are routed through a read-only container front end. PE reports include checked header/section/import metadata, per-section entropy, packer-signature hints, and bounded resource extraction. A bounded PyInstaller CArchive parser extracts validated entries; embedded PYZ module payloads are parsed only when the archive's version marker matches the analyzer's running Python minor version. Extracted candidate scripts, archives, encoded blobs, and Python artifacts are fed back into the same static transform and bytecode passes. Container strings and child analysis links are written beneath container/.
This is not a universal native unpacker. UPX, VMProtect, Themida, Nuitka, cx_Freeze, and PyInstaller names can appear as hints, but this version does not unpack arbitrary native packers, reconstruct native machine code into source, or guarantee recovery from every frozen-Python format. ELF/Mach-O currently receive identification and metadata triage; fat Mach-O slices are not split in this pass. Malformed, oversized, version-mismatched, and unknown entries are skipped and reported rather than executed.
Shared budget and source-order fixes (v0.5.0)
The evaluator preserves Python and/or operand-return and short-circuit behavior, processes straight-line assignments in source order, prevents statically known module values from leaking into function locals that shadow them, and treats dictionary **mapping expansion as unknown unless implemented explicitly. Crypto key-material collection now follows assignment order, separates discovered function-local bindings, and infers key/IV/nonce/ciphertext roles from recognized cryptographic API argument positions even when local variable names are opaque. Candidate transforms are streamed into ranking rather than retained as one unbounded intermediate list; aggregate bytes and evaluation steps are charged to the shared run budget.
A small synthetic benchmark corpus lives at tests/fixtures/benchmark/corpus.json; known outputs are asserted by test_benchmark_corpus_has_known_static_outputs. Run the full regression suite with py -m pytest -q. The corpus is a regression floor, not a claim that arbitrary input can be fully deobfuscated.
v0.6.0: obfuscator-v3.1 loader recovery
The automatic obfuscator-v3.1 front end parses XOR/hex bootstrap code and interprets only whitelisted, deterministic assignments. It can recover the supplied loader's concatenated Base64 payload, key derivation, AES-CBC layer, padding, XOR layers, and marshal object without invoking the sample's exec, eval, compile, imports, or function calls.
A successful recovery creates obfuscator_v3_analysis.json and obfuscator_v3_analysis.md; recovered code-object artifacts are written into bytecode/. Decoded string constants are passed through the IOC extractor too, with results stored in the per-payload *.iocs.json and merged into the root iocs.json. The CLI removes the generic ranked .py candidate for this case and writes a text pointer instead, so an unverified file is not presented as decompiled source. The existing generic transform, Blank Grabber/nested marshal path, IOC extraction, and native/container analysis are retained. Use --no-obfuscator-v3 to disable only the specialized loader pass.
For the reference test and its limits, read docs/OBFUSCATOR_V3_1.md. The plaintext fixture is the source supplied for comparison, not an automatically generated decompilation. Incompatible bytecode is retained for analysis, and no .pyc is emitted unless its opcode prologue passes the current-runtime compatibility check.
v0.7.0: source wrappers, Fernet, and captured stages
The new static source-wrapper pass decodes high-confidence whole-text \xNN wrappers, unwraps a plain outer HTML <pre>...</pre> response, extracts source-like constants stored in content/payload/source assignments, and identifies Fernet(key).decrypt(token) calls whose key and token resolve through literal-only expressions or source-order assignments. Authenticated plaintext is written under source_layers/ and fed back into the existing bounded transform graph so later Base64/compression/marshal layers can be analyzed by the previous passes. Fernet is not brute-forced: the key must be present or statically derivable from the supplied source.
Remote loader URLs, including requests.get(...).text patterns, are extracted as IOCs. By default no URL is fetched. v0.8.0 adds an explicit --fetch-remote-stages opt-in that fetches only a literal URL whose response flows into an exec, eval, or compile argument. For one URL, use pydeob fetch-stage URL. For a previously captured response body, use --stage-file path-to-response.txt; this remains offline.
source_layers.json, source_layers.md, and behavior.json record raw indicators, hashes, decrypt statuses, detected remote-code URLs and passive code indicators. Under v0.8.0 the opt-in retrieval stage adds fetched_stages/fetches.json, with full URLs, response status, byte counts, SHA-256 hashes and exact output paths. The behavior matcher is heuristic evidence, not a verdict. A .pyc is still only best-effort serialization, and static recovery does not imply bytecode compatibility or guarantee complete source decompilation.
v0.8.0: recursive exec payloads and explicit remote-stage retrieval
The source-wrapper pass extracts literal-only operands supplied to exec, eval, and compile, including nested static expressions such as bytes.fromhex(...).decode(), Base64, literal concatenation, and supported decoder calls. New candidates re-enter the same bounded decode graph, are written under source_layers/, and continue through compression, crypto, marshal and IOC analysis. Remote URLs are marked as fetchable only when a literal URL's response is consumed by an exec/eval/compile argument, directly or through a simple assigned response variable.
The default behavior remains offline. --fetch-remote-stages explicitly enables GET requests for those recognized source-stage URLs. pydeob fetch-stage URL explicitly fetches a single URL. Both modes use normal TLS certificate validation, public-address checks by default, safe redirect checks, request timeouts, response byte limits, and no cookies or sample-defined headers. --allow-private-network permits private/loopback targets for a deliberately isolated lab. Response bodies are saved byte-for-byte under fetched_stages/ and then analyzed as data. PyDeob never executes fetched responses. Failed requests and URLs are preserved in fetches.json, without silently pretending a payload was recovered.
All recovered strings and IOC values now remain verbatim in JSON, Markdown and text reports; no IOC/token/webhook/Fernet literal redactor is applied. Raw output can contain live credentials or session-bearing artifacts, so store reports accordingly. This is a no-redaction extraction mode, not a guarantee that every possible payload will decrypt: unknown cryptographic keys, custom algorithms, environment-derived values and unsupported bytecode remain unresolved.
Metadata
Release files for pydeob-static 0.10.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pydeob_static-0.10.0.tar.gz | 228.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pydeob_static-0.10.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 449.8 kB
Release files / pydeob_static-0.10.0.tar.gz
| Download URL | pydeob_static-0.10.0.tar.gz |
|---|---|
| Size | 228.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
09d7fe1b36cdd4311196b34c54b1dc23eedf4928649fb8db521c0da441b9ff11
|
|
BLAKE2b-256 checksum How to use checksums |
155c362d0c9081ee0622c99f8f9faa2c267818b30332cc6c7fc3a0793ad96327
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.2
|
Release files / pydeob_static-0.10.0-py3-none-any.whl
| Download URL | pydeob_static-0.10.0-py3-none-any.whl |
|---|---|
| Size | 221.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1139f18ea45ad3c918840313b79ff0ce881638d1eb44b025abcda088460f73ee
|
|
BLAKE2b-256 checksum How to use checksums |
2e95bd9a629500c28e193a07e918d790e12e3f9dae55ba44cc7c78a9aacedb1f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.2
|