Metadata-Version: 2.4
Name: snakecharm
Version: 0.1.1
Summary: Deobfuscate Python without executing it. A webcrack for Python.
Project-URL: Homepage, https://veryserious.systems
License-Expression: MIT
License-File: LICENSE
Keywords: deobfuscation,malware-analysis,obfuscation,python,reverse-engineering
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Information Technology
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Requires-Python: >=3.11
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.0
Requires-Dist: typer>=0.12
Description-Content-Type: text/markdown

# snakecharm

**Python De-Obfuscation**

Unwraps obfuscated Python — `exec`/`eval`/`compile` chains over base64, hex,
zlib/gzip/bz2/lzma, XOR, `marshal`, reversal and `chr`/`ord` reconstruction —
without executing the sample.

Requires Python 3.11+. Pure Python; depends on `typer`, `rich`, `pyyaml`.

## Install

```console
pip install snakecharm
uv tool install snakecharm     # isolated, puts snakecharm on PATH
```

## Commands

```console
snakecharm crack FILE [-o DIR] [--json] [--clean]
snakecharm detect FILE [--json]
snakecharm inspect FILE [--target-python X.Y]
snakecharm version
```

| | |
|---|---|
| `crack` | Unwrap. Recovered source to stdout, diagnostics to stderr. |
| `detect` | Fingerprint the obfuscator family; do not unwrap. |
| `inspect` | Disassemble a `.pyc` or bare marshal stream. |
| `version` | Print the version. |

`FILE` may be `-` to read from stdin.

Cleanup is a flag on `crack` (`--clean`), not a separate command.

### `crack` options

| Flag | Default | Meaning |
|---|---|---|
| `-o, --output DIR` | — | Write the full artifact tree instead of stdout. |
| `--json` | off | Emit `report.json` to stdout. |
| `--clean` | off | Also emit a cleaned view (see [Cleanup](#cleanup)). |
| `--target-python X.Y` | host | Version to assume for marshal payloads. |
| `--decompiler NAME` | auto | Force a backend instead of routing by version. |
| `--max-layers N` | 512 | Layer cap; `0` for no limit. |
| `--max-memory MiB` | 1024 | Total decoded output; `0` for no limit. |
| `--max-fold MiB` | 256 | Largest single decoded value; `0` for no limit. |
| `--timeout SEC` | 300 | Wall clock; `0` for no limit. |
| `--no-peel` | off | Skip unwrapping; report detection only. |
| `--no-detect` | off | Skip fingerprinting. |
| `--plugins` | off | Load third-party ingest adapters. |
| `-q, --quiet` | off | Suppress the stderr summary. |

Exit codes: `0` complete, `1` incomplete, `2` input could not be processed.
Identifying the obfuscator does not affect the exit code.

## Output

```console
$ snakecharm crack sample.py
import os
import sys

MESSAGE = "recovered exactly"
LIMIT = 42
...

31 layers, depth 30, 431,204 bytes folded in 0.006s
detected: freecodingtools (100%)
```

With `-o`:

```console
$ snakecharm crack sample.py -o out/
wrote out/
2 layers, depth 1, 1,966 bytes folded in 0.001s
detected: hyperion (100%), freecodingtools (67%)

$ find out -type f
out/deobfuscated.py
out/layers/00_depth0.py
out/layers/01_depth1.py
out/report.json
```

`--clean` adds `out/cleaned.py`. `layers/` stays byte-exact.

Each layer file carries its derivation:

```python
# ---------------------------------------------------------------
# snakecharm layer 1  (depth 1, source)
# provenance : zlib <- b64decode
# from       : exec() at line 13, col 0 of layer 0
# sha256     : 58dda7518dfd60b35d3b522a654ad6c8532b076846f3a37d98340b05f2131acd
# ---------------------------------------------------------------
```

When a site cannot be resolved it is reported, not skipped:

```console
$ snakecharm crack script.py
not_reached : could not fold the argument of exec(): not reached during
interpretation, and folding it in isolation also failed: unbound name 'handle'
incomplete: 1 site(s) could not be resolved statically
```

## Library

```python
import snakecharm

result = snakecharm.crack(open("sample.py", "rb").read())

result.code  # deepest recovered source
result.complete  # bool
result.unresolved  # [Unresolved(reason, message, layer, line, col)]
result.layers  # [Layer(...)], each with .provenance and .digest
result.detections  # [Detection(family, confidence, evidence)]
result.stats  # {'layers': 31, 'max_depth': 30, 'bytes_folded': 431204, ...}
result.cleaned  # str | None, when Options(clean=True)
result.save("out/")  # writes the tree above
```

`snakecharm.Options` mirrors the CLI flags. `snakecharm.Budget` holds the
resource limits.

To run the cleanup passes on their own, without peeling:

```python
from snakecharm.clean import clean

result = clean("X = 1 + 2\n")
result.code  # 'X = 3'
result.changed, result.refused, result.rolled_back
```

## Handles

- `exec`/`eval`/`compile` chains nested to arbitrary depth (default cap 512).
- Wrappers: `try`/`except`, `with`, class bodies, `if __name__ == "__main__"`,
  `async def`, decorators.
- `.pyc` files and bare marshal streams, Python 3.8–3.14, from any host version.
- PEP 263 source encodings, decoded before analysis and recorded in the
  provenance chain.
- Family fingerprinting: freecodingtools/pyobfuscate.com, Hyperion, Berserker,
  Kramer, BlankOBF, Jawbreaker, PlusOBF, Vare, Anubis, Mr.X, PyArmor, Intensio,
  Nuitka, PyInstaller, python-minifier. Detection is additive — an unrecognised
  sample is still unwrapped. Signatures not confirmed against real output are
  marked `[unvalidated signature]`.

Nothing in the default path executes sample code. Analysing live malware in a
disposable VM is still advisable.

### Cleanup

`--clean` runs, in order: constant folding, `getattr(o, "lit")` → `o.lit`,
removal of branches whose condition folds to a constant, and removal of unused
function locals.

Module-level globals and uncalled functions are never removed. A round that
fails to parse or compile is rolled back whole.

## Does not handle

| | |
|---|---|
| Accurate 3.11+ decompilation | No released decompiler handles it. Falls back to annotated disassembly, labelled as such. |
| Identifier renaming | Mangled names are returned unchanged. |
| Packed executables | PyInstaller / cx_Freeze / py2exe are not unpacked. `detect` still names the packer. |
| Nuitka | Native machine code; no `.pyc` exists in the binary. Named, not recovered. |
| PyArmor BCC / RFT | Native C, and one-way identifier destruction, respectively. |
| MBA obfuscation | Arithmetic seeded with live in-scope variables defeats static folding. |
| Runtime-fetched payloads | Not present in the file. |

Decompiler backends (`uncompyle6`, `decompyle3`, `pycdc`) are used if found on
`PATH`. They are GPL, so they are never bundled, never imported, and invoked
only as subprocesses; without them, `.pyc` input renders as disassembly.

MIT. Created and provided by veryserious.systems.
