8.7 KiB
patchwork
Python source obfuscator. Give it a .py file, get back a single self-contained .py that does the same thing but is hostile to read or debug.
Each build is unique by default. Pass --seed N if you want a reproducible one.
Version 0.2 adds accountability features for legitimate software-protection workflows: static source audits, build manifests, and strict audit refusal for inputs that use sensitive APIs.
What it actually does
Source-level rewrites first:
- Identifiers get renamed to confusable junk like
_OoIl01l1OOIlO0. Variables, function names, classes, import aliases, exception bindings. - String and bytes literals get XOR'd with random per-literal keys and replaced with calls to a tiny decrypt helper.
- Integer literals get rewritten as random equivalent expressions: XOR cancellations, sum displacements, shift round-trips,
int.from_bytesdecodes, affine identities. - Bitwise ops get pushed through MBA identities (
a^bbecomes(a|b) - (a&b)and similar). ifandwhiletests get wrapped with always-true tautologies anchored on a runtime seed (s*(s+1) % 2 == 0and friends).- Random dead-branch blocks get inserted with realistic-looking code, gated by an always-false runtime predicate so they never actually run.
Then it compiles to bytecode and gets weirder:
- Each user function and method's compiled code object is pulled out, encrypted into its own blob, and replaced in the marshaled module with a kind-matching stub (function / generator / coroutine / async-generator). On first call, the stub finds itself via frame inspection, decrypts its blob, swaps its own
__code__in place, and re-invokes. So a static dump of the marshaled module just shows function shells. - The whole module then goes through
marshal->zlib-> several rounds of cipher: XOR-CTR with a SHA-256 keystream, byte permutation, bit rotation. Order and keys are random per build.
Then the loader:
- Two stages. Stage 1 is the file you ship, a small mangled script that decrypts and execs Stage 2. Stage 2 is compiled, marshaled, multi-cipher encrypted, and embedded as bytes inside Stage 1. It holds the lazy blob table, the resolver, and the user payload.
- Both stages run anti-debug:
sys.gettrace,sys.getprofile,sys.moduleschecks forpdb/bdb/pydevd/debugpy/etc,PYTHONBREAKPOINTenv check, an audit hook that blocks subsequent imports of debugger modules and any call tosys.settrace/sys.setprofile, and a frame-stack walk looking for debugger frames. - Large bytes literals get split into 4-9 random chunks, shuffled, and reassembled at runtime, so the payload doesn't show up as one continuous blob in the source.
Requirements
Python 3.9 or newer. Stdlib only, no third-party packages.
The output has to run on the same Python major.minor you built it on. marshal format is version-bound.
Install
git clone https://github.com/bikini/patchwork.git
cd patchwork
That's it. If you want the patchwork console script on your PATH:
pip install -e .
Use it
python -m patchwork myscript.py
python myscript_obf.py
Custom output, reproducible build, more cipher layers:
python -m patchwork myscript.py -o protected.py --seed 12345 --layers 5
From Python:
from patchwork import Obfuscator, obfuscate_file
obfuscate_file('myscript.py', 'protected.py', seed=12345)
obf = Obfuscator(seed=12345, layers=4, stage2_layers=4)
out = obf.obfuscate(open('myscript.py').read())
Flags
python -m patchwork INPUT [-o OUTPUT] [options]
-o, --output PATH output path (default: <input>_obf.py)
--seed INT RNG seed for reproducible builds
--layers INT cipher layers around the user payload (default 3)
--stage2-layers INT cipher layers around stage 2 (default 3)
--keep NAME identifier to leave un-renamed (repeatable)
--no-rename turn off identifier renaming
--no-encrypt-strings turn off string/bytes literal encryption
--no-obfuscate-numbers turn off integer literal obfuscation
--no-opaque turn off opaque predicate injection
--no-mba turn off Mixed Boolean-Arithmetic
--no-junk turn off junk dead-branch injection
--no-lazy turn off lazy per-function encryption
--no-anti-debug turn off runtime anti-debug probes
--audit-only analyze the input and exit without writing output
--audit-json PATH write static audit metadata as JSON
--manifest PATH write build manifest with hashes/options/audit data
--strict-audit refuse inputs that contain sensitive API indicators
-q, --quiet quiet mode
Audit and Manifest Workflow
Audit a file without generating an obfuscated output:
python -m patchwork app.py --audit-only --audit-json evidence/app.audit.json
Generate a manifest alongside a reproducible build:
python -m patchwork app.py --seed 12345 --manifest evidence/app.manifest.json
Refuse to transform files that contain review indicators such as dynamic execution, process spawning, or sensitive standard-library imports:
python -m patchwork app.py --strict-audit
The manifest records the input and output SHA-256 hashes, Python version, Patchwork version, build seed, selected options, and static audit metadata.
Python API
from patchwork import Obfuscator
obf = Obfuscator(
seed=42,
rename=True,
encrypt_strings=True,
obfuscate_numbers=True,
opaque_predicates=True,
mba=True,
junk_branches=True,
lazy_funcs=True,
anti_debug=True,
layers=3,
stage2_layers=3,
keep={'public_api_name'},
)
output_source = obf.obfuscate(input_source)
obfuscate(src, **kwargs) is the one-shot version.
obfuscate_file(in_path, out_path=None, **kwargs) reads, obfuscates, writes, returns the output path.
Stuff that'll trip you up
Python version matters. Marshaled bytecode is tied to whatever Python major.minor built it. Build on 3.11, run on 3.11. Mismatch and it won't load.
One file at a time. This obfuscates a single module. For a package, run patchwork on each .py. Names that cross module boundaries (one obfuscated file importing another) keep their original spelling because we can't see across files.
Attribute access is left alone. obj.x doesn't get rewritten. The x could be addressing a stdlib method, a third-party API, anything. Class methods and class-body attributes are auto-skipped from renaming for the same reason. If you have other names that get accessed externally, throw them in --keep.
Generators, coroutines, async generators, decorators. All work. Stubs are kind-matched so you don't get flag-mismatch warnings. Decorators that call user functions at def time work too, because lazy resolution kicks in on first call.
sys.argv[0] will point at the obfuscated file, not the original.
This is not a cryptographic guarantee. Anything that runs on a target machine can eventually be reverse-engineered. A motivated person with a debugger can patch out the anti-debug checks, dump the unmarshaled code objects, and decompile back to source. What this buys you is depth: every layer has to be unwrapped before structure appears, and what you'd recover is heavily mangled. It's a speed bump, not a vault.
Examples
python -m patchwork examples/hello.py
python examples/hello_obf.py
# Hello, World! (from patchwork)
# number: 42
# computed: 285
examples/stress.py is the torture test - decorators, generators, classes with super(), match statements, walrus, exceptions, *args/**kwargs, globals. If something doesn't work, it'll usually break here first.
Tests
python tests/test_obfuscator.py
python tests/test_audit.py
Runs every example through the obfuscator at multiple seeds, executes original and obfuscated versions, and checks stdout matches byte-for-byte. Also confirms different seeds produce different output and the same seed produces stable output.
Layout
patchwork/
├── pyproject.toml
├── requirements.txt
├── README.md
├── .gitignore
├── examples/
│ ├── hello.py
│ ├── fizzbuzz.py
│ ├── classes.py
│ └── stress.py
├── patchwork/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── core.py
│ ├── crypto.py
│ ├── packer.py
│ ├── lazy.py
│ ├── loader.py
│ ├── util.py
│ └── transforms/
│ ├── identifiers.py
│ ├── strings.py
│ ├── numbers.py
│ ├── opaque.py
│ ├── mba.py
│ └── junk.py
└── tests/
└── test_obfuscator.py
MIT.