From 42ccb9ed6966c2818e679ad7593b085de6c521be Mon Sep 17 00:00:00 2001 From: bikini <63224111+bikini@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:17:18 -0500 Subject: [PATCH] Add files via upload --- README.md | 248 +++++++++++++++++- examples/classes.py | 47 ++++ examples/fizzbuzz.py | 16 ++ examples/hello.py | 8 + examples/stress.py | 58 ++++ patchwork/__init__.py | 3 + patchwork/__main__.py | 3 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 303 bytes .../__pycache__/__main__.cpython-314.pyc | Bin 0 -> 295 bytes patchwork/__pycache__/cli.cpython-314.pyc | Bin 0 -> 4646 bytes patchwork/__pycache__/core.cpython-314.pyc | Bin 0 -> 6464 bytes patchwork/__pycache__/crypto.cpython-314.pyc | Bin 0 -> 5608 bytes patchwork/__pycache__/lazy.cpython-314.pyc | Bin 0 -> 5349 bytes patchwork/__pycache__/loader.cpython-314.pyc | Bin 0 -> 18781 bytes patchwork/__pycache__/packer.cpython-314.pyc | Bin 0 -> 1539 bytes patchwork/__pycache__/util.cpython-314.pyc | Bin 0 -> 2727 bytes patchwork/cli.py | 44 ++++ patchwork/core.py | 79 ++++++ patchwork/crypto.py | 48 ++++ patchwork/lazy.py | 88 +++++++ patchwork/loader.py | 143 ++++++++++ patchwork/packer.py | 23 ++ patchwork/transforms/__init__.py | 5 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 498 bytes .../__pycache__/identifiers.cpython-314.pyc | Bin 0 -> 19933 bytes .../__pycache__/junk.cpython-314.pyc | Bin 0 -> 6499 bytes .../__pycache__/mba.cpython-314.pyc | Bin 0 -> 13220 bytes .../__pycache__/numbers.cpython-314.pyc | Bin 0 -> 7004 bytes .../__pycache__/opaque.cpython-314.pyc | Bin 0 -> 4076 bytes .../__pycache__/strings.cpython-314.pyc | Bin 0 -> 9397 bytes patchwork/transforms/identifiers.py | 167 ++++++++++++ patchwork/transforms/junk.py | 64 +++++ patchwork/transforms/mba.py | 93 +++++++ patchwork/transforms/numbers.py | 70 +++++ patchwork/transforms/opaque.py | 42 +++ patchwork/transforms/strings.py | 84 ++++++ patchwork/util.py | 25 ++ pyproject.toml | 25 ++ tests/test_obfuscator.py | 58 ++++ 39 files changed, 1440 insertions(+), 1 deletion(-) create mode 100644 examples/classes.py create mode 100644 examples/fizzbuzz.py create mode 100644 examples/hello.py create mode 100644 examples/stress.py create mode 100644 patchwork/__init__.py create mode 100644 patchwork/__main__.py create mode 100644 patchwork/__pycache__/__init__.cpython-314.pyc create mode 100644 patchwork/__pycache__/__main__.cpython-314.pyc create mode 100644 patchwork/__pycache__/cli.cpython-314.pyc create mode 100644 patchwork/__pycache__/core.cpython-314.pyc create mode 100644 patchwork/__pycache__/crypto.cpython-314.pyc create mode 100644 patchwork/__pycache__/lazy.cpython-314.pyc create mode 100644 patchwork/__pycache__/loader.cpython-314.pyc create mode 100644 patchwork/__pycache__/packer.cpython-314.pyc create mode 100644 patchwork/__pycache__/util.cpython-314.pyc create mode 100644 patchwork/cli.py create mode 100644 patchwork/core.py create mode 100644 patchwork/crypto.py create mode 100644 patchwork/lazy.py create mode 100644 patchwork/loader.py create mode 100644 patchwork/packer.py create mode 100644 patchwork/transforms/__init__.py create mode 100644 patchwork/transforms/__pycache__/__init__.cpython-314.pyc create mode 100644 patchwork/transforms/__pycache__/identifiers.cpython-314.pyc create mode 100644 patchwork/transforms/__pycache__/junk.cpython-314.pyc create mode 100644 patchwork/transforms/__pycache__/mba.cpython-314.pyc create mode 100644 patchwork/transforms/__pycache__/numbers.cpython-314.pyc create mode 100644 patchwork/transforms/__pycache__/opaque.cpython-314.pyc create mode 100644 patchwork/transforms/__pycache__/strings.cpython-314.pyc create mode 100644 patchwork/transforms/identifiers.py create mode 100644 patchwork/transforms/junk.py create mode 100644 patchwork/transforms/mba.py create mode 100644 patchwork/transforms/numbers.py create mode 100644 patchwork/transforms/opaque.py create mode 100644 patchwork/transforms/strings.py create mode 100644 patchwork/util.py create mode 100644 pyproject.toml create mode 100644 tests/test_obfuscator.py diff --git a/README.md b/README.md index 1c2a20a..4848015 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,248 @@ # patchwork -A polymorphic multi-stage python obfuscator that mangles source via AST rewrites, encrypts each function's bytecode into a separate lazily-decrypted blob, and wraps everything in a multi-cipher anti-debug loader + +Multi-layer polymorphic Python source obfuscator. + +Takes a `.py` file and emits a single self-contained `.py` file that, when run, +executes the original program. Every build is unique — different mangled +identifiers, different encryption keys, different decryption-stub layout, and +different junk filler. + +## Features + +- **Identifier renaming** — every variable, function, class, import alias, and exception binding gets a confusable identifier (`_OoIl01…`). +- **String / bytes encryption** — every literal is XOR-encrypted with a per-literal random key, decrypted on demand. +- **Integer obfuscation** — every int literal becomes one of: XOR cancellation, sum displacement, bit-shift round-trip, `int.from_bytes`, affine identity, or split-sum. +- **Mixed Boolean-Arithmetic (MBA)** — bitwise operators rewritten with algebraic identities (`a^b → (a|b) - (a&b)`, etc.). +- **Opaque predicates** — `if`/`while` tests wrapped with runtime-true tautologies (`s*(s+1) % 2 == 0`, Fermat's little theorem) anchored on a runtime seed. +- **Junk dead branches** — realistic-looking unreachable blocks gated by always-false runtime predicates. +- **Lazy per-function code encryption** — each user function/method's compiled `code` object is encrypted into a separate blob; the marshaled module only contains stubs. Real code is decrypted on first call and the function's `__code__` is swapped in place. +- **Multi-cipher payload packing** — `marshal` → `zlib` → N (default 3) layers chosen at random from `{XOR-CTR with SHA256 keystream, byte permutation, bit rotation}`, each with its own random key. +- **Multi-stage loader** — outer Stage 1 decrypts and execs Stage 2 (compiled, marshaled, multi-cipher encrypted). Stage 2 holds the lazy blob table, the resolver, and the user payload. +- **Anti-debug** — `sys.gettrace`, `sys.getprofile`, `sys.modules` debugger probe, `PYTHONBREAKPOINT` env check, audit hook blocking debugger module imports / `sys.settrace` / `sys.setprofile`, full frame-stack walk for debugger frames. Probes are scattered through both stages. +- **Chunked bytes literals** — payloads >64 bytes are split into 4–9 shuffled chunks, reassembled at runtime via an inverse-permutation tuple. +- **Polymorphic** — every emitted name is mangled fresh; ordering of independent statements is randomized; junk filler is interspersed. + +## Requirements + +- Python 3.9+ +- Standard library only — **no third-party dependencies** + +The obfuscated output must be run on the same Python `major.minor` that built +it. `marshal` format is version-bound. + +## Install + +```sh +git clone patchwork +cd patchwork +``` + +That's it — no install needed. + +Optionally install editable to get the `patchwork` console script: + +```sh +pip install -e . +``` + +## Quick start + +CLI: + +```sh +python -m patchwork myscript.py +python myscript_obf.py +``` + +Custom output, reproducible build, more cipher layers: + +```sh +python -m patchwork myscript.py -o protected.py --seed 12345 --layers 5 +``` + +Python API: + +```python +from patchwork import Obfuscator, obfuscate_file + +obfuscate_file('myscript.py', 'protected.py', seed=12345) + +obf = Obfuscator(seed=12345, layers=4, stage2_layers=4) +protected_source = obf.obfuscate(open('myscript.py').read()) +``` + +## CLI reference + +``` +python -m patchwork INPUT [-o OUTPUT] [options] + + -o, --output PATH output path (default: _obf.py) + --seed INT RNG seed for reproducible output + --layers INT cipher layers wrapping the user payload (default 3) + --stage2-layers INT cipher layers wrapping stage 2 (default 3) + --keep NAME identifier to leave un-renamed (repeatable) + --no-rename disable identifier renaming + --no-encrypt-strings disable string/bytes literal encryption + --no-obfuscate-numbers disable integer literal obfuscation + --no-opaque disable opaque predicate injection + --no-mba disable Mixed Boolean-Arithmetic + --no-junk disable junk dead-branch injection + --no-lazy disable lazy per-function encryption + --no-anti-debug disable runtime anti-debug probes + -q, --quiet suppress informational output +``` + +## Python API + +```python +from patchwork import Obfuscator + +obf = Obfuscator( + seed=42, # int or None for fresh randomness + rename=True, + encrypt_strings=True, + obfuscate_numbers=True, + opaque_predicates=True, + mba=True, + junk_branches=True, + lazy_funcs=True, + anti_debug=True, + layers=3, # cipher layers around user payload + stage2_layers=3, # cipher layers around stage 2 + keep={'public_api_name'}, # names you don't want renamed +) +output_source = obf.obfuscate(input_source) +``` + +`obfuscate(src, **kwargs)` is a shortcut for `Obfuscator(**kwargs).obfuscate(src)`. +`obfuscate_file(in_path, out_path=None, **kwargs)` reads, obfuscates, writes, returns the output path. + +## How it works + +``` +input.py + │ + ├─ AST passes: + │ opaque-predicate injection + │ junk dead-branch injection + │ MBA on bitwise ops + │ integer-literal obfuscation + │ string/bytes encryption (helpers prepended) + │ identifier renaming + │ + ├─ compile to module code object + │ + ├─ lazy function encryption: + │ walks co_consts, replaces each user-defined function/method + │ code with a kind-matching stub (function / generator / coroutine + │ / async-generator); the original code goes into a per-function + │ encrypted blob in a side table + │ + ├─ marshal → zlib → N cipher layers ─────► user payload + │ + ├─ build stage 2 source: + │ cipher helpers, anti-debug, lazy blobs table, resolver + │ function, user-payload decryption + exec + │ + ├─ compile stage 2 → marshal → N cipher layers ─────► stage 2 payload + │ + └─ emit stage 1 source (the file you ship): + polymorphic mangled imports, anti-debug, audit hook, + frame-stack walk, cipher helpers, stage 2 payload (chunked), + decryption layers, exec + +output.py +``` + +At runtime: + +``` +python output.py + │ + ├─ stage 1: anti-debug, decrypt stage 2, exec it + │ + ├─ stage 2: anti-debug, decrypt user payload, build user namespace + │ with __pw_resolve_lazy__ injected, exec user code + │ + ├─ user code runs with stub function bodies. On first call, each + │ stub invokes __pw_resolve_lazy__, which: + │ 1. finds the calling function via sys._getframe(1).f_code + │ 2. decrypts that function's blob + │ 3. swaps the function's __code__ in place (and caches it) + │ 4. re-invokes for native semantics + │ + └─ subsequent calls use the now-real code directly +``` + +## Caveats + +- **Same Python version**: marshaled bytecode is tied to the major.minor that built it. Obfuscate on 3.11 → run on 3.11; obfuscate on 3.12 → run on 3.12. +- **Single-module scope**: patchwork obfuscates one file at a time. For a package, run it on each `.py` separately. Names that cross module boundaries (imports of one obfuscated module from another) keep their original spelling. +- **External attribute API**: `obj.x` attribute access is never rewritten — `x` might address something external. Class-body method names and class attributes are auto-kept. If you expose other names externally, use `--keep`. +- **Generators / coroutines / async generators**: fully supported with kind-matching stubs. +- **Decorators**: supported. Lazy resolution kicks in on first call, including the call made by `@decorator` at `def` time. +- **`sys.argv[0]`**: will be the obfuscated file path, not the original source path. +- **Not a cryptographic guarantee**: a determined reverse-engineer with debugger access can patch out anti-debug, dump unmarshaled code objects, and decompile. The defense is depth: every layer must be unwrapped before useful structure appears, and the decompiled output is heavily mangled. + +## Examples + +```sh +python -m patchwork examples/hello.py +python examples/hello_obf.py +# Hello, World! (from patchwork) +# number: 42 +# computed: 285 +``` + +`examples/stress.py` exercises decorators, generators, classes with `super()`, +`match` statements, walrus operator, exception handling, `*args`/`**kwargs`, +and globals/nonlocals — useful as a torture test. + +## Tests + +```sh +python tests/test_obfuscator.py +``` + +Runs every example through the obfuscator at multiple seeds, executes original +and obfuscated versions, and verifies `stdout` is byte-identical. Also checks +polymorphism (different seeds → different output) and reproducibility +(same seed → stable output). + +## Layout + +``` +patchwork/ +├── pyproject.toml +├── requirements.txt +├── README.md +├── .gitignore +├── examples/ smoke-test inputs +│ ├── hello.py +│ ├── fizzbuzz.py +│ ├── classes.py +│ └── stress.py +├── patchwork/ +│ ├── __init__.py +│ ├── __main__.py python -m patchwork +│ ├── cli.py argparse front-end +│ ├── core.py pipeline orchestrator +│ ├── crypto.py XOR-CTR / permutation / rotation primitives +│ ├── packer.py multi-cipher payload packing +│ ├── lazy.py per-function code-object encryption +│ ├── loader.py polymorphic 2-stage loader generator +│ ├── util.py confusable-name generator + RNG +│ └── transforms/ +│ ├── identifiers.py +│ ├── strings.py +│ ├── numbers.py +│ ├── opaque.py +│ ├── mba.py +│ └── junk.py +└── tests/ + └── test_obfuscator.py +``` + +## License + +MIT diff --git a/examples/classes.py b/examples/classes.py new file mode 100644 index 0000000..d3c997e --- /dev/null +++ b/examples/classes.py @@ -0,0 +1,47 @@ +from collections import deque + +class Shape: + + def __init__(self, name: str): + self.name = name + + def describe(self) -> str: + return f'shape<{self.name}>' + + def area(self) -> float: + raise NotImplementedError + +class Rect(Shape): + + def __init__(self, w: float, h: float): + super().__init__('rect') + self.w = w + self.h = h + + def area(self) -> float: + return self.w * self.h + +class Circle(Shape): + + def __init__(self, r: float): + super().__init__('circle') + self.r = r + + def area(self) -> float: + return 3.14159265 * self.r * self.r + +def total_area(shapes): + q = deque(shapes) + total = 0.0 + while q: + s = q.popleft() + try: + total += s.area() + except NotImplementedError: + continue + return total +if __name__ == '__main__': + shapes = [Rect(3, 4), Circle(5), Rect(1, 1), Circle(2)] + for s in shapes: + print(s.describe(), '->', round(s.area(), 4)) + print('total area:', round(total_area(shapes), 4)) diff --git a/examples/fizzbuzz.py b/examples/fizzbuzz.py new file mode 100644 index 0000000..c3af00d --- /dev/null +++ b/examples/fizzbuzz.py @@ -0,0 +1,16 @@ +def fizzbuzz(n: int) -> str: + if n % 15 == 0: + return 'FizzBuzz' + if n % 3 == 0: + return 'Fizz' + if n % 5 == 0: + return 'Buzz' + return str(n) + +def main() -> None: + out = [] + for i in range(1, 21): + out.append(fizzbuzz(i)) + print(' '.join(out)) +if __name__ == '__main__': + main() diff --git a/examples/hello.py b/examples/hello.py new file mode 100644 index 0000000..367268e --- /dev/null +++ b/examples/hello.py @@ -0,0 +1,8 @@ +GREETING = 'Hello, World!' + +def greet(name: str) -> str: + return f'{GREETING} (from {name})' +if __name__ == '__main__': + print(greet('patchwork')) + print('number:', 42) + print('computed:', sum((i * i for i in range(10)))) diff --git a/examples/stress.py b/examples/stress.py new file mode 100644 index 0000000..b8362c9 --- /dev/null +++ b/examples/stress.py @@ -0,0 +1,58 @@ +from functools import wraps +COUNTER = 0 + +def trace(fn): + + @wraps(fn) + def wrapper(*a, **kw): + global COUNTER + COUNTER += 1 + return fn(*a, **kw) + return wrapper + +@trace +def factorial(n: int) -> int: + if n < 0: + raise ValueError('negative') + return 1 if n <= 1 else n * factorial(n - 1) + +def squares(limit): + return [x * x for x in range(limit) if x % 2 == 0] + +def gen_pairs(seq): + for i, v in enumerate(seq): + yield (i, v) + +def make_adder(by=1): + + def add(x, *, scale=1): + return (x + by) * scale + return add + +def categorize(n): + match n: + case 0: + return 'zero' + case x if x < 0: + return 'negative' + case _: + return 'positive' + +def run() -> None: + print('factorials:', [factorial(i) for i in range(7)]) + print('squares:', squares(10)) + print('pairs:', list(gen_pairs('abcd'))) + add5 = make_adder(5) + print('adder:', add5(3, scale=2)) + print('category:', [categorize(x) for x in (-1, 0, 7)]) + if (n := sum(squares(8))) > 100: + print('walrus-large:', n) + else: + print('walrus-small:', n) + try: + factorial(-1) + except ValueError as e: + print('caught:', str(e)) + print('counter:', COUNTER) +if __name__ == '__main__': + run() diff --git a/patchwork/__init__.py b/patchwork/__init__.py new file mode 100644 index 0000000..2522499 --- /dev/null +++ b/patchwork/__init__.py @@ -0,0 +1,3 @@ +from .core import Obfuscator, obfuscate, obfuscate_file +__version__ = '0.1.0' +__all__ = ['Obfuscator', 'obfuscate', 'obfuscate_file'] diff --git a/patchwork/__main__.py b/patchwork/__main__.py new file mode 100644 index 0000000..daa0cfc --- /dev/null +++ b/patchwork/__main__.py @@ -0,0 +1,3 @@ +from .cli import main +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/patchwork/__pycache__/__init__.cpython-314.pyc b/patchwork/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca981aefab56c79a36a18e84a85149102d6b68dd GIT binary patch literal 303 zcmdPq$i2 zy|YzJXfaSa#=kr-wJ63VwK%&ZzaXYKKdq!Zu_!gBAh9GlqddPT8;KPkpP83g5+AQu zP4BO~K|2J?#y<~R668@O+AnK!W)fx-y@mvvLZ literal 0 HcmV?d00001 diff --git a/patchwork/__pycache__/__main__.cpython-314.pyc b/patchwork/__pycache__/__main__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7acaec4602fee64db9958fb0838b472f1a28f6cd GIT binary patch literal 295 zcmdPqUMhB(F`Mo$J0#u7$(hFGQ`CW{CLh7>*q z1{sD}W<`crMnwh_s0I^82qS|rh*_CIljS9dugQ3eB{wlM?-oaVJcy2u_tRv##hjdz zS;P#K1_|dS=BCES-{K0cEG|jSb*;!OS;_DjWYjHtXRDad;?$zz82|FT)S?)d)Z*-t z{DPR`{Irtt#G=%gg2a;KjPm@VY$O)U9=(FfTO2mI`6;D2sdh!&K=VNEDOLg!AD9^# o8SgTP-eBhLsJg-|d6P?IV#wsM%Um)SSY$pjGq6Y%aRQYA0Pd_yX8-^I literal 0 HcmV?d00001 diff --git a/patchwork/__pycache__/cli.cpython-314.pyc b/patchwork/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51dd60ef701f0206e7fe638a121f8b6f1f72e4b8 GIT binary patch literal 4646 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 literal 0 HcmV?d00001 diff --git a/patchwork/__pycache__/lazy.cpython-314.pyc b/patchwork/__pycache__/lazy.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..46f99f717d2a0fe032726a315b856bb8107a99b7 GIT binary patch literal 5349 zcmbtYZ%kX)6~EW-`M-@#a0nsHF*u<(A%upcNz)`PrKF{yggpl*T@6n!=0RMHZQb`M zfkvA*OY&Xw0@YheV8UvqvE_$snR~>o1-kvHtoaCxz9F) zKvQW~^10{Ud*1zX?m546t`F9ET?EQE@7+tj=_ceK*r*o4Wa#~00+Sx;Lb_YB>mJFWdnM;C zBDr>vlwYrt+`xM{?~}a1*Kyu2`GEIxzFumO>Y=p*TPdpVkSt*m3Eag^MyTOd(&>z@ z=*di48-lh=)0wJF%U3fDTFyx|Enl9~Rc&54m7O){nY)A%k|Y*F62M@C7ZE6&o8Aur zlOr)QW;yXwLdK!5g?yi0p%RrWk|2qPVfCBVyYM|R#6)IX{ZE}fmsPc{KHx6^XV{pk z&#<&1z)o)B7Htz5Hm&FyuGMBd*LN~LqNz-apPx;uEdG+JP3f6zT+2-8vkFt=Sw&A= zna!}N4O~i@pXGGR8Q+I`-DU>+ z7BdKs&0y|B41OOw6PcXd@BPa3nJFL`s>| z3_EaXWm-j}QJAjHCiN?ZLrsr!ERAvv`33X=lOrX! z@7>eyoX$VJ>TX$ax7_hHmhA4IUHkF1ReSKZJ($;4?cvYu;YIOxo|~T4j?=e0PTxwb zcD(p`$BTD-O?TWoSp&@Th+R6MgWpsKUtxQ@l&oN%Hpy}rG!n5h^ln3(N~XsdZqN{t z<5z*Cb;HY4Et7gnW&G&O3!}^j1di*6%Cu5`Ty9^SG~$4{gNGzQZTFBYXl1Ml4ZK>4 z^6FNUkC_L7jn@jA1wV)m44RxoPFw;9gN?QTz{D&bFq0SsDjb0eg>iAixOKz0tuk)k zFz(nqE;%>%L|leb(dN<#88BhKo3o*Dbs{8d`po5y1G3T?I&eUqikx(XpqS&}xgq(j zP%<4dXvcg@X9%TgGAK8fR8!+11G6VGEHk4g)9U=8C(Uj`L`a!cl6q*~Rht)Pf!(Q0 z(KBp*|Mt5xSDj#)>Ck-Z_F|P~%=;Bs2vpBnk%WP0cs=dy71Q=`HON|Kh9y)MfgyhB z?*jvdZFgUPd&S;d7O5{>3U?O{FV!#gEbT87dZM)~SiEgzL3FyxF5>Xi1Vg3Jq5O{) zl_gixv0Cb)EHin3%!LzVPp}< zoX26zYJ1#IMA#=cuMpqx_I`r+&72_gM58|hLN#P{AhH5k7%F2`(0FToA0W#MA|l_% z$G~idtXUeP@fvn+g|cSckq9^fjpNyP`4&maB@y(VBnju21i%Q9?FUhYh|JKn*=``v zi?PILko(Tdz0uc)`sA1Uhx(=73+E-ni=BPvrSl^f28R00W^FWL;b>{l2@Oxy9x@Tr zDI*5S;76<*y17oD1qN)&;d$5ljyKm^vO8AXL4e2=d+?608Ja&oy=re-u{S-|`oAI4 zCfOl`BSbnlM4H~vhDUZ}SaLF%R#MCZP3|EaI=>r2o`7#^2v60Zf{7_A1stPn@1<>g{`%yP8S%8Kz?*o}!2;Aw5d8n1;@ z$+C;Ytiu>mHWfcPTIs2+WQ*B`G5l>Raa0a39>i$DOX3(`Dm_Pp7#+qavZ+8X<|>NaTNQL8_ND1J4|&KPa-v@)fd;EFjz*Qrz{p`^4kT{W3gutjYzkh^fK z<@&)7NAvWzZ{Di^WB;Fqem}I@b?$@K_gg<2{kMiU_mj3BdehZI4SF>quszUYxG|w& zs`{3~G~P(4sT4LMF79+3%`w@T&L&f;A;Qjdj8>4tz_B0=Vrn*}z~S73y+3$e9+vvg z4qU1wbWeG!=&Wsz@WE$d^hDHam1tX*h1inpr zU;`k*y~a!k*4%-GOGS4u_tKiHaX~K}D!RIJeQU1zh48|B;hCcA0JJ>*+x3I_=qIDU z8ZFij7Co=!`b%y<=3HwH!G%Kwq0pP3z43Nodf9)p6lhrubleVf)R?lJcy`|>qQ_Py z;#NTflP0i8dcPAG1n=!00g!AVk~k!%8^tPtJRpk{-`YcS*cj zhZMsxrOwG(a$aQ!Jse@?yY-oDN`2$e$3$l+mDKb%%pv}c=?w8|baeEI0qiB1TYH8d z1+%sm%*0(saziB~66d=tnh#!kHDSSQJ6ADz+TyA{nbh z5M5$I46?YGt;Z@+c(6HA{){bZYelTERQ)e2#>A+#2cL1mBgrn#4;e7MzmISFTlH@_ zj6uw1qBsJTsdsl4o=|C>N1g8m-$tjJPO!PGF3-TLOs+l@v~@?__~v>UR?^S|@VVu) zK6?*I9&`#7rod3$8snX&`YMnE9}t4>%a8XBK>H~xsx52T`t9W>Xa5G^wgK*A!g?Ms zT#NI3UQVjYt%BS5hnQH$G1K8CR(nB#`r);XL3ljPUtyv|c`7~0zo>zJ)KLot);K;B zc!|2B&gxoOTO3~wyu@p;zZKMj0o2xy*=pb0qmJR7)fv|7i<3RI7vo+Mvuq_3-&K{r z7>&BB;jX%4fbvfP*0=mJ>S-kxk5>1v;fp_P_r?0&Vcx%+Ap-6V6Q)fij)2Sy1ZTKq zERyATg#&{X4I7~3OiDG=Ey`X53CNwIP%+H7BXzusq2IFONDw6XOVbciYMNmvWayQK zeHt+S3cPNu<1^D)&9EuiEIeL0va&vuuyWvtczJ5ZhOx_)R%a`~&rV{8B{9Vz-4Fnj z3;~?MuwG7OE^F*H7&bv`5Cxq&z?p{sWoT#(Cb)+}IV|RTaSX*GPjaW)G~Ac|0^j@# zvcWyyyY4F0@BAS6i{SO%HQ(-h>&GLXjD0w^>g)X6*SWScT<9(CY+taH8iLDh$5tAS z-D>;HwQ};+wdU{T)sGG=^sP0A3w?`C#pWZ(@4IpFH>019E(`3D6U&Yh+~dug7l3hM6#G$SX0V)?mBG0`L z@ipaz{H0=Dd+z0ur(t1a*}Z33+{1Skp?90$vq*#Ck^$!6KPvnW2{SM@0tEiIGQ$)a zonhmUm@y4agja3KG(23L^&{y*atH}VOKzY%(J)gEjP889JW83G;eVkxPMP+9h5_90 zzu&a;z`)y%(yvI{m!$4X;{B3%z9PY|$evPNL(W~Qs|Vr_X9alr;5=KDoo(UY0BauIN)bEbZ2$m4!_m zwsw2c-d>(8uNQoKk`7-(vcmUTveIYXBKXR-2+^u!mCu5(m4&N)HiYdg?DUl*>|o&< zUj@RIEWF8Ag>W?s*ZQ0Y*Rb$r-zJ1>S$Ku)!GW2is;4a!jD^P3LCmN?KXM9wGNNC*5SSJGLcet613?HQZHI7K z91(q@Px8q=Wk0oS`ke%{v^bD9@k-5_nw-#LX<1KdpHr92Lvd{^l+>yIJ?VGbS_4;g zRnr5PredlVIIZfV$#^25$A^+rAx#Y=Ldn6Askk;;5Q@e_VO8@cX42)sU}l7BFsN0d zN(X-WHh8nb?*&oxJSA`L&=UglS6J+8=h%D`5s z9J{SW#(1Sv1skV#NQ;k+sMGGW=)oXm1U;fgyO#~LQL92QZRax`)YOEgZAJ>~?G^-P zg*C_a`IA36^P@9AKKG+{7D9&IZ76QdiM(~oLED}!$7`(AiykQAgH_liBrJ)te!(jij5t?MbHR)!tO42~%;j6E z63${2QFL6u%wE z(&R6PZ*DO)V664{CCVZ=1HU_ghM!B#u65^LK_aGJ{Q3w2dxUyy#~$Go^m zriEEkz0irR$p$1e2#4f|__+wm%1$%LspLJ776*0OM~yCi=&i>i9SKJ=5u`Nn_K@CU5{ z`6>HAQjfK-nG@>WdCL|B5=`wv}VrVF;rmbphVocRSNj0rdGad)0aa+-Nx-7; z4C;}TirbH7Ur)=4xSlq}HC!TTF_N}qY7D34NNh4K#?#hBNK5KLT^meW5~?;vA)O|s zvC)-xe0eL@uc>wvXpBZWad>Bi$7Qzr2mf?(PWs^1^1M z{-X1{opYvDW%GY)aIaK)etKv=G&lZ3>zwtchi2Q2O3#|T{C)$fKl738k!{;-+vBRb z`MqCNxgIF@#kFeZ{gWS>pUOh@?%!5z`l0m))?d=LT-kPPrRv?;jouU~7z9P!`BZ>+|IQNv3pFg3zjcAj997SHw~9dNJ1ZfA zX~^AGycqdlI;-+`l3yg&!A}f?)S|GM%wkF=W@0fj^UGR5+kt6eF>40H#$tBzTTCpU zmnqNWJ6L`N^DA4--m-*@o)ysUY~TEHmau_KsMd&PrM9}>b-AOVv4L>N6`XFx&(#ml z&w+Os1-zMO=BZH{eD;~B! zXq{76>^lr)NB+QIZkhX|+0@*TPAjA8jGn4YP9&mgFQkWNm)Gm0H5qkCaNDLR0n>5?|<77|#TcSPVb$?;=TS6S^Aq$dIDcBs&RdITQ|i z%)%V56<(uZNtd_rq3&2wT(DJ}CSN(l^_AV$BDx6O&LV4xv`Hd@23^gN_#QeUD zkV8OGd}d#n&jQ&I?3%~QhPK-ov2+ z>jr^<$%lTB_(CQdggis){R+aS77K|nK68n3>zMNwl(Up5XMI(B`K{^CakV3&$Cj3- z1UOxWP%x|xrbV~514CwnAL;_IK^bOE?dBl5y0jIJ5jUZ*a=!) zX1#y)gIjB0mq5M2K6QN7JilvUe6eQHwCGzhFHSFw8GOO|EPsBASXt>3)P$F@y#z7M^IvUTIGryav~iHmjl#bP;WNj^7m_Jz$5 z?R8Gntiw4_usVcYJ&Ji%Lu089_h_>#=4rJNAFpA<(}oObT4=`YVQGU%JHSFyBax`e z7#7XrI_8S;GQ8aDbW%n_`bab~kh<*EM?x)!-gb|Itr_$t<6OLRA8B?q4Mc{UJRWa2 zGOX&!OcPYapbJ$TQrA0f<+rNP_CnJ)X?jg@BWCxU+Q*(}A~;xD_0Whf`ZJ+8{TCFQ(AS zj3f2exdG-!k{cTvLjpC?_H{EM&5g;(bZ{6&nY`vs@E)TFz|GG(p|UDSE{rt0CaK>! z(GI2Z(~hFOlYJ;1**I^=^JspWHD}?h@zzpJ7;?!ny#rhzWwRCbXrSRj>VFs3fE#sP ze|bF@XuMd5OtqQ5vs&_f4;S=3h63(4MtdW!y{-#=#M~Zc9iqJu87kadl!8XRT=<3X zJm5Np-ADX2g2#`$+(*DT#~?IkIAq=`%+u#0qt}JXN=Hx~ zizI_1Y813z*Hp|5BRhY__~*3>=iI@$>z^E(^BS%(BNjIjGml~^W9$}q#M|N}#wvD` z-y<{b^Sb;##d^VgJ`;rNvP!Y{@)Ut-d4@nrZc~6VEuU5po088EIHTMn;FC|7 z08X3Q33Qmw6X-NuHK9NUGN_ZnT83Bm^4BeGU*gk@dyBrG)5X!jqF2xeodj#%Thiw4u))4|xTY|v2O(US&ZriAU?GD8fwz~xG+L9FjH*FIH z$|uP;O;+To`yb79f1Xhe=Wn7M|IPWVeysW{x$6@^%5m4SSp8IhM|TmxBJ{xk%N;C{ zcR9lmqm&@z*sK4F2(gPkp@@`=Ep#DmV5L~9QiN+_spg{8GL~9=by!*Ih6{F<`u1C?Ek)Tpxyhr2(A^D{XYMd$4^nn< zVY(aQV@)2=hu4u0Ih<`3+9hdIG!j#F9qa_@#kh=-AsT$px|TrR3H=!#+8r}nlUbu7=1!INwV%Ksa?Xc zIDuz9#O{gb37nUL1n!782&mE!fnh05n5vYZSVpKGm)eQ23`iXWx@1P3y5;LciMlvl zx+wQjUN0AUJMn6W*o>Saa0?^|m^1QOB07;X0MvP@K10; zwPDQMD^DN^e+4JdNr%lj4>|#`*vG)~3`=~;34F}fh_PP9EZxgk!eU%g36_r7$C9k# z3vh`J6tkd~5^1Q{t9;{gY1MjYGNDlto^SO+XOz@JgR0x(is-Jccue)Uz=CKuDckA| zs?(9A`=BSajW82hdj!ADI*Rh?_O$C!${DG?rMYHk`} zeqtu9PKFT;s{<3mi5Y}=QgC1*7LKaLalCR;0X!M%@#v(gQGRk#ry4X~?-c3lYpQUphMWlk7Q68q~qGBgeftgs036cM&OJTC!oU`0ANCyBrv7iq5>1j zZHgt8I|S}1r-=bLV?ImZoVk;jgEQs}6g$m1hKpuD#m1Fu1S;M$Lqg4H+s&)WmNk3z z?8S{FK*1a_5zK|a%M>IeDr$W|dgbvaZBBtW7&4Lg7cvn6Ah>vx$MbywK6508v%a(= z@Dg#b_>9ZYJPW=Ox38D(KXd8}61wP-Cm5Ot;~1%vYAo68f}&Q>@WkMGS)%LRcU?{V zx@gL?u0dj#3A^_|AF;<2WZW$_a3R|aJhq#^?FlP!g7S?M-^{20&Qq2OutwEruGg$p zT}th^#5U_lRBwj zqYulpv4`dPU9{1Mtl zG}y|x4|r@`QGk5hBXkqcUBb6haF{l7aL79p9FE2Z@Yc&IbC9cIgPbC>&4sN6Y_E>j zjHEslNshSP{NjKPfo={^>de*Q%HJKXrXWiN-Ole{zIS)5-@B~$tRn+3{9zPj!(OZA z^qh?gubLqE+HFi1n{-Yf)+%c7P@;a)KG%v>-*t~@@Co@OZLw2$j=)|yA+zU*dz|BH z=VG3NNqObNglEbuPuiBhH+t4R1y-R=MwE`QwR*8G%?GA}>5r{Go6qhm_c?qOzDl2E zKQybJDy~f~hvr-hCPFi0G{o&+Dp^uAG=mI!V|c!WDc%Ym8kmx@uP<$jhEg-Z0j!+< zysiL=Y|OWG`p7s$6Mp1tY+sI@4xzMLm0}yn|7tl>ySimm~N};@%A;J;j-|=%8#senenBPm1539n%L1`=(PD->)JROSYT=)iFg;CHCzCisi6{J5ZNXIRlZ?*Ga=FO;N zkMYzZb=PFaoQHmUwg&bjk$cjp7cN%6V4ZUIK(H$+-By`jMr{^t>>BnKSaPmkFtVDW zk!>o0C$|)gs&t=Gs8EIRJ`n|RA ztdy<`qpd3{wTiP2REHPtJiF((aT;HyeC)1YQ1HN6&*{uVJtF1m&Ppoxv zCILZny^e{cL7Bq@-Q4qCs)F&E>>@*f-n7`4s$8Gc7n;MJOVtj>!z$k4aQ_4K)*TzA zg~8Dv2JRM57>u_^;!p(d2166ELHBU8iyL9E!WmXXS5;zucp2iU!P*RtB89yZ40|W( zu~3IC2xEUZ55-XOoD97buq1CHo+x-ciATc7iGsmYNzJ=Ku4_8^ZHGIcbJNDL;@7&tXe^BxT5YGv8$YG%kU z9|A-9X2YQRGecpe)-}wRDDz&yvh#CF1vgCphB<)Dghz(tOaT{51?gF3h>LiW-XXBu zcpmZ&VMj3-R-;AuR0efZkz!7qvI^b0J`csdZHMWc6*W$son)_9v_52@5)9@x`T9*k z9a-0b$;?L1Ofd-%?3kstxg+6eE;!>Ua4OT}5bXjkiYyd}A_w^>=v8k>%ZnUrt@Px? z0NbzGOgiQ)hZZ9|omFA2rvUkGgHdkAvmCvS<_SkH3ijo;!up~tkGeSuqV>GYOZ+#U zH|b$BM3l{q$}l8#CijvVic;n4C{Vrue!wya>nKldWPJ1(P6oGN53mypdcku%g@Yy1 zmLDcZ$e#)HUkydq4Uq~k+L5m>zk4zZ$g6ShBfUklYu`Ss9IFF6lMj*6>2*}aO${}> zhB;!Uo}t*fI?_rwgsOd*Lv4(v)$ScQatO8)SWCASrId0nMaL2lNpyGG!iaf%;HIWi z8B%RBT|`=n>8UC7H!l*)*yBc8JewB%X%n*nPTT0x46>IFtpg}&QB8B1k~b6PCN5;% zq7(N{RxOQ7_5JB;ky@%>XudI%}nIuo%5-M-S~~qr&g->ttzkoLs|8j z#qqHGLHTO!-i4cs)%b-LZZ0*f)V{rHIh-w5JEwoNZB^Mp=(St!+kU-i*MrmdPp*}3 z{#E(TUzYD&5dW2Tsd{D45o7n!W#`{^eR*)@n9q3UigBgK*md=*azBiMYwLf@TbjGO z5dPPZpGKB!D~<0Mdyg%jG-{7OvV13#ZPYfdTADH~LrUFrsuF%%WjBFSm6r)zu8a~G ztBetdS0*c=PZ+D5pjfnWlE7qTuM;5P>?6?c9CiXF;2fb?uQNg*;!F@2cWMN5=WRk6 zciy2`!g-g#U1w?wfb-Ut{{wx*?JbXM>V8$z@b@(h3l~;u4$fZ4bQ7A6p-(#I-kBd< z*uBvHv)31PE{^`K_sbnCt-Z$4fD!03s{2=!;NzO@^OwG=X?{?4U%KCxnfQ@za+4KR>!WxKexSD@z+DZPOdStp5dZ`rNA0UTR@8t1~`dJ%8x4 zt@AcxPb-S`{NnJk=Zm98?Zs6~SH6YtC+FslVrF(`H15nk^=j5Wb(pk0BhmzcVQG@U zq%=*yIU~_brlf3DhtOQ>XFKOB7qrFQi>Lox(_$TLX{+B_RSvD$D<2+xaCGkKihcWh z_>p~&q3mI*hqNPeIGAPzmiuJ3iz{m)mRzSEa^lc1_0TZ{UZy{PfqJOIhY$GnTfs+H zb2BVP7f9E^iNkj(#3;m{QizcW(iRk{aI`jMU!N|co)jogSd=5kkBj)@{Age?Ya{0FW=S|ndYYo>v z@WY<$Fj^*;u;*1feth`jlis2pQcih*rk=TL|JiT$$%ivJOp)^6p<1c*gNS5t@~}XZ z{~+6otUv|DMP(_GD^Qy)@89sb70Q@$Y8^;d5J*e6OJDOb2QeQCD!&y!rDP9E^*XOy zuw;9ig{;@5%H$La{?cVisC`~B($XW_xp8FKzGdMbH?skXhAl{OfH1RAmx`>XP+LdvQ_;Xbf8hgFen87dxcM#WQ|aF*^xAiF_GshC}R zsL|H<&F?Isrx9MU+Kcdt(+F>C(OME-aavu=u2-$EZ(E5K-dlXt1vq}D%Y?&;rP1O2px}g*=cK+Iu`zG_4 z0fj3*iD2J%?&yBAxGbJmbM5$c6shHQ;)^84Evy$RQqE(!kJwu|i*r1!kmy5S!*gaw zqqp3=T&I0V(M{x$P^W#LJX|{b4Y^2`)BYJ{5I>FnJ^P9D>9MHqhcc&+iE4_VOC^x=L%*^M7R%+d=7EewnZCn^%sr9T{_7zJk+4u&LHk*-c z`*H2|U)ApY``Wz=v6b3G=y6U;DkZY*GOhMJ@A6}2a+|RuYK+B5+OV@ro265a8xlH>427 z2Bc9!xgkX<#^0WWF>Z)CE@_mgOScJST)IQCgmjm{UG_2!1A;clEy8K}JlPSP;@>l# zWP%MoYs{EzUM3VDEXsjG2Aqgp;s%`W%Y78Vse~7 zLY^j+7&kf}&X;%w73BGm4Qr33Mu51THDv1THJ@6Urr}mttLz zjQ|1)GYY+-geW$^&24UQbDJPHM2jiol&2{=fuwSmYSFln`?zv~jA~ApP7!dnnQ&@N zr%l=Qeu;4X*j_nj`tY6E(~lj_xz?{7yYT&9_FMB$uPki(@%5F8ru#B|(?{RDwtZSV zum5=4SC-eFnuLn_CuX6%iV5dEAAV=P=aGH4q3mXoBmRj<1eAv>5ZP73?j=T*bN2zh z`MNy{F~vwrcnM8+DuV)iJYL& zdGgpzqEaY9UeN_a*A^qb+-ipO51c$(j&W`^-kBe9z2e-}J=H?KrN%$cx#8JUPx~|0L7L@bE?U4KNSikV8O3 IIIj%&e*zal4*&oF literal 0 HcmV?d00001 diff --git a/patchwork/__pycache__/packer.cpython-314.pyc b/patchwork/__pycache__/packer.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f4bd6d23c4006a1a989e7d7a9c5770a91be496b GIT binary patch literal 1539 zcmZux%}*Og6rb5$uh(|ge3+CVAbgmjxJ9IGXqq;qm$(%)O5<)@Q^ku{i@ku0?cL6f zMa{{F9!P~6dUK_!;Yj|0p8F@zSZb`noO1$|5Gl-JcD3qKo0i1yc8@%TZ*_ zsah*_>i7i=>3{(zm47@#d^W{F17%TOy7MzaMJ^jbU*mObU_&xwLoq^zdQ;}H!p9O< zWiqL*ZRy0W7TwCMAsAE3cHJR9lS@^~RN~MYscyYZYhKyOiO@6S<&sZx=MtZinQVeD2SDxLI|GnRfgQ>UyT{uF_4LIHqUQ!uqC5HeN%8 z4TsEnb#}$FE{fq;7U|(GBmDCx`O-iyQ5;`Cg{7&V0NeKkJNP&+q-R_U6)vt0Fw*!Z z3iUV7u7OM;ge3#z=%NW;$hnIWd$Oqn#~CPh7ja!VADO%G;>okfQ%p!zP*w{3^rA2R zM92$eaV8d=UUKtFfCGI`HQ}cPE%jyk0({By1Nj?4+<<%GtZLvp-}4r@1}$Lr2l|xr8a6Ln^$VU7CusaK$G6x?N_WVy)u&OxY@zR+v_BD;{xt zpM`kGuQ`0K*4tW^_5(1B5mj?lmn8gA>! MM)ai;ydc8Q|4!9U8UO$Q literal 0 HcmV?d00001 diff --git a/patchwork/__pycache__/util.cpython-314.pyc b/patchwork/__pycache__/util.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f01848c95b35569bef6afb42dd2f6a1ed32278f2 GIT binary patch literal 2727 zcmcIm%TrTV7(eIUn-?L3AczVf;bCk>MXg$2fl9ShhcToV#c_y9LvjPryyo1S`WUB^ z(Jp8g6{elhZYoQex^UU0o%#pFMhkPLtFG9=us~h)`%WGPDt5Z)nYrKj&g(nh_xm2_ z?ryNv5oix)RubQcg#3krN`o;rx+(+BlMs>VS#pMI)P=loR?r0Ai<&5lnk3iBQY(=S ztt8o)q?$pt%0?KQ%44H!hLHtFnn@PAiMM_U#e1o4jil2VEutkdY1OYAhcjoB`wv7Z z-ibkz+^o{W-^aP(_JVldZEKBtpZrm`T5va}Bc8-0n}kb>jasVy`$tHz&JV<3l`u0S1>ZcHla32l<0 zU|q_oO6;axRkSfxW0%VT!z}zT%rHz7u+$1&DK5CB+@qZE1uv|kTdj243XnV)ZSgur;+>z z4Tn%etRjA$hV}Jnh$(as>yRH9AMFB+^W;1k+yUs(5#e243RV-dqRn-ZH@L`9JK+e( zfW%=jL0Kb+oD+0obTX5OD!T2|uz&Ev$fw~`X9nbvIZ+pS_cJH#VUYPmMbCQN`BJ`F zd=pAqxt?YHdK+-zbR?zp`jVMwB&qiIRu}OG6-B&S0<{ay;a{D<4BU@CxbowbN6z_< zBZZD5zd2v8qLFvHcG7hP=>N8yx#3pU4P>2ZY$r~8fHdQh3rJb{=(Buf#(Q^RhJMo6|*Dm@wnaPN>+iB^}+&A z@}Gctg{91P`>O><^S!p2w)^sIQ_;~gWn8e@@Aggg{c!O6!}-&Twx;P1zq>R=7wk>b z^1X{Q7oR(apSC>t@~N}v94@v5Uf2Uu5*+!%+P1Kz_1mEp6KQB&HWRD8=0p@v6apjj zfw5Y-!0``+G}y|dzcQ%|9(=jYUG z6;tv~M;SNAHxS^lJaBKR2xh$eBwMN%vLi6f{elD{dC}VV+~IqqJRW?MDmr{c zYhOX?=el>P{OsZ1=F uRQlO5*oS^p@zk{d9Xy+BJ0M87jK2Z&Bm?{a literal 0 HcmV?d00001 diff --git a/patchwork/cli.py b/patchwork/cli.py new file mode 100644 index 0000000..033eeaf --- /dev/null +++ b/patchwork/cli.py @@ -0,0 +1,44 @@ +from __future__ import annotations +import argparse +import sys +from pathlib import Path +from .core import Obfuscator + +def _parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog='patchwork') + p.add_argument('input') + p.add_argument('-o', '--output') + p.add_argument('--seed', type=int, default=None) + p.add_argument('--layers', type=int, default=3) + p.add_argument('--stage2-layers', type=int, default=3, dest='stage2_layers') + p.add_argument('--keep', action='append', default=[], metavar='NAME') + p.add_argument('--no-rename', action='store_true') + p.add_argument('--no-encrypt-strings', action='store_true') + p.add_argument('--no-obfuscate-numbers', action='store_true') + p.add_argument('--no-opaque', action='store_true') + p.add_argument('--no-mba', action='store_true') + p.add_argument('--no-junk', action='store_true') + p.add_argument('--no-lazy', action='store_true') + p.add_argument('--no-anti-debug', action='store_true') + p.add_argument('--quiet', '-q', action='store_true') + return p + +def main(argv: list[str] | None=None) -> int: + args = _parser().parse_args(argv) + inp = Path(args.input) + if not inp.is_file(): + print(f'patchwork: input file not found: {inp}', file=sys.stderr) + return 2 + src = inp.read_text(encoding='utf-8') + obf = Obfuscator(seed=args.seed, rename=not args.no_rename, encrypt_strings=not args.no_encrypt_strings, obfuscate_numbers=not args.no_obfuscate_numbers, opaque_predicates=not args.no_opaque, mba=not args.no_mba, junk_branches=not args.no_junk, lazy_funcs=not args.no_lazy, anti_debug=not args.no_anti_debug, layers=args.layers, stage2_layers=args.stage2_layers, keep=set(args.keep)) + out_src = obf.obfuscate(src) + out_path = Path(args.output) if args.output else inp.with_name(inp.stem + '_obf.py') + out_path.write_text(out_src, encoding='utf-8') + if not args.quiet: + in_size = len(src) + out_size = len(out_src) + ratio = out_size / max(in_size, 1) + print(f'patchwork: {inp} -> {out_path}\n input: {in_size:,} bytes\n output: {out_size:,} bytes ({ratio:.1f}x)\n seed: {obf.seed}\n layers: {obf.layers}', file=sys.stderr) + return 0 +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/patchwork/core.py b/patchwork/core.py new file mode 100644 index 0000000..075f6b6 --- /dev/null +++ b/patchwork/core.py @@ -0,0 +1,79 @@ +from __future__ import annotations +import ast +from pathlib import Path +from .lazy import encrypt_user_functions +from .loader import build_loader +from .packer import pack +from .transforms import IdentifierRenamer, NumberObfuscator, OpaquePredicateInjector, StringEncryptor, build_decrypt_helper, collect_skip_names +from .transforms.junk import JunkBranchInjector +from .transforms.mba import MBATransformer +from .transforms.opaque import build_seed_stmt +from .util import make_rng +_SEED_NAME = '_pw_seed_value' +_DEC_S_NAME = '_pw_dec_str' +_DEC_B_NAME = '_pw_dec_bytes' +_RESOLVE_NAME = '__pw_resolve_lazy__' + +class Obfuscator: + + def __init__(self, *, seed: int | None=None, rename: bool=True, encrypt_strings: bool=True, obfuscate_numbers: bool=True, opaque_predicates: bool=True, mba: bool=True, junk_branches: bool=True, lazy_funcs: bool=True, anti_debug: bool=True, layers: int=3, stage2_layers: int=3, keep: set[str] | None=None): + self.rng, self.seed = make_rng(seed) + self.rename = rename + self.encrypt_strings = encrypt_strings + self.obfuscate_numbers = obfuscate_numbers + self.opaque_predicates = opaque_predicates + self.mba = mba + self.junk_branches = junk_branches + self.lazy_funcs = lazy_funcs + self.anti_debug = anti_debug + self.layers = max(1, int(layers)) + self.stage2_layers = max(1, int(stage2_layers)) + self.keep_extra = set(keep or ()) + + def obfuscate(self, source: str) -> str: + tree = ast.parse(source) + skip = collect_skip_names(tree) + seed_used = False + if self.opaque_predicates: + opi = OpaquePredicateInjector(self.rng, _SEED_NAME) + tree = opi.visit(tree) + seed_used = seed_used or opi.injected + if self.junk_branches: + jbi = JunkBranchInjector(self.rng, _SEED_NAME) + tree = jbi.visit(tree) + seed_used = seed_used or jbi.injected + if seed_used: + tree.body.insert(0, build_seed_stmt(_SEED_NAME)) + if self.mba: + tree = MBATransformer(self.rng).visit(tree) + if self.obfuscate_numbers: + tree = NumberObfuscator(self.rng).visit(tree) + if self.encrypt_strings: + enc = StringEncryptor(self.rng, _DEC_S_NAME, _DEC_B_NAME) + tree = enc.visit(tree) + tree.body[:0] = build_decrypt_helper(_DEC_S_NAME, _DEC_B_NAME) + if self.rename: + renamer = IdentifierRenamer(self.rng, keep=skip | self.keep_extra | {_RESOLVE_NAME}) + tree = renamer.visit(tree) + ast.fix_missing_locations(tree) + user_code = compile(tree, '', 'exec') + if self.lazy_funcs: + user_code, lazy_blobs = encrypt_user_functions(user_code, self.rng, _RESOLVE_NAME) + else: + lazy_blobs = [] + user_payload, user_keys = pack(user_code, self.rng, layers=self.layers) + return build_loader(user_payload, user_keys, lazy_blobs, resolver_name=_RESOLVE_NAME, rng=self.rng, anti_debug=self.anti_debug, stage2_layers=self.stage2_layers) + +def obfuscate(source: str, **kwargs) -> str: + return Obfuscator(**kwargs).obfuscate(source) + +def obfuscate_file(input_path: str | Path, output_path: str | Path | None=None, **kwargs) -> Path: + inp = Path(input_path) + src = inp.read_text(encoding='utf-8') + out = obfuscate(src, **kwargs) + if output_path is None: + outp = inp.with_name(inp.stem + '_obf.py') + else: + outp = Path(output_path) + outp.write_text(out, encoding='utf-8') + return outp diff --git a/patchwork/crypto.py b/patchwork/crypto.py new file mode 100644 index 0000000..7e3e523 --- /dev/null +++ b/patchwork/crypto.py @@ -0,0 +1,48 @@ +from __future__ import annotations +import hashlib +import random + +def keystream(key: bytes, length: int) -> bytes: + out = bytearray() + ctr = 0 + while len(out) < length: + out.extend(hashlib.sha256(key + ctr.to_bytes(8, 'big')).digest()) + ctr += 1 + return bytes(out[:length]) + +def stream_xor(data: bytes, key: bytes) -> bytes: + ks = keystream(key, len(data)) + return bytes((a ^ b for a, b in zip(data, ks))) + +def make_perm(key: bytes) -> bytes: + seed = int.from_bytes(hashlib.sha256(key).digest(), 'big') + rng = random.Random(seed) + perm = list(range(256)) + rng.shuffle(perm) + return bytes(perm) + +def invert_perm(perm: bytes) -> bytes: + inv = bytearray(256) + for i, p in enumerate(perm): + inv[p] = i + return bytes(inv) + +def perm_apply(data: bytes, key: bytes) -> bytes: + perm = make_perm(key) + return bytes((perm[b] for b in data)) + +def perm_invert(data: bytes, key: bytes) -> bytes: + inv = invert_perm(make_perm(key)) + return bytes((inv[b] for b in data)) + +def _rot_left(b: int, n: int) -> int: + n &= 7 + if not n: + return b + return (b << n | b >> 8 - n) & 255 + +def rot_apply(data: bytes, key: bytes) -> bytes: + return bytes((_rot_left(b, key[i % len(key)]) for i, b in enumerate(data))) + +def rot_invert(data: bytes, key: bytes) -> bytes: + return bytes((_rot_left(b, -key[i % len(key)] & 7) for i, b in enumerate(data))) diff --git a/patchwork/lazy.py b/patchwork/lazy.py new file mode 100644 index 0000000..e5b0e0e --- /dev/null +++ b/patchwork/lazy.py @@ -0,0 +1,88 @@ +from __future__ import annotations +import marshal +import random +import types +from .crypto import stream_xor +from .util import gen_bytes +LAZY_PREFIX = ' str: + if not code.co_flags & 1: + return 'class' + if code.co_name.startswith('<') and code.co_name.endswith('>'): + return 'special' + return 'function' + + +def _stub_source(kind: str, idx: int, resolver_name: str) -> str: + if kind == 'async_gen': + return ( + f'async def _stub(*_a, **_k):\n' + f' async for _v in {resolver_name}({idx}, _a, _k):\n' + f' yield _v\n' + ) + if kind == 'coroutine': + return ( + f'async def _stub(*_a, **_k):\n' + f' return await {resolver_name}({idx}, _a, _k)\n' + ) + if kind == 'generator': + return ( + f'def _stub(*_a, **_k):\n' + f' yield from {resolver_name}({idx}, _a, _k)\n' + ) + return ( + f'def _stub(*_a, **_k):\n' + f' return {resolver_name}({idx}, _a, _k)\n' + ) + + +def _kind_for(code: types.CodeType) -> str: + f = code.co_flags + if f & _CO_ASYNC_GENERATOR: + return 'async_gen' + if f & _CO_COROUTINE: + return 'coroutine' + if f & _CO_GENERATOR: + return 'generator' + return 'function' + + +def _make_stub_code(idx: int, original: types.CodeType, resolver_name: str) -> types.CodeType: + kind = _kind_for(original) + src = _stub_source(kind, idx, resolver_name) + mod = compile(src, '', 'exec') + inner = next((c for c in mod.co_consts if isinstance(c, types.CodeType))) + return inner.replace( + co_name=f'{LAZY_PREFIX}{idx:x}{LAZY_SUFFIX}', + co_freevars=original.co_freevars, + co_cellvars=original.co_cellvars, + ) + + +def encrypt_user_functions(top: types.CodeType, rng: random.Random, resolver_name: str, key_size: int=24) -> tuple[types.CodeType, list[tuple[bytes, bytes]]]: + blobs: list[tuple[bytes, bytes]] = [] + + def transform(code: types.CodeType, parent_kind: str) -> types.CodeType: + new_consts: list = [] + for c in code.co_consts: + if isinstance(c, types.CodeType): + ck = _classify_code(c) + if parent_kind in ('module', 'class') and ck == 'function': + idx = len(blobs) + key = gen_bytes(rng, key_size) + enc = stream_xor(marshal.dumps(c), key) + blobs.append((key, enc)) + new_consts.append(_make_stub_code(idx, c, resolver_name)) + else: + new_consts.append(transform(c, ck)) + else: + new_consts.append(c) + return code.replace(co_consts=tuple(new_consts)) + new_top = transform(top, 'module') + return (new_top, blobs) diff --git a/patchwork/loader.py b/patchwork/loader.py new file mode 100644 index 0000000..d468d98 --- /dev/null +++ b/patchwork/loader.py @@ -0,0 +1,143 @@ +from __future__ import annotations +import base64 +import marshal +import random +import zlib +from . import crypto +from .packer import pack +from .util import gen_bytes, gen_name +_MOD_KEYS = ('sys', 'os', 'marshal', 'zlib', 'base64', 'hashlib', 'rand_mod') +_FUNC_KEYS = ('kstream', 'xor_dec', 'perm_inv', 'perm_dec', 'rot_dec') +_VAR_KEYS = ('data', 'code_obj', 'ns', 'blobs', 'tmp', 'i', 'k', 'p', 'g') + +def _bytes_repr(b: bytes) -> str: + return f'bytes.fromhex({b.hex()!r})' + +def _chunked_bytes(b: bytes, rng: random.Random) -> str: + if len(b) < 64: + return _bytes_repr(b) + nchunks = rng.randint(4, 9) + chunk_size = (len(b) + nchunks - 1) // nchunks + chunks: list[bytes] = [] + pos = 0 + while pos < len(b): + chunks.append(b[pos:pos + chunk_size]) + pos += chunk_size + order = list(range(len(chunks))) + rng.shuffle(order) + shuffled = [chunks[i] for i in order] + inv = [0] * len(order) + for i, o in enumerate(order): + inv[o] = i + parts_src = ', '.join((_bytes_repr(c) for c in shuffled)) + perm_src = ', '.join((str(x) for x in inv)) + return f"b''.join((({parts_src},)[__pwi] for __pwi in ({perm_src},)))" + +def _junk(rng: random.Random) -> str: + target = gen_name(rng) + n = rng.randint(0, 1 << 16) + forms = [f'{target} = (lambda _x: _x ^ _x)({n})', f'{target} = sum(({n},))', f'{target} = ({n} & {n}) | 0', f'{target} = pow({n} & 0xFF, 1, 1) + 0', f'{target} = len(()) + {n} - {n}', f'{target} = ({n} * 0) ^ 0', f'{target} = (~{n} ^ ~{n}) & 0xFF'] + return rng.choice(forms) + +def _name_pool(rng: random.Random, keys: tuple[str, ...]) -> dict[str, str]: + used: set[str] = set() + out: dict[str, str] = {} + for k in keys: + n = gen_name(rng, used=used) + used.add(n) + out[k] = n + return out + +def _decrypt_call(N: dict[str, str], op: str, var: str, key: bytes) -> str: + kbytes = _bytes_repr(key) + if op == 'xor': + return f"{var} = {N['xor_dec']}({var}, {kbytes})" + if op == 'perm': + return f"{var} = {N['perm_dec']}({var}, {kbytes})" + if op == 'rot': + return f"{var} = {N['rot_dec']}({var}, {kbytes})" + raise ValueError(op) + +def _emit_helpers(N: dict[str, str]) -> list[str]: + return [f"def {N['kstream']}(k, n):\n r = bytearray()\n c = 0\n while len(r) < n:\n r += {N['hashlib']}.sha256(k + c.to_bytes(8, 'big')).digest()\n c += 1\n return bytes(r[:n])", f"def {N['xor_dec']}(d, k):\n s = {N['kstream']}(k, len(d))\n return bytes(a ^ b for a, b in zip(d, s))", f"def {N['perm_inv']}(k):\n g = {N['rand_mod']}.Random(int.from_bytes({N['hashlib']}.sha256(k).digest(), 'big'))\n p = list(range(256))\n g.shuffle(p)\n inv = bytearray(256)\n for i, v in enumerate(p):\n inv[v] = i\n return bytes(inv)", f"def {N['perm_dec']}(d, k):\n inv = {N['perm_inv']}(k)\n return bytes(inv[b] for b in d)", f"def {N['rot_dec']}(d, k):\n out = bytearray()\n L = len(k)\n for i, b in enumerate(d):\n n = (-k[i % L]) & 7\n if n:\n out.append(((b << n) | (b >> (8 - n))) & 0xFF)\n else:\n out.append(b)\n return bytes(out)"] + +def _emit_imports(N: dict[str, str], rng: random.Random) -> list[str]: + lines = [f"{N['sys']} = __import__('sys')", f"{N['os']} = __import__('os')", f"{N['marshal']} = __import__('marshal')", f"{N['zlib']} = __import__('zlib')", f"{N['base64']} = __import__('base64')", f"{N['hashlib']} = __import__('hashlib')", f"{N['rand_mod']} = __import__('random')"] + rng.shuffle(lines) + return lines + +def _emit_anti_debug(N: dict[str, str], rng: random.Random) -> list[str]: + checks = [f"({N['sys']}.gettrace() is None) or {N['os']}._exit(1)", f"({N['sys']}.getprofile() is None) or {N['os']}._exit(1)", f"all(_m not in {N['sys']}.modules for _m in ('pdb','bdb','pydevd','debugpy','pydevd_bundle','pydevd_bundle.pydevd_resolver','ptvsd','rpdb','wdb')) or {N['os']}._exit(1)", f"({N['os']}.environ.get('PYTHONBREAKPOINT', '') in ('', '0', 'pdb.set_trace')) or {N['os']}._exit(1)"] + rng.shuffle(checks) + return checks + +def _emit_audit_hook(N: dict[str, str]) -> list[str]: + return [f"def __pw_audit(event, args):\n if event == 'import':\n _n = args[0] if args else ''\n if _n in ('pdb','bdb','pydevd','debugpy','pydevd_bundle','ptvsd','rpdb','wdb'):\n {N['os']}._exit(1)\n elif event in ('sys.settrace','sys.setprofile'):\n {N['os']}._exit(1)", f"{N['sys']}.addaudithook(__pw_audit)"] + +def _emit_frame_check(N: dict[str, str]) -> list[str]: + return [f"_f = {N['sys']}._getframe(0)\nwhile _f is not None:\n _gn = _f.f_globals.get('__name__', '')\n if _gn.startswith(('pdb', 'bdb', 'pydevd', 'debugpy', '_pydev')):\n {N['os']}._exit(1)\n _f = _f.f_back"] + +def _build_stage2_source(user_payload: bytes, user_layer_keys: list[tuple[str, bytes]], lazy_blobs: list[tuple[bytes, bytes]], resolver_name: str, mod_names: dict[str, str], rng: random.Random, *, anti_debug: bool=True) -> str: + N = mod_names + F = _name_pool(rng, _FUNC_KEYS) + V = _name_pool(rng, _VAR_KEYS) + parts: list[str] = [] + parts.append("_pw_types = __import__('types')") + parts.extend(_emit_helpers({**N, **F})) + if anti_debug: + parts.extend(_emit_anti_debug({**N, **F}, rng)) + parts.append(_junk(rng)) + if lazy_blobs: + blob_lines = [] + for k, e in lazy_blobs: + blob_lines.append(f'({_chunked_bytes(k, rng)}, {_chunked_bytes(e, rng)})') + parts.append(f"__pw_blobs = [{', '.join(blob_lines)}]") + else: + parts.append('__pw_blobs = []') + parts.append(f"_pw_code_cache = {{}}\n_pw_done = set()\ndef _pw_find_func(g, target_code):\n seen = set()\n stack = list(g.values())\n while stack:\n v = stack.pop()\n oid = id(v)\n if oid in seen:\n continue\n seen.add(oid)\n if isinstance(v, _pw_types.FunctionType) and v.__code__ is target_code:\n return v\n if isinstance(v, (classmethod, staticmethod)):\n inner = v.__func__\n if isinstance(inner, _pw_types.FunctionType) and inner.__code__ is target_code:\n return inner\n stack.append(inner)\n continue\n if isinstance(v, property):\n for fn in (v.fget, v.fset, v.fdel):\n if fn is not None:\n stack.append(fn)\n continue\n if isinstance(v, type):\n for vv in v.__dict__.values():\n stack.append(vv)\n return None\ndef {resolver_name}(idx, args, kw):\n fr = {N['sys']}._getframe(1)\n g = fr.f_globals\n stub_code = fr.f_code\n if idx not in _pw_code_cache:\n key, enc = __pw_blobs[idx]\n _pw_code_cache[idx] = {N['marshal']}.loads({F['xor_dec']}(enc, key))\n real = _pw_code_cache[idx]\n target = _pw_find_func(g, stub_code)\n if target is not None and id(target) not in _pw_done:\n target.__code__ = real\n _pw_done.add(id(target))\n return target(*args, **kw)\n fn = _pw_types.FunctionType(real, g)\n return fn(*args, **kw)") + user_encoded = base64.b85encode(user_payload) + parts.append(f"{V['data']} = {N['base64']}.b85decode({_chunked_bytes(user_encoded, rng)})") + for op, key in reversed(user_layer_keys): + parts.append(_decrypt_call({**N, **F}, op, V['data'], key)) + parts.append(f"{V['data']} = {N['zlib']}.decompress({V['data']})") + parts.append(f"{V['code_obj']} = {N['marshal']}.loads({V['data']})") + parts.append(_junk(rng)) + if anti_debug: + parts.append(f"({N['sys']}.gettrace() is None) or {N['os']}._exit(1)") + parts.append(f"{V['ns']} = {{'__name__': '__main__', '__builtins__': __builtins__, '__file__': __file__, '__package__': None, '__doc__': None, '{resolver_name}': {resolver_name}}}") + parts.append(f"exec({V['code_obj']}, {V['ns']})") + return '\n'.join(parts) + '\n' + +def build_loader(user_payload: bytes, user_layer_keys: list[tuple[str, bytes]], lazy_blobs: list[tuple[bytes, bytes]], resolver_name: str, rng: random.Random, *, anti_debug: bool=True, stage2_layers: int=3, junk_count: int=8) -> str: + N = _name_pool(rng, _MOD_KEYS) + F = _name_pool(rng, _FUNC_KEYS) + V = _name_pool(rng, _VAR_KEYS) + stage2_src = _build_stage2_source(user_payload, user_layer_keys, lazy_blobs, resolver_name=resolver_name, mod_names=N, rng=rng, anti_debug=anti_debug) + stage2_code = compile(stage2_src, '', 'exec') + stage2_payload, stage2_keys = pack(stage2_code, rng, layers=stage2_layers) + parts: list[str] = [] + parts.extend(_emit_imports(N, rng)) + parts.append(_junk(rng)) + if anti_debug: + parts.extend(_emit_anti_debug({**N, **F}, rng)) + parts.extend(_emit_audit_hook(N)) + parts.extend(_emit_frame_check(N)) + parts.append(_junk(rng)) + parts.extend(_emit_helpers({**N, **F})) + parts.append(_junk(rng)) + stage2_encoded = base64.b85encode(stage2_payload) + parts.append(f"{V['data']} = {N['base64']}.b85decode({_chunked_bytes(stage2_encoded, rng)})") + for op, key in reversed(stage2_keys): + parts.append(_decrypt_call({**N, **F}, op, V['data'], key)) + parts.append(f"{V['data']} = {N['zlib']}.decompress({V['data']})") + if anti_debug: + parts.append(f"({N['sys']}.gettrace() is None) or {N['os']}._exit(1)") + parts.append(f"{V['code_obj']} = {N['marshal']}.loads({V['data']})") + parts.append(_junk(rng)) + parts.append(f"{V['ns']} = {{'__builtins__': __builtins__, '__file__': __file__, '__name__': __name__, '{N['sys']}': {N['sys']}, '{N['os']}': {N['os']}, '{N['marshal']}': {N['marshal']}, '{N['zlib']}': {N['zlib']}, '{N['base64']}': {N['base64']}, '{N['hashlib']}': {N['hashlib']}, '{N['rand_mod']}': {N['rand_mod']}}}") + parts.append(f"exec({V['code_obj']}, {V['ns']})") + body = list(parts) + for _ in range(junk_count): + idx = rng.randint(0, len(body)) + body.insert(idx, _junk(rng)) + return '\n'.join(body) + '\n' diff --git a/patchwork/packer.py b/patchwork/packer.py new file mode 100644 index 0000000..af02d5c --- /dev/null +++ b/patchwork/packer.py @@ -0,0 +1,23 @@ +from __future__ import annotations +import marshal +import random +import zlib +from . import crypto +from .util import gen_bytes +CIPHER_OPS = ('xor', 'perm', 'rot') + +def pack(code_obj, rng: random.Random, layers: int=3) -> tuple[bytes, list[tuple[str, bytes]]]: + data = marshal.dumps(code_obj) + data = zlib.compress(data, 9) + layer_keys: list[tuple[str, bytes]] = [] + for _ in range(layers): + op = rng.choice(CIPHER_OPS) + key = gen_bytes(rng, rng.randint(20, 40)) + if op == 'xor': + data = crypto.stream_xor(data, key) + elif op == 'perm': + data = crypto.perm_apply(data, key) + elif op == 'rot': + data = crypto.rot_apply(data, key) + layer_keys.append((op, key)) + return (data, layer_keys) diff --git a/patchwork/transforms/__init__.py b/patchwork/transforms/__init__.py new file mode 100644 index 0000000..44aaf91 --- /dev/null +++ b/patchwork/transforms/__init__.py @@ -0,0 +1,5 @@ +from .identifiers import IdentifierRenamer, collect_skip_names +from .strings import StringEncryptor, build_decrypt_helper +from .numbers import NumberObfuscator +from .opaque import OpaquePredicateInjector +__all__ = ['IdentifierRenamer', 'collect_skip_names', 'StringEncryptor', 'build_decrypt_helper', 'NumberObfuscator', 'OpaquePredicateInjector'] diff --git a/patchwork/transforms/__pycache__/__init__.cpython-314.pyc b/patchwork/transforms/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9959fc608ba9c2381730cbedfb800032d3954c4c GIT binary patch literal 498 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 literal 0 HcmV?d00001 diff --git a/patchwork/transforms/__pycache__/junk.cpython-314.pyc b/patchwork/transforms/__pycache__/junk.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..730a425b85d42f785d368f8a87ba83967ea74978 GIT binary patch literal 6499 zcmc&&Z%kX)6~E7Z&ln6m6CjY3hDZ2QoDlxRB%xiCG^HUB8sfeXXfw)-u}yF>wsYSj z38bWam?r3=kxG^7PLt}Ym708GTcu^6R&D#RR+DN9tG(AusqO&OPUM?!9}<%Ss5Ociy-c>~#?G4{Vr8Fe=>Ud8jOsaUydAq@UBc zdaPRpESiOtR?R9GX}oOL1ld|cWWI)koSLngG!R*Uw(Vt$r`%+kExWBoU9-!UM&c^| z3Y#uYcX-3$i00LTk+3@CUYQL~w@2snQbkG2 z5>OOc2E2>lukM6wkvt$A*L)ANrG>j##4A~A6GjOW8!MPHVz)p8#rBvAVZ%l?4#Ib% zz>q+A%U4Jfg#R-jUNMRf{|f;ZM{Ur_1Z|Pp9OWyAmk-A( zVkg!eO$ok<;uiYgpGoQc<2FOkwMbqj8YdI5i4w2}r_905ThJ0h9N0$IE`*l_&1TpI zvy&pZsD^+I*nz&7^%Tn`Put}q4%`XV1sNKKO=!A@S?ttZ zELMH2_^6r{iP3Xx(AbZ(kHSPkaQ8vx;!PPUi)7Rp#$J|2rEtb(Mmxi4J0U~{YYM0L zk$_i2i_gq?CY<3eb7)h2LoS=4ob5nFYa!F^L8ktIU$+^$&`XqzUR;9CtAWsD-Xw}1 zpd#}YOokcS6(txBYKo#iR!~>QAk$M5%mfRq+B+`LtuI?FiFwGzAeJu zV`qlHYxC)2Kv@O4&afRdBrfdvBV2b{yhd`1 z8@k2m6`)D<2`IFPG7<65-Lg;#cv0k|;ulPUuIb37|%Hk+BMsVgv#WggbN(NDuQI0jL6=7|!yNr#i0#n%(Ixn1Psy2KAMsW6{=76!#vV7jMmWzdvz{$Y=}o;w`NDLbhU^@(Hri{>s)rB^ zke;JAfN`tNBP0wBySC|8a4+2%)B;rTO$9>`>Hz9gy#zoZtZEQl1G*y@8=S|~xv(#{ z$KD(Asw&{2Zc7`xz${xYx)6XvoJJY;!-JcuvO}UG1D6KUrC1%Ns^3ci!rnygl)k@+!yr|$QH?+>e^(r z>$7Ut&8E+*JC=^$5qHFz-*5b&dAjg2^KZfLQQ_;6C(^O?9ORavt%m~x0KJtl$P_Itn%%NTD`c?uy~cC-MZxh4DrL$^rZq2#KCd_{&nur6#;aEh840^t=c#58GXbaLJVt*~)T$zYw*aY6A+6 zQ2>l)C+FuOR)oBj7;h9qw|6(k|NwU#N$FR0tARnQn`EX;7Qf`N@#x*16VsUfLtI&R0r%r7o9|HB0-Y z!-u8Lc`FP-wojTbH%HvYNTz*&wRJvl$s2-OFFymp48xrkfV&&|rf!SSKuCp7dJ39! z2>bBk2V5nT-Dz-7benG~67&U_6YB!35rPq$F0eZ^MW2Jqy%EwBw0PYzFzG5{{;hMW z&QAx!e%YrQ}pIYJ}(A?*MCwnRrFq8h6Jp#(g&{Z@zG|_vW=k&5`B)FVl0kj;pI(tBtE?Zdcs4-aeA3Il0t- zN8GkD_xfC{_NHw^>`02;Yhw2je`nwR)$ZT+{HiDE?p<^Dest`ki|g)zjeRHA#lf|G zCvS2_!28_U2(2YPAr2y+ORrywb+7K<5DzBo2mk%Gl{la0RNRq2wAc1Z{6iP~{~Ds` zATan|M0y&CGKjG!R_YyHjX zKh6Gm_|q3Ze({g5ZJbjQ=l#j^Q)}m^*3Si(rq|0~S+bf=TKThUNpbg@xI5nUSFt%^ zZ)T)0yqnA z!afD>6v2sEz;xh+XpTCaK+q;cu$!~#aK)~mh+sYSZ#UvSNLqzPIX$sVcmN@rJRXJ^-wZkIWs z1v>lE$u<8YV}d$6(+Uas9yuZ@(=K3$WG)GwoS`iR!x9Tlt(|R>dw-kM<8ry?TXL53 zsgS&{=qKi2w_x>35Mnq?%@frSA(#~b);|A4`cv0%MJ`wcTMTH?x8O2`$IrNm(E=xD z;XNN8*%5OMr#+0d3z$t|hKCbf9D)cP*s$$F>Bi0-NHRzp)+UT4I&S9c^nn+WtkL!HnrYEinQV literal 0 HcmV?d00001 diff --git a/patchwork/transforms/__pycache__/mba.cpython-314.pyc b/patchwork/transforms/__pycache__/mba.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20709b34ef0e122e705386683529d27b70223e5e GIT binary patch literal 13220 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 M)c-e!DMsP{0ke3__y7O^ literal 0 HcmV?d00001 diff --git a/patchwork/transforms/__pycache__/numbers.cpython-314.pyc b/patchwork/transforms/__pycache__/numbers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dfe34406faa5b0caa45149ae2ee007b20c7d5b9f GIT binary patch literal 7004 zcmcgxT~J%c72f+N2_!6xz?gpl32+?;3?`Uh;+WV@f*oUggK_E-G#4R2V5Im$I7(RC=Z#+Z%Q5-KeRJVo-%!sEvIH~+^0TuI@7^*s8heV=j_!VlHAxy znw`<^*}Hr9?(X^a?01fy+g4FdAicMC&;M~5A%DY4CP}8i-G2jw6*5f}Zj`*v>0ArS z{3x&UtQ2&ihO`nzs3nT{Ha~9543I1P1yj}~g>NUa^mnYvoMBgk!H}-%{!maG^XB>! zl74}^|66#vLf$4irx5s{qzg)&!ojEbS`w%XaJp!I7;7-R1T7XOTNEqEHYQt@GLXxe zY*XwYJD6OiRDisV$>oX@FvyAgqu{GR+VEh>%%$)<{lr3!K6!ydo%KkGVNK zbTb4RoL{b>YH&8R*sZX_5W_SyV@Pv>kg8wD{ju^w7}MDa}Wt@YWJa)c+)yXncy0sUUt73gcWkSGKf!EI_(Z- zc8(~dom}U}WS%0bC_t3Rf+1?Yz+CQ$h68tWbkGVY4V%Z~5BhbOc3p{Sn@w!W4(Po? z)?GW^v8`9{{COFzga_Y7aTJF (OFx9m_gz55WrT33U*VWB=fLW2*j_;lpW32KL) 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 Ho6!FNu|T7W literal 0 HcmV?d00001 diff --git a/patchwork/transforms/identifiers.py b/patchwork/transforms/identifiers.py new file mode 100644 index 0000000..f0b932e --- /dev/null +++ b/patchwork/transforms/identifiers.py @@ -0,0 +1,167 @@ +from __future__ import annotations +import ast +import builtins +import keyword +import random +from ..util import gen_name +_DUNDERS = {'__name__', '__main__', '__file__', '__doc__', '__builtins__', '__package__', '__path__', '__loader__', '__spec__', '__cached__', '__all__', '__init__', '__new__', '__del__', '__init_subclass__', '__subclasshook__', '__set_name__', '__class_getitem__', '__enter__', '__exit__', '__aenter__', '__aexit__', '__await__', '__aiter__', '__anext__', '__call__', '__repr__', '__str__', '__bytes__', '__format__', '__hash__', '__bool__', '__len__', '__length_hint__', '__sizeof__', '__dir__', '__eq__', '__ne__', '__lt__', '__le__', '__gt__', '__ge__', '__add__', '__sub__', '__mul__', '__truediv__', '__floordiv__', '__mod__', '__divmod__', '__pow__', '__lshift__', '__rshift__', '__and__', '__or__', '__xor__', '__radd__', '__rsub__', '__rmul__', '__rtruediv__', '__rfloordiv__', '__rmod__', '__rdivmod__', '__rpow__', '__rlshift__', '__rrshift__', '__rand__', '__ror__', '__rxor__', '__iadd__', '__isub__', '__imul__', '__itruediv__', '__ifloordiv__', '__imod__', '__ipow__', '__ilshift__', '__irshift__', '__iand__', '__ior__', '__ixor__', '__matmul__', '__rmatmul__', '__imatmul__', '__neg__', '__pos__', '__abs__', '__invert__', '__int__', '__float__', '__complex__', '__round__', '__index__', '__trunc__', '__floor__', '__ceil__', '__getattr__', '__setattr__', '__delattr__', '__getattribute__', '__getitem__', '__setitem__', '__delitem__', '__missing__', '__iter__', '__next__', '__reversed__', '__contains__', '__copy__', '__deepcopy__', '__reduce__', '__reduce_ex__', '__getstate__', '__setstate__', '__getnewargs__', '__getnewargs_ex__', '__class__', '__instancecheck__', '__subclasscheck__', '__slots__', '__dict__', '__weakref__', '__module__', '__qualname__', '__defaults__', '__kwdefaults__', '__annotations__', '__wrapped__', '__signature__', '__match_args__', '__post_init__', '__fspath__', '__index__', '__buffer__', '__release_buffer__'} +RESERVED: set[str] = set(dir(builtins)) | set(keyword.kwlist) | _DUNDERS | {'self', 'cls', 'metaclass'} + +def collect_skip_names(tree: ast.AST) -> set[str]: + skip: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == '__all__': + if isinstance(node.value, (ast.List, ast.Tuple, ast.Set)): + for elt in node.value.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + skip.add(elt.value) + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and (node.func.id in ('getattr', 'setattr', 'hasattr', 'delattr')): + if len(node.args) >= 2: + arg = node.args[1] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + skip.add(arg.value) + elif isinstance(node, ast.ClassDef): + for stmt in node.body: + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)): + skip.add(stmt.name) + elif isinstance(stmt, ast.Assign): + for tgt in stmt.targets: + if isinstance(tgt, ast.Name): + skip.add(tgt.id) + elif isinstance(tgt, (ast.Tuple, ast.List)): + for elt in tgt.elts: + if isinstance(elt, ast.Name): + skip.add(elt.id) + elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + skip.add(stmt.target.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + args = node.args + for a in args.args + args.posonlyargs + args.kwonlyargs: + skip.add(a.arg) + if args.vararg: + skip.add(args.vararg.arg) + if args.kwarg: + skip.add(args.kwarg.arg) + return skip + +class IdentifierRenamer(ast.NodeTransformer): + + def __init__(self, rng: random.Random, keep: set[str] | None=None): + self.rng = rng + self.mapping: dict[str, str] = {} + self.reserved = RESERVED | (keep or set()) + self._used_outputs: set[str] = set() + + def _new(self) -> str: + n = gen_name(self.rng, used=self._used_outputs) + self._used_outputs.add(n) + return n + + def _rename(self, name: str) -> str: + if not name or name in self.reserved: + return name + if name.startswith('__') and name.endswith('__') and (len(name) >= 4): + return name + if name not in self.mapping: + self.mapping[name] = self._new() + return self.mapping[name] + + def visit_Name(self, node: ast.Name) -> ast.AST: + node.id = self._rename(node.id) + return node + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST: + node.name = self._rename(node.name) + node.decorator_list = [self.visit(d) for d in node.decorator_list] + if node.returns is not None: + node.returns = self.visit(node.returns) + node.args = self.visit(node.args) + node.body = [self.visit(stmt) for stmt in node.body] + return node + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST: + node.name = self._rename(node.name) + node.bases = [self.visit(b) for b in node.bases] + for kw in node.keywords: + kw.value = self.visit(kw.value) + node.decorator_list = [self.visit(d) for d in node.decorator_list] + node.body = [self.visit(stmt) for stmt in node.body] + return node + + def visit_Lambda(self, node: ast.Lambda) -> ast.AST: + node.args = self.visit(node.args) + node.body = self.visit(node.body) + return node + + def visit_arg(self, node: ast.arg) -> ast.AST: + node.arg = self._rename(node.arg) + if node.annotation is not None: + node.annotation = self.visit(node.annotation) + return node + + def visit_arguments(self, node: ast.arguments) -> ast.AST: + node.args = [self.visit(a) for a in node.args] + node.posonlyargs = [self.visit(a) for a in node.posonlyargs] + node.kwonlyargs = [self.visit(a) for a in node.kwonlyargs] + if node.vararg is not None: + node.vararg = self.visit(node.vararg) + if node.kwarg is not None: + node.kwarg = self.visit(node.kwarg) + node.defaults = [self.visit(d) for d in node.defaults] + node.kw_defaults = [self.visit(d) if d is not None else None for d in node.kw_defaults] + return node + + def visit_Attribute(self, node: ast.Attribute) -> ast.AST: + node.value = self.visit(node.value) + return node + + def visit_keyword(self, node: ast.keyword) -> ast.AST: + if node.value is not None: + node.value = self.visit(node.value) + return node + + def visit_Global(self, node: ast.Global) -> ast.AST: + node.names = [self._rename(n) for n in node.names] + return node + + def visit_Nonlocal(self, node: ast.Nonlocal) -> ast.AST: + node.names = [self._rename(n) for n in node.names] + return node + + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> ast.AST: + if node.type is not None: + node.type = self.visit(node.type) + if node.name: + node.name = self._rename(node.name) + node.body = [self.visit(s) for s in node.body] + return node + + def visit_Import(self, node: ast.Import) -> ast.AST: + for alias in node.names: + if '.' in alias.name: + if alias.asname: + new = self._rename(alias.asname) + alias.asname = new + continue + if alias.asname: + alias.asname = self._rename(alias.asname) + else: + if alias.name not in self.mapping: + self.mapping[alias.name] = self._new() + alias.asname = self.mapping[alias.name] + return node + + def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.AST: + for alias in node.names: + if alias.name == '*': + continue + if alias.asname: + alias.asname = self._rename(alias.asname) + else: + if alias.name not in self.mapping: + self.mapping[alias.name] = self._new() + alias.asname = self.mapping[alias.name] + return node diff --git a/patchwork/transforms/junk.py b/patchwork/transforms/junk.py new file mode 100644 index 0000000..2b8407c --- /dev/null +++ b/patchwork/transforms/junk.py @@ -0,0 +1,64 @@ +from __future__ import annotations +import ast +import random +_JUNK_NAMES = ('_v', '_t', '_acc', '_state', '_buf', '_h', '_z', '_w', '_q', '_n') +_JUNK_STMT_TEMPLATES = ('{v} = ({a} ^ {b}) + ({c} & {d})', '{v} = ((({a} * 0x01000193) ^ {b}) & 0xFFFFFFFF)', '{v} = sum(((({a} >> _k) & 0xFF) for _k in range(0, 32, 8)))', '{v} = ({a} | {b}) - (({c} & {d}) >> 1)', '{v} = pow({a} | 1, 1, ({b} | 0x10001))', '{v} = (({a} + {b}) ^ ({c} - {d})) & 0x7FFFFFFF', '{v} = bytes(((_i * {a}) ^ {b}) & 0xFF for _i in range(8))', '{v} = (~({a}) | ({b} & {c})) & 0xFFFFFF') + +def _rint(rng: random.Random) -> int: + return rng.randrange(1, 1 << 30) + +class JunkBranchInjector(ast.NodeTransformer): + + def __init__(self, rng: random.Random, seed_name: str, prob: float=0.4, max_depth: int=0): + self.rng = rng + self.seed_name = seed_name + self.prob = prob + self.injected = False + self._depth = 0 + self._max_depth = max_depth + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST: + return self._inject_into_body(node) + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Module(self, node: ast.Module) -> ast.AST: + return self._inject_into_body(node) + + def _inject_into_body(self, node): + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)): + self.visit(child) + if not getattr(node, 'body', None): + return node + if self.rng.random() > self.prob: + return node + block = self._build_block() + idx = self.rng.randint(0, len(node.body)) + node.body.insert(idx, block) + self.injected = True + return node + + def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST: + for child in node.body: + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.visit(child) + return node + + def _build_block(self) -> ast.stmt: + s = self.seed_name + kind = self.rng.choice(('parity', 'flt3', 'flt5')) + if kind == 'parity': + cond_src = f'({s} * ({s} + 1) + 1) % 2 == 0' + elif kind == 'flt3': + cond_src = f'({s} * {s} * {s} - {s} + 1) % 3 == 0' + else: + cond_src = f'({s} * {s} * {s} * {s} * {s} - {s} + 1) % 5 == 0' + cond = ast.parse(cond_src, mode='eval').body + body = [self._random_stmt() for _ in range(self.rng.randint(2, 4))] + return ast.If(test=cond, body=body, orelse=[]) + + def _random_stmt(self) -> ast.stmt: + v = self.rng.choice(_JUNK_NAMES) + tmpl = self.rng.choice(_JUNK_STMT_TEMPLATES) + src = tmpl.format(v=v, a=_rint(self.rng), b=_rint(self.rng), c=_rint(self.rng), d=_rint(self.rng)) + return ast.parse(src).body[0] diff --git a/patchwork/transforms/mba.py b/patchwork/transforms/mba.py new file mode 100644 index 0000000..af7c507 --- /dev/null +++ b/patchwork/transforms/mba.py @@ -0,0 +1,93 @@ +from __future__ import annotations +import ast +import copy +import random + +def _is_pure(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return True + if isinstance(node, ast.Constant): + return isinstance(node.value, (int, bool)) + if isinstance(node, ast.Attribute): + return _is_pure(node.value) + if isinstance(node, ast.Subscript): + return _is_pure(node.value) and _is_pure(node.slice) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd, ast.Invert)): + return _is_pure(node.operand) + return False + +def _dup(node: ast.AST) -> ast.AST: + return copy.deepcopy(node) + +class MBATransformer(ast.NodeTransformer): + + def __init__(self, rng: random.Random, prob: float=0.7): + self.rng = rng + self.prob = prob + + def visit_BinOp(self, node: ast.BinOp) -> ast.AST: + self.generic_visit(node) + if not (_is_pure(node.left) and _is_pure(node.right)): + return node + if self.rng.random() > self.prob: + return node + a, b = (node.left, node.right) + op = node.op + if isinstance(op, ast.BitXor): + return self._xor(a, b) + if isinstance(op, ast.BitOr): + return self._or(a, b) + if isinstance(op, ast.BitAnd): + return self._and(a, b) + if isinstance(op, ast.LShift) and isinstance(b, ast.Constant) and isinstance(b.value, int): + return self._lshift(a, b.value) + if isinstance(op, ast.RShift) and isinstance(b, ast.Constant) and isinstance(b.value, int): + return self._rshift(a, b.value) + return node + + def visit_UnaryOp(self, node: ast.UnaryOp) -> ast.AST: + self.generic_visit(node) + if not _is_pure(node.operand): + return node + if self.rng.random() > self.prob: + return node + if isinstance(node.op, ast.Invert): + return self._invert(node.operand) + return node + + def _xor(self, a: ast.AST, b: ast.AST) -> ast.AST: + choice = self.rng.choice(('subor', 'ornotand', 'addsub')) + if choice == 'subor': + return ast.BinOp(left=ast.BinOp(left=_dup(a), op=ast.BitOr(), right=_dup(b)), op=ast.Sub(), right=ast.BinOp(left=_dup(a), op=ast.BitAnd(), right=_dup(b))) + if choice == 'ornotand': + return ast.BinOp(left=ast.BinOp(left=_dup(a), op=ast.BitOr(), right=_dup(b)), op=ast.BitAnd(), right=ast.UnaryOp(op=ast.Invert(), operand=ast.BinOp(left=_dup(a), op=ast.BitAnd(), right=_dup(b)))) + return ast.BinOp(left=ast.BinOp(left=_dup(a), op=ast.Add(), right=_dup(b)), op=ast.Sub(), right=ast.BinOp(left=ast.Constant(value=2), op=ast.Mult(), right=ast.BinOp(left=_dup(a), op=ast.BitAnd(), right=_dup(b)))) + + def _or(self, a: ast.AST, b: ast.AST) -> ast.AST: + if self.rng.random() < 0.5: + return ast.BinOp(left=ast.BinOp(left=_dup(a), op=ast.Add(), right=_dup(b)), op=ast.Sub(), right=ast.BinOp(left=_dup(a), op=ast.BitAnd(), right=_dup(b))) + return ast.BinOp(left=ast.BinOp(left=_dup(a), op=ast.BitXor(), right=_dup(b)), op=ast.BitXor(), right=ast.BinOp(left=_dup(a), op=ast.BitAnd(), right=_dup(b))) + + def _and(self, a: ast.AST, b: ast.AST) -> ast.AST: + if self.rng.random() < 0.5: + return ast.BinOp(left=ast.BinOp(left=_dup(a), op=ast.Add(), right=_dup(b)), op=ast.Sub(), right=ast.BinOp(left=_dup(a), op=ast.BitOr(), right=_dup(b))) + return ast.UnaryOp(op=ast.Invert(), operand=ast.BinOp(left=ast.UnaryOp(op=ast.Invert(), operand=_dup(a)), op=ast.BitOr(), right=ast.UnaryOp(op=ast.Invert(), operand=_dup(b)))) + + def _invert(self, a: ast.AST) -> ast.AST: + return ast.BinOp(left=ast.UnaryOp(op=ast.USub(), operand=_dup(a)), op=ast.Sub(), right=ast.Constant(value=1)) + + def _lshift(self, a: ast.AST, n: int) -> ast.AST: + if n < 0 or n > 60: + return ast.BinOp(left=_dup(a), op=ast.LShift(), right=ast.Constant(value=n)) + return ast.BinOp(left=_dup(a), op=ast.Mult(), right=ast.Constant(value=1 << n)) + + def _rshift(self, a: ast.AST, n: int) -> ast.AST: + if n < 0 or n > 60: + return ast.BinOp(left=_dup(a), op=ast.RShift(), right=ast.Constant(value=n)) + return ast.BinOp(left=_dup(a), op=ast.FloorDiv(), right=ast.Constant(value=1 << n)) + + def visit_match_case(self, node: ast.match_case) -> ast.AST: + if node.guard is not None: + node.guard = self.visit(node.guard) + node.body = [self.visit(stmt) for stmt in node.body] + return node diff --git a/patchwork/transforms/numbers.py b/patchwork/transforms/numbers.py new file mode 100644 index 0000000..4457769 --- /dev/null +++ b/patchwork/transforms/numbers.py @@ -0,0 +1,70 @@ +from __future__ import annotations +import ast +import random + +class NumberObfuscator(ast.NodeTransformer): + + def __init__(self, rng: random.Random, prob: float=0.85): + self.rng = rng + self.prob = prob + + def visit_Constant(self, node: ast.Constant) -> ast.AST: + v = node.value + if isinstance(v, bool): + return node + if isinstance(v, int): + if self.rng.random() <= self.prob: + return self._obfuscate(v) + return node + + def visit_match_case(self, node: ast.match_case) -> ast.AST: + if node.guard is not None: + node.guard = self.visit(node.guard) + node.body = [self.visit(stmt) for stmt in node.body] + return node + + def _obfuscate(self, n: int) -> ast.AST: + choices = ['xor', 'sum', 'shift', 'frombytes', 'affine', 'split'] + kind = self.rng.choice(choices) + try: + return getattr(self, f'_t_{kind}')(n) + except Exception: + return ast.Constant(value=n) + + def _t_xor(self, n: int) -> ast.AST: + k = self.rng.randrange(1, 1 << 32) + return self._parse_expr(f'(({n ^ k}) ^ {k})') + + def _t_sum(self, n: int) -> ast.AST: + a = self.rng.randint(-(1 << 16), 1 << 16) + return self._parse_expr(f'(({n - a}) + ({a}))') + + def _t_shift(self, n: int) -> ast.AST: + if abs(n) >= 1 << 60: + return self._t_xor(n) + s = self.rng.randint(1, 8) + return self._parse_expr(f'(({n} << {s}) >> {s})') + + def _t_frombytes(self, n: int) -> ast.AST: + signed = n < 0 + bit_len = max(1, n.bit_length() + (1 if signed else 0)) + byte_len = (bit_len + 7) // 8 + try: + data = n.to_bytes(byte_len, 'big', signed=signed) + except OverflowError: + return self._t_xor(n) + signed_arg = 'True' if signed else 'False' + return self._parse_expr(f"int.from_bytes({data!r}, 'big', signed={signed_arg})") + + def _t_affine(self, n: int) -> ast.AST: + a = self.rng.randint(2, 64) + b = self.rng.randint(-(1 << 12), 1 << 12) + return self._parse_expr(f'((({n * a + b}) - ({b})) // {a})') + + def _t_split(self, n: int) -> ast.AST: + p = self.rng.randint(-(1 << 16), 1 << 16) + q = n - p + return self._parse_expr(f'({p} + ({q}))') + + def _parse_expr(self, src: str) -> ast.AST: + return ast.parse(src, mode='eval').body diff --git a/patchwork/transforms/opaque.py b/patchwork/transforms/opaque.py new file mode 100644 index 0000000..02dde8a --- /dev/null +++ b/patchwork/transforms/opaque.py @@ -0,0 +1,42 @@ +from __future__ import annotations +import ast +import random + +class OpaquePredicateInjector(ast.NodeTransformer): + + def __init__(self, rng: random.Random, seed_name: str, prob: float=0.5): + self.rng = rng + self.seed_name = seed_name + self.prob = prob + self.injected = False + + def visit_If(self, node: ast.If) -> ast.AST: + self.generic_visit(node) + if self.rng.random() < self.prob: + node.test = self._wrap(node.test) + self.injected = True + return node + + def visit_While(self, node: ast.While) -> ast.AST: + self.generic_visit(node) + if self.rng.random() < self.prob * 0.4: + node.test = self._wrap(node.test) + self.injected = True + return node + + def _wrap(self, test: ast.expr) -> ast.expr: + return ast.BoolOp(op=ast.And(), values=[test, self._tautology()]) + + def _tautology(self) -> ast.expr: + s = self.seed_name + kind = self.rng.choice(('parity', 'flt3', 'flt5')) + if kind == 'parity': + src = f'({s} * ({s} + 1)) % 2 == 0' + elif kind == 'flt3': + src = f'({s} * {s} * {s} - {s}) % 3 == 0' + else: + src = f'({s} * {s} * {s} * {s} * {s} - {s}) % 5 == 0' + return ast.parse(src, mode='eval').body + +def build_seed_stmt(seed_name: str) -> ast.stmt: + return ast.parse(f'{seed_name} = id(int) & 0xFFFFFFFF').body[0] diff --git a/patchwork/transforms/strings.py b/patchwork/transforms/strings.py new file mode 100644 index 0000000..de64fa5 --- /dev/null +++ b/patchwork/transforms/strings.py @@ -0,0 +1,84 @@ +from __future__ import annotations +import ast +import random + +class StringEncryptor(ast.NodeTransformer): + + def __init__(self, rng: random.Random, decrypt_str_name: str, decrypt_bytes_name: str): + self.rng = rng + self.dec_s = decrypt_str_name + self.dec_b = decrypt_bytes_name + + def _strip_docstring(self, body: list[ast.stmt]) -> list[ast.stmt]: + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str): + return body[1:] or [ast.Pass()] + return body + + def visit_Module(self, node: ast.Module) -> ast.AST: + node.body = self._strip_docstring(node.body) + self.generic_visit(node) + return node + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST: + node.body = self._strip_docstring(node.body) + self.generic_visit(node) + return node + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST: + node.body = self._strip_docstring(node.body) + self.generic_visit(node) + return node + + def visit_JoinedStr(self, node: ast.JoinedStr) -> ast.AST: + new_values: list[ast.AST] = [] + for v in node.values: + if isinstance(v, ast.FormattedValue): + v.value = self.visit(v.value) + if v.format_spec is not None: + v.format_spec = self.visit(v.format_spec) + new_values.append(v) + else: + new_values.append(v) + node.values = new_values + return node + + def visit_match_case(self, node: ast.match_case) -> ast.AST: + if node.guard is not None: + node.guard = self.visit(node.guard) + node.body = [self.visit(stmt) for stmt in node.body] + return node + + def visit_Constant(self, node: ast.Constant) -> ast.AST: + v = node.value + if isinstance(v, bool): + return node + if isinstance(v, str): + return self._encrypt_str(v) if v else node + if isinstance(v, bytes): + return self._encrypt_bytes(v) if v else node + return node + + def _make_key(self, n_min: int=4, n_max: int=24) -> bytes: + return bytes((self.rng.randrange(256) for _ in range(self.rng.randint(n_min, n_max)))) + + def _xor(self, data: bytes, key: bytes) -> bytes: + return bytes((b ^ key[i % len(key)] for i, b in enumerate(data))) + + def _encrypt_str(self, s: str) -> ast.AST: + try: + data = s.encode('utf-8') + except UnicodeEncodeError: + return ast.Constant(value=s) + key = self._make_key() + enc = self._xor(data, key) + return ast.Call(func=ast.Name(id=self.dec_s, ctx=ast.Load()), args=[ast.Constant(value=enc), ast.Constant(value=key)], keywords=[]) + + def _encrypt_bytes(self, b: bytes) -> ast.AST: + key = self._make_key() + enc = self._xor(b, key) + return ast.Call(func=ast.Name(id=self.dec_b, ctx=ast.Load()), args=[ast.Constant(value=enc), ast.Constant(value=key)], keywords=[]) + +def build_decrypt_helper(decrypt_str_name: str, decrypt_bytes_name: str) -> list[ast.stmt]: + src = f'def {decrypt_str_name}(d, k):\n return bytes(b ^ k[i % len(k)] for i, b in enumerate(d)).decode()\ndef {decrypt_bytes_name}(d, k):\n return bytes(b ^ k[i % len(k)] for i, b in enumerate(d))\n' + return ast.parse(src).body diff --git a/patchwork/util.py b/patchwork/util.py new file mode 100644 index 0000000..7e62e91 --- /dev/null +++ b/patchwork/util.py @@ -0,0 +1,25 @@ +from __future__ import annotations +import random +import secrets +CONFUSE_CHARS = 'OoIl01' + +def make_rng(seed: int | None=None) -> tuple[random.Random, int]: + if seed is None: + seed = secrets.randbits(63) + return (random.Random(seed), seed) + +def gen_name(rng: random.Random, length: int | None=None, used: set[str] | None=None) -> str: + if length is None: + length = rng.randint(12, 20) + used = used or set() + for _ in range(64): + name = '_' + ''.join((rng.choice(CONFUSE_CHARS) for _ in range(length))) + if name not in used: + return name + while True: + name = '_' + ''.join((rng.choice(CONFUSE_CHARS) for _ in range(length + rng.randint(1, 4)))) + if name not in used: + return name + +def gen_bytes(rng: random.Random, length: int) -> bytes: + return bytes((rng.randrange(256) for _ in range(length))) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2eb72ca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "patchwork" +version = "0.1.0" +description = "Advanced multi-layer Python source obfuscator: AST mangling + marshaled bytecode + multi-cipher encryption + polymorphic anti-debug loader" +requires-python = ">=3.9" +license = {text = "MIT"} +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Security", + "Topic :: Software Development :: Code Generators", +] + +[project.scripts] +patchwork = "patchwork.cli:main" + +[tool.setuptools.packages.find] +include = ["patchwork*"] diff --git a/tests/test_obfuscator.py b/tests/test_obfuscator.py new file mode 100644 index 0000000..d098f53 --- /dev/null +++ b/tests/test_obfuscator.py @@ -0,0 +1,58 @@ +from __future__ import annotations +import subprocess +import sys +from pathlib import Path +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +from patchwork import Obfuscator +EXAMPLES = ROOT / 'examples' +EXAMPLE_NAMES = ['hello', 'fizzbuzz', 'classes', 'stress'] +SEEDS = [1, 7, 42, 1234, 999999] + +def run(path: Path) -> str: + res = subprocess.run([sys.executable, str(path)], capture_output=True, text=True) + if res.returncode != 0: + raise RuntimeError(f'{path} exited {res.returncode}\nstderr:\n{res.stderr}') + return res.stdout + +def check_one(name: str, seed: int) -> None: + src_path = EXAMPLES / f'{name}.py' + expected = run(src_path) + obf_src = Obfuscator(seed=seed).obfuscate(src_path.read_text(encoding='utf-8')) + obf_path = EXAMPLES / f'{name}_test_{seed}.py' + obf_path.write_text(obf_src, encoding='utf-8') + try: + actual = run(obf_path) + finally: + obf_path.unlink(missing_ok=True) + if actual != expected: + raise AssertionError(f'{name} seed={seed}: output mismatch\n--- expected ---\n{expected}--- actual ---\n{actual}') + +def main() -> int: + fails = 0 + for name in EXAMPLE_NAMES: + for seed in SEEDS: + try: + check_one(name, seed) + print(f'ok: {name:10s} seed={seed}') + except Exception as e: + fails += 1 + print(f'FAIL: {name:10s} seed={seed}: {e}') + src = (EXAMPLES / 'hello.py').read_text(encoding='utf-8') + a = Obfuscator(seed=1).obfuscate(src) + b = Obfuscator(seed=2).obfuscate(src) + c = Obfuscator(seed=1).obfuscate(src) + if a == b: + print('FAIL: polymorphism (seed=1 vs seed=2 produced identical output)') + fails += 1 + else: + print('ok: polymorphism (seeds 1 vs 2 differ)') + if a != c: + print('FAIL: reproducibility (same seed gave different output)') + fails += 1 + else: + print('ok: reproducibility (seed=1 stable)') + print(f'\n{fails} failures' if fails else '\nall good') + return 1 if fails else 0 +if __name__ == '__main__': + raise SystemExit(main())