Metadata-Version: 2.4
Name: pydeob-static
Version: 0.11.0
Summary: Static-first recursive Python deobfuscation with structural decoder recovery, cross-version .pyc passthrough, bundled pycdas bytecode disassembly, remote-stage retrieval, and IOC extraction
Author: PyDeob Static contributors
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: crypto
Requires-Dist: cryptography>=42.0; extra == "crypto"
Requires-Dist: pycryptodome>=3.20; extra == "crypto"
Provides-Extra: fast-compression
Requires-Dist: zstandard>=0.22; extra == "fast-compression"
Requires-Dist: brotli>=1.1; extra == "fast-compression"
Requires-Dist: lz4>=4.3; extra == "fast-compression"
Provides-Extra: exotic-compression
Requires-Dist: python-snappy>=0.7; extra == "exotic-compression"
Requires-Dist: python-lzo>=1.15; extra == "exotic-compression"
Requires-Dist: py7zr>=0.21; extra == "exotic-compression"
Requires-Dist: cabarchive>=0.2; extra == "exotic-compression"
Provides-Extra: xdis
Requires-Dist: xdis>=6.1; extra == "xdis"
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Provides-Extra: all
Requires-Dist: cryptography>=42.0; extra == "all"
Requires-Dist: pycryptodome>=3.20; extra == "all"
Requires-Dist: zstandard>=0.22; extra == "all"
Requires-Dist: brotli>=1.1; extra == "all"
Requires-Dist: lz4>=4.3; extra == "all"
Requires-Dist: python-snappy>=0.7; extra == "all"
Requires-Dist: python-lzo>=1.15; extra == "all"
Requires-Dist: py7zr>=0.21; extra == "all"
Requires-Dist: cabarchive>=0.2; extra == "all"
Requires-Dist: pytest>=8.0; extra == "all"
Dynamic: license-file

```markdown

\# pydeob-static



\*\*Static-first recursive Python deobfuscation with cross-version `.pyc` passthrough, bundled `pycdas` disassembly, and IOC extraction.\*\*



`pydeob-static` analyzes obfuscated Python samples -- loaders, droppers, grabbers, cracked packages, malware staging chains -- \*\*without ever importing, executing, or evaluating the target\*\*. Every transform is a pure function over bytes or over an AST built from the sample. Nothing is `exec`'d, `eval`'d, `import`'d, or compiled. The input is data, not code.



The tool walks a bounded transform graph from the input toward its payload, producing:



\- Reconstructed `.pyc` files compatible with any Python 3.7-3.13 interpreter (via raw passthrough -- no `marshal.loads` gate)

\- Structured `pycdas` disassembly for every recovered code object

\- Every encoded string, URL, domain, IP, bot token, and webhook target the sample carries, from the raw bytes through every decoder layer to the final payload

\- A deobfuscated payload bundle at the output root so the finished artifact is one file away



\---



\## What it does not do



\- \*\*Does not execute the sample.\*\* No `exec`, `eval`, `compile`, `import`, `subprocess`, or `pickle.loads` on sample data. Every operation is a static read over bytes and an allow-listed AST interpreter.

\- \*\*Does not contact URLs unless explicitly told.\*\* `--fetch-remote-stages` or `pydeob fetch-stage <url>` are the only network paths. Both save responses verbatim as inert data and analyze them without execution.

\- \*\*Does not guess crypto keys.\*\* AES, ChaCha20, Fernet, RSA, and DES decryption all require the key, IV, nonce, tag, or private key to be present as a literal source assignment. No brute force.



Every honest ceiling is documented under \[Limitations](#limitations).



\---



\## Install



```bash

pip install pydeob-static

```



The wheel bundles the `pycdas` binary (`zrax/pycdc`) and requires no external tooling. Windows, macOS, and Linux wheels are built from the same source tree; the `pycdas` binary bundled is currently Windows-x64 only. On other platforms `pydeob-static` still runs -- you lose `.pycdas.txt` output and code-object IOC mining until `pycdas` is available on `$PATH` or via `PYDEOB\_PYCDAS=/path/to/pycdas`.



Optional extras:



```bash

pip install 'pydeob-static\[crypto]'               # AES / ChaCha20 / Fernet / RSA

pip install 'pydeob-static\[fast-compression]'     # zstd / brotli / lz4

pip install 'pydeob-static\[exotic-compression]'   # snappy / lzo / 7z / cab

pip install 'pydeob-static\[xdis]'                 # cross-version marshal reader

pip install 'pydeob-static\[all]'                  # everything above

```



\---



\## Quick start



```bash

\# Analyze a pycdas / pydisasm / pyc-dis dump

pydeob dis.txt



\# Analyze a marshalled code object, an obfuscated source file, or a native binary

pydeob sample.pyc

pydeob obfuscated\_loader.py

pydeob packed\_loader.exe



\# Analyze a pycdas dump and (only if you want to) fetch literal URLs

\# that the sample feeds directly into exec / eval / compile

pydeob --fetch-remote-stages dis.txt



\# Fetch a single stage URL as inert data and analyze it

pydeob fetch-stage https://example.com/stage.js

```



The default output directory is `<input-stem>-output/`.



\---



\## Output layout



```

sample-output/

├── report.json                     # summary, config, budget snapshot

├── iocs.json                       # every extracted indicator

├── layers.json                     # full transform graph

├── decoded\_final\_payload.py        # best-ranked "final" artifact

├── decoded\_final\_payload.json      # which artifact won and why

├── deobfuscated\_payload.py         # stub pointing at the finished payload

├── deobfuscated\_payload.pyc        # real .pyc of the finished payload

├── deobfuscated\_payload.marshal    # raw marshal bytes

├── deobfuscated\_payload.pycdas.txt # pycdas disassembly of the payload

├── deobfuscated\_payload.json       # payload metadata + IOC count

├── decoded\_strings.json            # every successful decode attempt

├── source\_layers.json              # wrapper analysis results

├── source\_layers.md

├── behavior.json                   # dynamic-behavior signals

├── layers/                         # every intermediate artifact

│   ├── L0001\_xz-lzma.py

│   ├── L0002\_xz-lzma.dis.txt

│   └── decoded-<sha>.js            # extracted payload wrappers

├── bytecode/

│   ├── payload-<sha>.pyc           # cross-version passthrough

│   ├── payload-<sha>.marshal       # raw bytes

│   ├── payload-<sha>.dis.txt       # stdlib dis (when loadable)

│   ├── payload-<sha>.pycdas.txt    # pycdas disassembly

│   ├── payload-<sha>.json          # per-artifact metadata

│   └── index.json                  # bytecode index

└── source\_layers/                  # recovered wrapper source

```



`deobfuscated\_payload.\*` is the payload after the loader has been unwrapped. `decoded\_final\_payload.py` is the loader itself -- a valid, byte-identical copy of the input artifact that ranked highest. Both are surfaced because both matter.



\---



\## What it recovers



| Family | Recognized by | Example |

|---|---|---|

| \*\*BlankOBF\*\* | `\_\_\_\_\_\_\_ + \_\_\_\_\_\_\[::-1] + \_\_\_\_\_` chain + marshal | Blank Grabber, common stealers |

| \*\*obfuscator-v3.1\*\* | AST-only dataflow over `pbkdf2\_hmac` + AES-CBC | Payload loaders |

| \*\*adversarial-v2\*\* | RC4 + reverse + inverse-sbox string pools | Advanced Python obfuscators |

| \*\*Structural decoders\*\* | `range(256)` + b64 table + reverse + subst lookup | Unknown per-sample decoders (detected by shape, not by name) |

| \*\*exec/eval/compile\*\* | Literal argument reconstruction | Staged loaders |

| \*\*Base64 / base32 / base85 / base58 / base91 / hex / decimal ints / binary\*\* | Alphabet + structure | String encoding |

| \*\*zlib / gzip / bz2 / lzma / xz / zstd / brotli / lz4 / snappy / 7z / cab\*\* | Magic + bounded decompression | Second-stage stubs |

| \*\*Fernet / AES-GCM / AES-CBC / ChaCha20 / ChaCha20-Poly1305 / RSA / DES / 3DES / RC4\*\* | Constant table signature + literal key | Encrypted payloads |

| \*\*PE / ELF / Mach-O / PyInstaller CArchive / PYZ\*\* | Container magic | Native packers |

| \*\*Discord webhook / Telegram bot / SMTP / IRC endpoints\*\* | Regex + decoded-string mining | C2 |

| \*\*JS payloads inside `ast` string constants\*\* | Base64 -> UTF-8 detection | Discord injectors |

| \*\*Remote loaders\*\* | Literal URL fed to `exec(urlopen(...))` | URL droppers |



Anything else gets the generic sweep: XOR single-byte and multibyte cryptanalysis, custom alphabet base-N, encoded-integer arrays, exotic decodings (uuencode, xxencode, BinHex, Punycode, Braille, Bacon, Morse, base122, base65536-adjacent), URL percent and HTML entity unescape, ROT-1 through ROT-47, and multi-layer chain solving.



\---



\## Version support



`.pyc` and marshal formats handled by passthrough: \*\*Python 3.7 through 3.13\*\*.



The writer never re-marshals. It stamps the producer's version header onto the producer's own bytes and hands them to `pycdas`, which reads the magic number and dispatches. This means:



\- A 3.10 payload under a 3.11 interpreter produces a loadable 3.10 `.pyc`

\- A 3.13 payload under any interpreter produces a loadable 3.13 `.pyc`

\- Cross-version works in both directions



When the running interpreter matches the producer version, the stdlib `dis` module output is included as a second disassembly view. When it doesn't, `pycdas` output is the only one -- which is fine because `pycdas` is the version-aware one.



\---



\## Budget and safety



Every run shares three hard ceilings:



| Budget | Default | Configurable | CLI flag |

|---|---|---|---|

| Wall clock | 240 s | yes | `--max-seconds` |

| Aggregate bytes | 256 MiB | yes | `--max-total-bytes` |

| Evaluation steps | 2,000,000 | yes | `--max-steps` |



`BudgetExceeded` inherits `BaseException` so nothing in the transform graph can swallow a cancellation. Every BFS node, every chain solver probe, every regex sweep, and every AST walk ticks the shared budget. There is no path that runs unbounded.



Separate ceilings per artifact: `--max-bytes` (default 16 MiB) caps each individual recovered blob; `--max-depth` (default 10, CLI ceiling 32) caps graph traversal; `--max-artifacts` (default 10,000) caps total nodes.



The output scanner applies its own caps: 32 MiB per file, 512 MiB total, with a bounded decoder depth (3 for runs under 4 KiB, 1 for larger). Every decode attempt is recorded in `decoded\_strings.json` whether it succeeded or failed.



\---



\## CLI reference



```

pydeob <input> \[options]



Positional:

&#x20; input                        Python source, encoded blob, .pyc, pycdas dump,

&#x20;                              or supported native/container file



Output:

&#x20; -o, --out <dir>              Output directory (default: <input-stem>-output)



Budget:

&#x20; --max-seconds <int>          Wall-clock ceiling (default 240)

&#x20; --max-total-bytes <int>      Aggregate byte ceiling (default 256 MiB)

&#x20; --max-steps <int>            Evaluation-step ceiling (default 2,000,000)

&#x20; --max-depth <int>            Transform-graph depth ceiling (default 10, max 32)

&#x20; --max-artifacts <int>        Total artifact ceiling (default 10,000)

&#x20; --max-bytes <int>            Per-artifact byte ceiling (default 16 MiB)

&#x20; --max-input-bytes <int>      Whole-file input ceiling (default 512 MiB)



Transform toggles:

&#x20; --no-xor                     Skip bounded single-byte XOR search

&#x20; --no-crypto                  Skip key-backed crypto attempts

&#x20; --no-deep-python             Skip nested marshal analysis

&#x20; --no-obfuscator-v3           Skip obfuscator-v3.1 frontend

&#x20; --no-source-wrappers         Skip wrapper / Fernet / remote-loader analysis



Inputs:

&#x20; --stage-file <path>          Additional saved stage content (repeatable)



Network (explicit opt-in only):

&#x20; --fetch-remote-stages        GET only literal URLs whose responses feed

&#x20;                              exec/eval/compile; save as inert data

&#x20; --fetch-timeout <float>      Per-request timeout (default 15 s)

&#x20; --fetch-max-bytes <int>      Per-response cap (default 10 MiB)

&#x20; --allow-private-network      Allow fetch to private/loopback (lab only)



Fetch a single stage directly:

&#x20; pydeob fetch-stage <url> \[options]



Version:

&#x20; --version

```



\---



\## How it works (a short tour)



1\. \*\*Root scoring.\*\* The input is classified (Python source, pycdas dump, marshal blob, `.pyc`, native binary, container) and dispatched.

2\. \*\*Binary frontend.\*\* PE, ELF, Mach-O, and PyInstaller CArchive/PYZ containers are parsed read-only. Embedded artifacts -- including `.pyc` files inside `CArchive` and modules inside `PYZ` -- are extracted to `container/extracted/` and re-analyzed as fresh roots.

3\. \*\*Bytecode lane.\*\* Any marshalled code object discovered in the graph goes through `write\_bytecode\_artifacts` (same-version) or `write\_pyc\_passthrough` (cross-version). Both produce `.pyc`, `.pycdas.txt`, and mine the pycdas text for IOCs.

4\. \*\*Source wrapper analysis.\*\* Exec payloads, nested assignments, `<pre>` wrappers, unicode-escape wrappers, and Fernet tokens are recovered. Fernet decryption only fires when the key is a literal; the key and token are both recorded verbatim. Remote URLs are recorded as indicators, never fetched unless `--fetch-remote-stages` was passed.

5\. \*\*Recursive transform graph.\*\* Each artifact is classified, routed to the matching transform family (crypto, compression, encoding, obfuscation, script), and the top candidates per node are ranked and kept. `max\_depth` bounds this.

6\. \*\*Deep unpack.\*\* Every `xz-lzma` / `lzma-auto` / `gzip` / `bzip2` / `deflate-raw` / `zlib` artifact that parses as Python is fed back through the embedded-wrapper evaluator (`analyze\_packed\_python`), which resolves the loader chain without executing it. On success, the deobfuscated payload is surfaced at the output root.

7\. \*\*Output scan.\*\* Every file under the output directory is read, every encoded run is decoded up to 3 levels deep, and every decoded variant gets its own IOC pass. Originals are never modified.

8\. \*\*Report.\*\* Everything accumulates into `report.json` and `iocs.json` in memory and is written once at the end. A run that hits its budget writes a report through the stage that hit it, not a half-merged one.



\---



\## Limitations



Honest list, because a static analyzer that oversells is a liability.



\*\*Depth.\*\* The transform graph is capped at `--max-depth` (default 10, CLI ceiling 32). Mixed 3-10-layer chains work today. Mixed 11-32-layer chains work if you raise the CLI flag. Deep same-primitive stacks (300x base64) do not -- that would need a dedicated loop pass that is not shipped. If you have a real sample like that, \[open an issue](https://github.com/Blank-c/BlankOBF) with the shape and it can be added.



\*\*Crypto.\*\* AES, ChaCha20, Fernet, RSA, DES, and 3DES all require the key material as a literal source assignment. If the sample fetches its key at runtime from a server, static decryption is impossible -- full stop. The tool reports which call sites it found and what it attempted; it does not guess.



\*\*Container parsing.\*\* PE and ELF are parsed to the extent of extracting resources, imports, and PyInstaller CArchives. Full disassembly of native code is out of scope. Obscure or packed PE layouts (VMProtect, Themida) are detected by string but not analyzed further.



\*\*`pycdas` cross-version.\*\* The bundled binary tracks `zrax/pycdc` master. Versions 3.7-3.13 are covered as of the current build. If a sample ships with 3.14 opcodes before `pycdc` catches up, use `pip install pydeob-static\[xdis]` -- the xdis fallback reader is wired in and covers broader version ranges.



\*\*Non-Windows `pycdas`.\*\* The bundled binary is Windows-x64. On macOS and Linux, `pydeob-static` runs but produces no `.pycdas.txt` output until `pycdas` is available on `$PATH` or via `PYDEOB\_PYCDAS`. `.pyc`, `.marshal`, `.dis.txt` (when the running interpreter can load the code object), and all IOC extraction still work.



\*\*Semantic reconstruction.\*\* `pycdas` disassembly is not decompilation. It is a version-aware instruction listing plus a full constant-table dump. If you need human-readable source reconstruction from a marshalled payload, that is a separate tool (decompyle3, uncompyle6, pycdc) run against the `.pyc` this tool produces. `pydeob-static` is the front half: it gets you a loadable `.pyc` from an obfuscated chain. The back half is yours.



\---



\## Development



```bash

git clone https://github.com/Blank-c/BlankOBF  # placeholder -- update to the real repo

cd pydeob-static

pip install -e '.\[all]'

pytest

```



The repository layout:



```

pydeob-static/

├── pyproject.toml

├── MANIFEST.in

├── README.md

├── LICENSE

├── src/

│   └── pydeob/

│       ├── \_\_init\_\_.py         # \_\_version\_\_

│       ├── \_\_main\_\_.py

│       ├── cli.py              # entry point, main()

│       ├── engine.py           # BFS transform graph

│       ├── budget.py           # shared AnalysisBudget

│       ├── models.py           # Artifact / Candidate / IOC

│       ├── scoring.py          # content scoring

│       ├── transforms.py       # generic transform families

│       ├── decode\_planner.py   # bounded chain solver

│       ├── exotic\_decoding.py  # uuencode / base122 / etc.

│       ├── advanced\_decoding.py # custom alphabet / bit-packed

│       ├── xor\_cryptanalysis.py

│       ├── cipher\_signatures.py

│       ├── crypto\_static.py    # AES / ChaCha / Fernet / RSA attempt

│       ├── crypto\_context.py   # call-site key discovery

│       ├── decoder\_recovery.py # structural decoder inference

│       ├── obfuscator\_v3.py    # specialized obfuscator-v3.1 frontend

│       ├── adversarial\_prelude.py

│       ├── ast\_static.py       # static AST evaluator

│       ├── ast\_passes.py       # opaque-predicate / MBA / string-array

│       ├── source\_wrappers.py  # exec payloads / Fernet / URL sinks

│       ├── binary\_frontend.py  # PE / ELF / Mach-O / PyInstaller

│       ├── disassembly.py      # pycdas text constant mining

│       ├── nested.py           # embedded wrapper -> marshal chain

│       ├── pyc\_versions.py     # cross-version .pyc headers

│       ├── pycdas\_runner.py    # bundled pycdas invocation

│       ├── output\_scanner.py   # decode-everything pass

│       ├── ioc.py              # regex + AST indicator extraction

│       ├── pattern\_detect.py   # family/priority classification

│       ├── priority.py         # content-signal router

│       ├── urlfetch.py         # explicit HTTP retrieval (opt-in)

│       ├── regexes.py          # shared compiled regexes

│       └── bin/

│           └── pycdas.exe      # bundled pycdas binary

└── tests/

```



Every module is documented in its own docstring. The short version is: `engine.py` orchestrates, everything else is a transform or a helper that `engine.py` calls.



\---



\## Licensing



`pydeob-static` is released under the MIT License.



The bundled `pycdas` binary is from \[`zrax/pycdc`](https://github.com/zrax/pycdc) and is distributed under GPLv3. The GPL obligations apply to the binary itself; the surrounding Python source remains MIT. If you distribute a build of `pydeob-static` that contains the binary, you must also make the corresponding `pycdc` source available (the upstream repository is the canonical source).



\---



\## Credits



\- \[`zrax/pycdc`](https://github.com/zrax/pycdc) -- the `pycdas` disassembler

\- The BlankOBF, Blank Grabber, and Discord Injection communities -- the samples that shaped most of the design

\- The `uncompyle6` / `decompyle3` lineage -- the conceptual model for want-to-run-this-safely static analysis



\---



\## Status



`0.11.0`. Working on obfuscated Python from 3.7 through 3.13, cross-version, static-only. Every limitation is real and documented above. If a new obfuscator shows up, the codebase is designed to absorb it: a new `Candidate` producer drops into `transform\_all`, and the graph, budget, scoring, and reporting layers pick it up for free.

```



\## Why this matters



The mangled version you pasted would show up on the PyPI package page as `‚Äî` and `‚îú‚ÄÄ‚îÄ‚Äî` — literally unreadable to any visitor. The ASCII version renders perfectly on PyPI, in `notepad`, in `vim`, in VS Code, everywhere.



Difference between the two versions:

\- `—` → `--`

\- `├─` / `└─` / `│` → box-drawing preserved (these are safe in most modern editors, but if you want fully-safe, replace with `+-`, `\\-`, `|`)

\- `✅` → `yes`

\- `→` → `->`

\- `×` → `x`

\- `‚Äî` → `--`



Ship this ASCII version. Save it with Notepad (default UTF-8 on Windows 10+), or `Set-Content -Encoding utf8 README.md` in PowerShell. Then `rmdir /s /q dist build src\\pydeob\_static.egg-info \&\& python -m build \&\& python -m twine upload ...`. Clean.

