Add Abyss VM protection mode

This commit is contained in:
ashton
2026-06-17 20:10:17 -05:00
parent 27a361ef35
commit f453e02e6c
9 changed files with 1163 additions and 9 deletions
+22
View File
@@ -4,6 +4,11 @@ Python source obfuscator. Give it a `.py` file, get back a single self-contained
Each build is unique by default. Pass `--seed N` if you want a reproducible one.
Version 0.6 adds optional Abyss VM protection for selected functions. Protected
function bodies are lowered into encrypted VM assets before CPython compilation,
then executed by a generated per-build runtime instead of being restored as
ordinary Python code objects.
Version 0.5 adds a shared encoded literal pool, randomized decode paths, more
integer rewrite forms, and a reserved-import regression fix for reproducible
stress-tested builds.
@@ -17,6 +22,19 @@ keep-name files, dry runs, stats JSON, HTML reports, manifest verification,
output budget gates, a version flag, and CI metadata that matches the supported
Python versions.
## 0.6 Improvements
- `--abyss` and `--abyss-functions` add an opt-in virtualized protection tier
for supported pure-ish functions.
- Abyss-protected functions are replaced with wrappers before `compile(...)`;
their real bodies are stored as encrypted data for a generated stack VM, not
as marshaled CPython function code objects.
- The generated VM uses per-build randomized opcode values, shuffled dispatch
block order, encrypted constant/code assets, and preservation of referenced
globals so targeted functions still run under identifier renaming.
- Unsupported syntax is refused for explicitly named Abyss functions and skipped
during broad `--abyss` auto-protection, keeping the default profile stable.
## 0.5 Improvements
- String and bytes literals are now stored in a shared encoded pool and decoded
@@ -139,6 +157,8 @@ python -m patchwork INPUT [-o OUTPUT] [options]
--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
--abyss virtualize eligible functions into encrypted VM assets
--abyss-functions NAMES virtualize only these functions, comma-separated or repeatable
--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
@@ -214,6 +234,8 @@ obf = Obfuscator(
junk_branches=True,
lazy_funcs=True,
anti_debug=True,
abyss=False,
abyss_functions=[],
layers=3,
stage2_layers=3,
keep={'public_api_name'},
+1 -1
View File
@@ -1,7 +1,7 @@
from .core import Obfuscator, obfuscate, obfuscate_file
from .audit import analyze_source, build_manifest, build_stats, verify_manifest
__version__ = '0.5.0'
__version__ = '0.6.0'
__all__ = [
'Obfuscator',
+965
View File
@@ -0,0 +1,965 @@
from __future__ import annotations
import ast
import base64
import json
import random
from dataclasses import dataclass
from typing import Any
from .util import gen_bytes
DISPATCH_NAME = "__pw_ab_dispatch__"
ASSETS_NAME = "__pw_ab_assets__"
_OPS = (
"CONST",
"LOAD",
"STORE",
"POP",
"DUP",
"BIN",
"UNARY",
"COMPARE_CHAIN",
"JUMP",
"JUMP_IF_FALSE",
"JUMP_IF_TRUE_KEEP",
"JUMP_IF_FALSE_KEEP",
"CALL",
"GET_ATTR",
"SUBSCR",
"BUILD_SLICE",
"BUILD_LIST",
"BUILD_TUPLE",
"BUILD_SET",
"BUILD_DICT",
"RETURN",
"GET_ITER",
"FOR_ITER",
"UNPACK",
"BUILD_STRING",
"FORMAT_VALUE",
)
_BIN_OPS: tuple[tuple[type[ast.operator], str], ...] = (
(ast.Add, "add"),
(ast.Sub, "sub"),
(ast.Mult, "mul"),
(ast.MatMult, "matmul"),
(ast.Div, "truediv"),
(ast.FloorDiv, "floordiv"),
(ast.Mod, "mod"),
(ast.Pow, "pow"),
(ast.LShift, "lshift"),
(ast.RShift, "rshift"),
(ast.BitOr, "or"),
(ast.BitXor, "xor"),
(ast.BitAnd, "and"),
)
_UNARY_OPS: tuple[tuple[type[ast.unaryop], str], ...] = (
(ast.Invert, "invert"),
(ast.Not, "not"),
(ast.UAdd, "pos"),
(ast.USub, "neg"),
)
_COMPARE_OPS: tuple[tuple[type[ast.cmpop], str], ...] = (
(ast.Eq, "eq"),
(ast.NotEq, "ne"),
(ast.Lt, "lt"),
(ast.LtE, "le"),
(ast.Gt, "gt"),
(ast.GtE, "ge"),
(ast.Is, "is"),
(ast.IsNot, "is_not"),
(ast.In, "in"),
(ast.NotIn, "not_in"),
)
class UnsupportedAbyssNode(ValueError):
pass
@dataclass(frozen=True)
class EncodedAbyssAssets:
payload: str
key: str
opcodes: dict[str, int]
class _Label:
def __init__(self) -> None:
self.index: int | None = None
class _Emitter:
def __init__(self) -> None:
self.instructions: list[list[Any]] = []
def label(self) -> _Label:
return _Label()
def mark(self, label: _Label) -> None:
label.index = len(self.instructions)
def emit(self, op: str, *args: Any) -> None:
self.instructions.append([op, *args])
def resolve(self) -> list[list[Any]]:
resolved: list[list[Any]] = []
for inst in self.instructions:
op, *args = inst
new_args: list[Any] = []
for arg in args:
if isinstance(arg, _Label):
if arg.index is None:
raise UnsupportedAbyssNode("internal unresolved VM label")
new_args.append(arg.index)
else:
new_args.append(arg)
resolved.append([op, *new_args])
return resolved
def _op_name(op: ast.AST, table: tuple[tuple[type[Any], str], ...]) -> str:
for cls, name in table:
if isinstance(op, cls):
return name
raise UnsupportedAbyssNode(f"unsupported operator {type(op).__name__}")
def _strip_docstring(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:]
return body
def _argument_names(args: ast.arguments) -> set[str]:
names = {arg.arg for arg in args.posonlyargs + args.args + args.kwonlyargs}
if args.vararg is not None:
names.add(args.vararg.arg)
if args.kwarg is not None:
names.add(args.kwarg.arg)
return names
def _target_names(target: ast.AST) -> set[str]:
if isinstance(target, ast.Name):
return {target.id}
if isinstance(target, (ast.Tuple, ast.List)):
names: set[str] = set()
for elt in target.elts:
names.update(_target_names(elt))
return names
return set()
class _ScopeCollector(ast.NodeVisitor):
def __init__(self, args: ast.arguments) -> None:
self.local_names = set(_argument_names(args))
self.global_names: set[str] = set()
def visit_Global(self, node: ast.Global) -> None:
self.global_names.update(node.names)
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
return
visit_AsyncFunctionDef = visit_FunctionDef
def visit_ClassDef(self, node: ast.ClassDef) -> None:
return
def visit_Lambda(self, node: ast.Lambda) -> None:
return
def visit_Assign(self, node: ast.Assign) -> None:
for target in node.targets:
self.local_names.update(_target_names(target))
self.visit(node.value)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
self.local_names.update(_target_names(node.target))
if node.value is not None:
self.visit(node.value)
def visit_AugAssign(self, node: ast.AugAssign) -> None:
self.local_names.update(_target_names(node.target))
self.visit(node.value)
def visit_For(self, node: ast.For) -> None:
self.local_names.update(_target_names(node.target))
self.visit(node.iter)
for stmt in node.body + node.orelse:
self.visit(stmt)
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
self.local_names.update(_target_names(node.target))
self.visit(node.value)
def finalize(self) -> tuple[set[str], set[str]]:
return self.local_names - self.global_names, self.global_names
class AbyssCompiler:
def __init__(self) -> None:
self.emitter = _Emitter()
self.constants: list[Any] = []
self.local_names: set[str] = set()
self.global_names: set[str] = set()
self.external_names: set[str] = set()
self.loop_stack: list[tuple[_Label, _Label, int]] = []
def compile(self, node: ast.FunctionDef) -> dict[str, Any]:
self._reject_function_shape(node)
collector = _ScopeCollector(node.args)
for stmt in node.body:
collector.visit(stmt)
self.local_names, self.global_names = collector.finalize()
body = _strip_docstring(node.body)
if not body:
self.emitter.emit("CONST", self._const(None))
self.emitter.emit("RETURN")
else:
for stmt in body:
self._stmt(stmt)
self.emitter.emit("CONST", self._const(None))
self.emitter.emit("RETURN")
return {
"name": node.name,
"code": self.emitter.resolve(),
"consts": [_encode_const(value) for value in self.constants],
"globals": sorted(self.global_names),
"locals": sorted(self.local_names),
"externals": sorted(self.external_names),
}
def _reject_function_shape(self, node: ast.FunctionDef) -> None:
for child in ast.walk(node):
if isinstance(child, (ast.Yield, ast.YieldFrom, ast.Await)):
raise UnsupportedAbyssNode("generators, coroutines, and await are not supported")
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
if child is not node:
raise UnsupportedAbyssNode("nested functions/classes are not supported")
def _const(self, value: Any) -> int:
idx = len(self.constants)
self.constants.append(value)
return idx
def _stmt(self, node: ast.stmt) -> None:
if isinstance(node, ast.Return):
if node.value is None:
self.emitter.emit("CONST", self._const(None))
else:
self._expr(node.value)
self.emitter.emit("RETURN")
return
if isinstance(node, ast.Assign):
self._expr(node.value)
for index, target in enumerate(node.targets):
if index < len(node.targets) - 1:
self.emitter.emit("DUP")
self._store_target(target)
return
if isinstance(node, ast.AnnAssign):
if node.value is not None:
self._expr(node.value)
self._store_target(node.target)
return
if isinstance(node, ast.AugAssign):
if not isinstance(node.target, ast.Name):
raise UnsupportedAbyssNode("augmented assignment only supports local/global names")
self._expr(ast.Name(id=node.target.id, ctx=ast.Load()))
self._expr(node.value)
self.emitter.emit("BIN", _op_name(node.op, _BIN_OPS))
self.emitter.emit("STORE", node.target.id)
return
if isinstance(node, ast.Expr):
self._expr(node.value)
self.emitter.emit("POP")
return
if isinstance(node, ast.Pass):
return
if isinstance(node, ast.Global):
return
if isinstance(node, ast.If):
else_label = self.emitter.label()
end_label = self.emitter.label()
self._expr(node.test)
self.emitter.emit("JUMP_IF_FALSE", else_label)
for stmt in node.body:
self._stmt(stmt)
self.emitter.emit("JUMP", end_label)
self.emitter.mark(else_label)
for stmt in node.orelse:
self._stmt(stmt)
self.emitter.mark(end_label)
return
if isinstance(node, ast.While):
start_label = self.emitter.label()
end_label = self.emitter.label()
self.emitter.mark(start_label)
self._expr(node.test)
self.emitter.emit("JUMP_IF_FALSE", end_label)
self.loop_stack.append((start_label, end_label, 0))
for stmt in node.body:
self._stmt(stmt)
self.loop_stack.pop()
self.emitter.emit("JUMP", start_label)
self.emitter.mark(end_label)
if node.orelse:
raise UnsupportedAbyssNode("while/else is not supported")
return
if isinstance(node, ast.For):
if node.orelse:
raise UnsupportedAbyssNode("for/else is not supported")
start_label = self.emitter.label()
end_label = self.emitter.label()
self._expr(node.iter)
self.emitter.emit("GET_ITER")
self.emitter.mark(start_label)
self.emitter.emit("FOR_ITER", end_label)
self._store_target(node.target)
self.loop_stack.append((start_label, end_label, 1))
for stmt in node.body:
self._stmt(stmt)
self.loop_stack.pop()
self.emitter.emit("JUMP", start_label)
self.emitter.mark(end_label)
return
if isinstance(node, ast.Break):
if not self.loop_stack:
raise UnsupportedAbyssNode("break outside loop")
_, end_label, cleanup = self.loop_stack[-1]
for _ in range(cleanup):
self.emitter.emit("POP")
self.emitter.emit("JUMP", end_label)
return
if isinstance(node, ast.Continue):
if not self.loop_stack:
raise UnsupportedAbyssNode("continue outside loop")
start_label, _, _ = self.loop_stack[-1]
self.emitter.emit("JUMP", start_label)
return
raise UnsupportedAbyssNode(f"unsupported statement {type(node).__name__}")
def _store_target(self, target: ast.AST) -> None:
if isinstance(target, ast.Name):
self.emitter.emit("STORE", target.id)
return
if isinstance(target, (ast.Tuple, ast.List)):
self.emitter.emit("UNPACK", len(target.elts))
for elt in target.elts:
self._store_target(elt)
return
raise UnsupportedAbyssNode(f"unsupported assignment target {type(target).__name__}")
def _expr(self, node: ast.expr) -> None:
if isinstance(node, ast.Constant):
self.emitter.emit("CONST", self._const(node.value))
return
if isinstance(node, ast.Name):
if isinstance(node.ctx, ast.Load):
if node.id not in self.local_names or node.id in self.global_names:
self.external_names.add(node.id)
self.emitter.emit("LOAD", node.id)
return
raise UnsupportedAbyssNode("name expression is not load context")
if isinstance(node, ast.BinOp):
self._expr(node.left)
self._expr(node.right)
self.emitter.emit("BIN", _op_name(node.op, _BIN_OPS))
return
if isinstance(node, ast.UnaryOp):
self._expr(node.operand)
self.emitter.emit("UNARY", _op_name(node.op, _UNARY_OPS))
return
if isinstance(node, ast.BoolOp):
self._bool_op(node)
return
if isinstance(node, ast.Compare):
self._expr(node.left)
for comparator in node.comparators:
self._expr(comparator)
self.emitter.emit("COMPARE_CHAIN", [_op_name(op, _COMPARE_OPS) for op in node.ops])
return
if isinstance(node, ast.IfExp):
else_label = self.emitter.label()
end_label = self.emitter.label()
self._expr(node.test)
self.emitter.emit("JUMP_IF_FALSE", else_label)
self._expr(node.body)
self.emitter.emit("JUMP", end_label)
self.emitter.mark(else_label)
self._expr(node.orelse)
self.emitter.mark(end_label)
return
if isinstance(node, ast.Call):
if any(isinstance(arg, ast.Starred) for arg in node.args):
raise UnsupportedAbyssNode("*args calls are not supported")
if any(keyword.arg is None for keyword in node.keywords):
raise UnsupportedAbyssNode("**kwargs calls are not supported")
self._expr(node.func)
for arg in node.args:
self._expr(arg)
keyword_names: list[str] = []
for keyword in node.keywords:
if keyword.arg is None:
raise UnsupportedAbyssNode("**kwargs calls are not supported")
keyword_names.append(keyword.arg)
self._expr(keyword.value)
self.emitter.emit("CALL", len(node.args), keyword_names)
return
if isinstance(node, ast.Attribute):
self._expr(node.value)
self.emitter.emit("GET_ATTR", node.attr)
return
if isinstance(node, ast.Subscript):
self._expr(node.value)
self._expr(node.slice)
self.emitter.emit("SUBSCR")
return
if isinstance(node, ast.Slice):
for part in (node.lower, node.upper, node.step):
if part is None:
self.emitter.emit("CONST", self._const(None))
else:
self._expr(part)
self.emitter.emit("BUILD_SLICE")
return
if isinstance(node, ast.List):
self._sequence(node.elts, "BUILD_LIST")
return
if isinstance(node, ast.Tuple):
self._sequence(node.elts, "BUILD_TUPLE")
return
if isinstance(node, ast.Set):
self._sequence(node.elts, "BUILD_SET")
return
if isinstance(node, ast.Dict):
for key, value in zip(node.keys, node.values):
if key is None:
raise UnsupportedAbyssNode("dictionary unpacking is not supported")
self._expr(key)
self._expr(value)
self.emitter.emit("BUILD_DICT", len(node.keys))
return
if isinstance(node, ast.JoinedStr):
for value in node.values:
if isinstance(value, ast.Constant) and isinstance(value.value, str):
self.emitter.emit("CONST", self._const(value.value))
elif isinstance(value, ast.FormattedValue):
self._formatted_value(value)
else:
raise UnsupportedAbyssNode("unsupported f-string part")
self.emitter.emit("BUILD_STRING", len(node.values))
return
if isinstance(node, ast.FormattedValue):
self._formatted_value(node)
return
if isinstance(node, ast.NamedExpr):
if not isinstance(node.target, ast.Name):
raise UnsupportedAbyssNode("walrus target must be a name")
self._expr(node.value)
self.emitter.emit("DUP")
self._store_target(node.target)
return
raise UnsupportedAbyssNode(f"unsupported expression {type(node).__name__}")
def _bool_op(self, node: ast.BoolOp) -> None:
if not node.values:
raise UnsupportedAbyssNode("empty boolean operation")
end_label = self.emitter.label()
jump_op = "JUMP_IF_TRUE_KEEP" if isinstance(node.op, ast.Or) else "JUMP_IF_FALSE_KEEP"
for index, value in enumerate(node.values):
self._expr(value)
if index < len(node.values) - 1:
self.emitter.emit(jump_op, end_label)
self.emitter.mark(end_label)
def _sequence(self, elts: list[ast.expr], op: str) -> None:
for elt in elts:
if isinstance(elt, ast.Starred):
raise UnsupportedAbyssNode("starred literals are not supported")
self._expr(elt)
self.emitter.emit(op, len(elts))
def _formatted_value(self, node: ast.FormattedValue) -> None:
self._expr(node.value)
if node.format_spec is not None:
self._expr(node.format_spec)
self.emitter.emit("FORMAT_VALUE", node.conversion, True)
else:
self.emitter.emit("FORMAT_VALUE", node.conversion, False)
class AbyssTransformer(ast.NodeTransformer):
def __init__(
self,
rng: random.Random,
*,
targets: set[str] | None = None,
auto: bool = False,
dispatch_name: str = DISPATCH_NAME,
) -> None:
self.rng = rng
self.targets = set(targets or ())
self.auto = auto
self.dispatch_name = dispatch_name
self.assets: list[dict[str, Any]] = []
self.keep_names: set[str] = {dispatch_name}
self.protected: list[str] = []
self.skipped: list[tuple[str, str]] = []
self._matched_targets: set[str] = set()
self._class_stack: list[str] = []
self._function_depth = 0
@property
def explicit(self) -> bool:
return bool(self.targets)
def protect(self, tree: ast.Module) -> ast.Module:
new_tree = self.visit(tree)
if not isinstance(new_tree, ast.Module):
raise TypeError("expected module")
missing = sorted(self.targets - self._matched_targets)
if missing:
raise ValueError(f"abyss target(s) not found: {', '.join(missing)}")
return new_tree
def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST:
self._class_stack.append(node.name)
node.body = [self.visit(stmt) for stmt in node.body]
self._class_stack.pop()
return node
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
qualname = ".".join([*self._class_stack, node.name]) if self._class_stack else node.name
target_names = {node.name, qualname}
targeted = bool(self.targets & target_names)
desired = self._function_depth == 0 and (targeted or (self.auto and not self.explicit))
if targeted:
self._matched_targets.update(self.targets & target_names)
if not desired:
self._function_depth += 1
node = self.generic_visit(node)
self._function_depth -= 1
return node
try:
asset = AbyssCompiler().compile(node)
except UnsupportedAbyssNode as exc:
if targeted:
raise ValueError(f"abyss target {qualname} is unsupported: {exc}") from exc
self.skipped.append((qualname, str(exc)))
self._function_depth += 1
node = self.generic_visit(node)
self._function_depth -= 1
return node
fid = len(self.assets)
self.assets.append(asset)
self.keep_names.update(asset["externals"])
self.protected.append(qualname)
node.body = self._wrapper_body(fid, _function_docstring(node))
return node
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
qualname = ".".join([*self._class_stack, node.name]) if self._class_stack else node.name
target_names = {node.name, qualname}
targeted = bool(self.targets & target_names)
if targeted:
self._matched_targets.update(self.targets & target_names)
raise ValueError(f"abyss target {qualname} is unsupported: async functions are not supported")
return node
def _wrapper_body(self, fid: int, docstring: str | None) -> list[ast.stmt]:
call = ast.Call(
func=ast.Name(id=self.dispatch_name, ctx=ast.Load()),
args=[
ast.Constant(value=fid),
ast.Call(func=ast.Name(id="locals", ctx=ast.Load()), args=[], keywords=[]),
],
keywords=[],
)
body: list[ast.stmt] = []
if docstring is not None:
body.append(ast.Expr(value=ast.Constant(value=docstring)))
body.append(ast.Return(value=call))
return body
def _function_docstring(node: ast.FunctionDef) -> str | None:
if (
node.body
and isinstance(node.body[0], ast.Expr)
and isinstance(node.body[0].value, ast.Constant)
and isinstance(node.body[0].value.value, str)
):
return node.body[0].value.value
return None
def _encode_const(value: Any) -> dict[str, Any]:
if value is None:
return {"t": "none"}
if value is Ellipsis:
return {"t": "ellipsis"}
if isinstance(value, bool):
return {"t": "bool", "v": value}
if isinstance(value, int):
return {"t": "int", "v": str(value)}
if isinstance(value, float):
return {"t": "float", "v": repr(value)}
if isinstance(value, str):
return {"t": "str", "v": value}
if isinstance(value, bytes):
return {"t": "bytes", "v": base64.b85encode(value).decode("ascii")}
raise UnsupportedAbyssNode(f"unsupported constant type {type(value).__name__}")
def encode_assets(assets: list[dict[str, Any]], rng: random.Random) -> EncodedAbyssAssets:
values = rng.sample(range(1, 251), len(_OPS))
opcodes = dict(zip(_OPS, values))
encoded_funcs: list[dict[str, Any]] = []
for asset in assets:
encoded_code = [[opcodes[inst[0]], *inst[1:]] for inst in asset["code"]]
encoded_funcs.append(
{
"n": asset["name"],
"c": asset["consts"],
"g": asset["globals"],
"l": asset["locals"],
"b": encoded_code,
}
)
document = json.dumps({"v": 1, "f": encoded_funcs}, separators=(",", ":"), sort_keys=True).encode("utf-8")
key = gen_bytes(rng, rng.randint(24, 48))
encrypted = bytes(byte ^ key[index % len(key)] for index, byte in enumerate(document))
return EncodedAbyssAssets(
payload=base64.b85encode(encrypted).decode("ascii"),
key=base64.b85encode(key).decode("ascii"),
opcodes=opcodes,
)
def build_runtime_stmts(encoded: EncodedAbyssAssets, rng: random.Random) -> list[ast.stmt]:
blocks = _runtime_dispatch_blocks(encoded.opcodes)
rng.shuffle(blocks)
dispatch_blocks = "\n".join(blocks)
source = f'''
{ASSETS_NAME} = ({encoded.payload!r}, {encoded.key!r})
__pw_ab_cache__ = None
def __pw_ab_const__(_x):
_t = _x['t']
if _t == 'none':
return None
if _t == 'ellipsis':
return Ellipsis
if _t == 'bool':
return bool(_x['v'])
if _t == 'int':
return int(_x['v'])
if _t == 'float':
return float(_x['v'])
if _t == 'str':
return _x['v']
if _t == 'bytes':
return __import__('base64').b85decode(_x['v'].encode('ascii'))
raise RuntimeError('invalid abyss constant')
def __pw_ab_load__():
global __pw_ab_cache__
if __pw_ab_cache__ is None:
_base64 = __import__('base64')
_json = __import__('json')
_payload, _key = {ASSETS_NAME}
_enc = _base64.b85decode(_payload.encode('ascii'))
_raw_key = _base64.b85decode(_key.encode('ascii'))
_raw = bytes(_b ^ _raw_key[_i % len(_raw_key)] for _i, _b in enumerate(_enc))
_doc = _json.loads(_raw.decode('utf-8'))
for _fn in _doc['f']:
_fn['c'] = [__pw_ab_const__(_item) for _item in _fn['c']]
__pw_ab_cache__ = _doc
return __pw_ab_cache__
def __pw_ab_get__(_name, _locals, _globals, _builtins, _declared_globals, _declared_locals):
if _name not in _declared_globals and _name in _locals:
return _locals[_name]
if _name not in _declared_globals and _name in _declared_locals:
raise UnboundLocalError("cannot access local variable '" + _name + "' where it is not associated with a value")
if _name in _globals:
return _globals[_name]
if _name in _builtins:
return _builtins[_name]
raise NameError(_name)
def __pw_ab_store__(_name, _value, _locals, _globals, _declared_globals):
if _name in _declared_globals:
_globals[_name] = _value
else:
_locals[_name] = _value
def __pw_ab_bin__(_op, _a, _b):
if _op == 'add':
return _a + _b
if _op == 'sub':
return _a - _b
if _op == 'mul':
return _a * _b
if _op == 'matmul':
return _a @ _b
if _op == 'truediv':
return _a / _b
if _op == 'floordiv':
return _a // _b
if _op == 'mod':
return _a % _b
if _op == 'pow':
return _a ** _b
if _op == 'lshift':
return _a << _b
if _op == 'rshift':
return _a >> _b
if _op == 'or':
return _a | _b
if _op == 'xor':
return _a ^ _b
if _op == 'and':
return _a & _b
raise RuntimeError('invalid abyss binary op')
def __pw_ab_unary__(_op, _a):
if _op == 'invert':
return ~_a
if _op == 'not':
return not _a
if _op == 'pos':
return +_a
if _op == 'neg':
return -_a
raise RuntimeError('invalid abyss unary op')
def __pw_ab_compare_one__(_op, _a, _b):
if _op == 'eq':
return _a == _b
if _op == 'ne':
return _a != _b
if _op == 'lt':
return _a < _b
if _op == 'le':
return _a <= _b
if _op == 'gt':
return _a > _b
if _op == 'ge':
return _a >= _b
if _op == 'is':
return _a is _b
if _op == 'is_not':
return _a is not _b
if _op == 'in':
return _a in _b
if _op == 'not_in':
return _a not in _b
raise RuntimeError('invalid abyss compare op')
def __pw_ab_compare__(_ops, _values):
for _idx, _op in enumerate(_ops):
if not __pw_ab_compare_one__(_op, _values[_idx], _values[_idx + 1]):
return False
return True
def __pw_ab_format__(_value, _conversion, _has_spec, _stack):
_spec = _stack.pop() if _has_spec else ''
if _conversion == 115:
_value = str(_value)
elif _conversion == 114:
_value = repr(_value)
elif _conversion == 97:
_value = ascii(_value)
return format(_value, _spec)
def __pw_ab_exec__(_fn, _initial_locals, _globals):
_builtins = _globals.get('__builtins__', __builtins__)
if not isinstance(_builtins, dict):
_builtins = _builtins.__dict__
_declared_globals = set(_fn.get('g', ()))
_declared_locals = set(_fn.get('l', ()))
_locals = dict(_initial_locals)
_consts = _fn['c']
_code = _fn['b']
_stack = []
_ip = 0
while True:
_inst = _code[_ip]
_op = _inst[0]
{dispatch_blocks}
raise RuntimeError('invalid abyss opcode')
def {DISPATCH_NAME}(_fid, _env):
_doc = __pw_ab_load__()
return __pw_ab_exec__(_doc['f'][_fid], _env, globals())
'''
return ast.parse(source).body
def _runtime_dispatch_blocks(opcodes: dict[str, int]) -> list[str]:
return [
f""" if _op == {opcodes['CONST']}:
_stack.append(_consts[_inst[1]])
_ip += 1
continue""",
f""" if _op == {opcodes['LOAD']}:
_stack.append(__pw_ab_get__(_inst[1], _locals, _globals, _builtins, _declared_globals, _declared_locals))
_ip += 1
continue""",
f""" if _op == {opcodes['STORE']}:
__pw_ab_store__(_inst[1], _stack.pop(), _locals, _globals, _declared_globals)
_ip += 1
continue""",
f""" if _op == {opcodes['POP']}:
_stack.pop()
_ip += 1
continue""",
f""" if _op == {opcodes['DUP']}:
_stack.append(_stack[-1])
_ip += 1
continue""",
f""" if _op == {opcodes['BIN']}:
_b = _stack.pop()
_a = _stack.pop()
_stack.append(__pw_ab_bin__(_inst[1], _a, _b))
_ip += 1
continue""",
f""" if _op == {opcodes['UNARY']}:
_stack.append(__pw_ab_unary__(_inst[1], _stack.pop()))
_ip += 1
continue""",
f""" if _op == {opcodes['COMPARE_CHAIN']}:
_ops = _inst[1]
_values = [_stack.pop() for _ in range(len(_ops) + 1)]
_values.reverse()
_stack.append(__pw_ab_compare__(_ops, _values))
_ip += 1
continue""",
f""" if _op == {opcodes['JUMP']}:
_ip = _inst[1]
continue""",
f""" if _op == {opcodes['JUMP_IF_FALSE']}:
_ip = _inst[1] if not _stack.pop() else _ip + 1
continue""",
f""" if _op == {opcodes['JUMP_IF_TRUE_KEEP']}:
if _stack[-1]:
_ip = _inst[1]
else:
_stack.pop()
_ip += 1
continue""",
f""" if _op == {opcodes['JUMP_IF_FALSE_KEEP']}:
if not _stack[-1]:
_ip = _inst[1]
else:
_stack.pop()
_ip += 1
continue""",
f""" if _op == {opcodes['CALL']}:
_argc = _inst[1]
_kw_names = _inst[2]
_kw = {{}}
for _name in reversed(_kw_names):
_kw[_name] = _stack.pop()
_args = [_stack.pop() for _ in range(_argc)]
_args.reverse()
_func = _stack.pop()
_stack.append(_func(*_args, **_kw))
_ip += 1
continue""",
f""" if _op == {opcodes['GET_ATTR']}:
_stack.append(getattr(_stack.pop(), _inst[1]))
_ip += 1
continue""",
f""" if _op == {opcodes['SUBSCR']}:
_key = _stack.pop()
_obj = _stack.pop()
_stack.append(_obj[_key])
_ip += 1
continue""",
f""" if _op == {opcodes['BUILD_SLICE']}:
_step = _stack.pop()
_upper = _stack.pop()
_lower = _stack.pop()
_stack.append(slice(_lower, _upper, _step))
_ip += 1
continue""",
f""" if _op == {opcodes['BUILD_LIST']}:
_items = [_stack.pop() for _ in range(_inst[1])]
_items.reverse()
_stack.append(_items)
_ip += 1
continue""",
f""" if _op == {opcodes['BUILD_TUPLE']}:
_items = [_stack.pop() for _ in range(_inst[1])]
_items.reverse()
_stack.append(tuple(_items))
_ip += 1
continue""",
f""" if _op == {opcodes['BUILD_SET']}:
_items = [_stack.pop() for _ in range(_inst[1])]
_items.reverse()
_stack.append(set(_items))
_ip += 1
continue""",
f""" if _op == {opcodes['BUILD_DICT']}:
_items = []
for _ in range(_inst[1]):
_value = _stack.pop()
_key = _stack.pop()
_items.append((_key, _value))
_items.reverse()
_stack.append(dict(_items))
_ip += 1
continue""",
f""" if _op == {opcodes['RETURN']}:
return _stack.pop() if _stack else None""",
f""" if _op == {opcodes['GET_ITER']}:
_stack.append(iter(_stack.pop()))
_ip += 1
continue""",
f""" if _op == {opcodes['FOR_ITER']}:
try:
_stack.append(next(_stack[-1]))
_ip += 1
except StopIteration:
_stack.pop()
_ip = _inst[1]
continue""",
f""" if _op == {opcodes['UNPACK']}:
_items = list(_stack.pop())
if len(_items) != _inst[1]:
raise ValueError('not enough values to unpack' if len(_items) < _inst[1] else 'too many values to unpack')
for _item in reversed(_items):
_stack.append(_item)
_ip += 1
continue""",
f""" if _op == {opcodes['BUILD_STRING']}:
_items = [_stack.pop() for _ in range(_inst[1])]
_items.reverse()
_stack.append(''.join(str(_item) for _item in _items))
_ip += 1
continue""",
f""" if _op == {opcodes['FORMAT_VALUE']}:
_stack.append(__pw_ab_format__(_stack.pop() if not _inst[2] else _stack.pop(-2), _inst[1], _inst[2], _stack))
_ip += 1
continue""",
]
+17 -2
View File
@@ -33,6 +33,8 @@ def _parser() -> argparse.ArgumentParser:
p.add_argument("--no-junk", action="store_false", dest="junk_branches", default=None)
p.add_argument("--no-lazy", action="store_false", dest="lazy_funcs", default=None)
p.add_argument("--no-anti-debug", action="store_false", dest="anti_debug", default=None)
p.add_argument("--abyss", action="store_true", default=None, help="virtualize eligible functions into encrypted Abyss VM assets")
p.add_argument("--abyss-functions", action="append", default=None, metavar="NAME[,NAME]", help="only virtualize the named function(s); repeatable or comma-separated")
p.add_argument("--audit-only", action="store_true", help="analyze the input and exit without writing obfuscated output")
p.add_argument("--audit-json", metavar="PATH", help="write static audit metadata as JSON")
p.add_argument("--manifest", metavar="PATH", help="write build manifest with hashes, options, and audit metadata")
@@ -47,6 +49,13 @@ def _parser() -> argparse.ArgumentParser:
return p
def _split_name_options(values: list[str] | None) -> list[str]:
names: list[str] = []
for value in values or []:
names.extend(part.strip() for part in value.split(",") if part.strip())
return names
def _effective_config(args: argparse.Namespace) -> dict[str, object]:
config = dict(DEFAULT_CONFIG)
config.update(load_config(args.config))
@@ -63,6 +72,7 @@ def _effective_config(args: argparse.Namespace) -> dict[str, object]:
"junk_branches",
"lazy_funcs",
"anti_debug",
"abyss",
"max_output_bytes",
"max_ratio",
"max_review_indicators",
@@ -75,6 +85,9 @@ def _effective_config(args: argparse.Namespace) -> dict[str, object]:
keep.update(args.keep or [])
keep.update(read_keep_file(args.keep_file))
config["keep"] = sorted(keep)
abyss_functions = set(config.get("abyss_functions") or [])
abyss_functions.update(_split_name_options(args.abyss_functions))
config["abyss_functions"] = sorted(abyss_functions)
return normalize_config(config)
@@ -88,6 +101,8 @@ def _obfuscator_options(config: dict[str, object]) -> dict[str, object]:
"junk_branches": config["junk_branches"],
"lazy_funcs": config["lazy_funcs"],
"anti_debug": config["anti_debug"],
"abyss": config["abyss"],
"abyss_functions": config["abyss_functions"],
"layers": config["layers"],
"stage2_layers": config["stage2_layers"],
"keep": sorted(config["keep"]),
@@ -102,7 +117,7 @@ def _build_obfuscator(config: dict[str, object]) -> Obfuscator:
def _gate_errors(config: dict[str, object], audit: dict[str, object], stats: dict[str, object]) -> list[str]:
errors: list[str] = []
review_count = int(audit.get("review", {}).get("indicator_count", 0)) # type: ignore[union-attr]
review_count = int(audit.get("review", {}).get("indicator_count", 0))
max_review = config.get("max_review_indicators")
if max_review is not None and review_count > int(max_review):
errors.append(f"review indicators {review_count} exceed max_review_indicators {max_review}")
@@ -122,7 +137,7 @@ def _gate_errors(config: dict[str, object], audit: dict[str, object], stats: dic
def _print_audit_refusal(audit: dict[str, object]) -> None:
print("patchwork: strict audit refused to obfuscate input with review indicators", file=sys.stderr)
indicators = audit.get("review", {}).get("indicators", []) # type: ignore[union-attr]
indicators = audit.get("review", {}).get("indicators", [])
if isinstance(indicators, list):
for indicator in indicators:
if isinstance(indicator, dict):
+5 -2
View File
@@ -17,6 +17,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
"junk_branches": True,
"lazy_funcs": True,
"anti_debug": True,
"abyss": False,
"abyss_functions": [],
"keep": [],
"max_output_bytes": None,
"max_ratio": None,
@@ -32,15 +34,16 @@ BOOLEAN_KEYS = {
"junk_branches",
"lazy_funcs",
"anti_debug",
"abyss",
}
INTEGER_KEYS = {"seed", "layers", "stage2_layers", "max_output_bytes", "max_review_indicators"}
FLOAT_KEYS = {"max_ratio"}
LIST_KEYS = {"keep"}
LIST_KEYS = {"keep", "abyss_functions"}
class ConfigError(ValueError):
"""Raised for invalid Patchwork configuration files."""
pass
def load_config(path: str | Path | None) -> dict[str, Any]:
+20 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import ast
from pathlib import Path
from .abyss import ASSETS_NAME as _ABYSS_ASSETS_NAME, DISPATCH_NAME as _ABYSS_DISPATCH_NAME, AbyssTransformer, build_runtime_stmts as build_abyss_runtime, encode_assets as encode_abyss_assets
from .lazy import encrypt_user_functions
from .loader import build_loader
from .packer import pack
@@ -36,7 +37,7 @@ def _insert_runtime_stmts(tree: ast.Module, stmts: list[ast.stmt]) -> None:
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):
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, abyss: bool=False, abyss_functions: list[str] | set[str] | tuple[str, ...] | None=None):
self.rng, self.seed = make_rng(seed)
self.rename = rename
self.encrypt_strings = encrypt_strings
@@ -49,10 +50,25 @@ class Obfuscator:
self.layers = max(1, int(layers))
self.stage2_layers = max(1, int(stage2_layers))
self.keep_extra = set(keep or ())
self.abyss = bool(abyss)
self.abyss_functions = set(abyss_functions or ())
self.abyss_stats: dict[str, object] = {"protected": [], "skipped": []}
def obfuscate(self, source: str) -> str:
tree = ast.parse(source)
skip = collect_skip_names(tree)
abyss_runtime_stmts: list[ast.stmt] = []
abyss_keep: set[str] = set()
if self.abyss or self.abyss_functions:
abyss_transformer = AbyssTransformer(self.rng, targets=self.abyss_functions or None, auto=self.abyss)
tree = abyss_transformer.protect(tree)
abyss_keep = abyss_transformer.keep_names | {_ABYSS_DISPATCH_NAME, _ABYSS_ASSETS_NAME}
self.abyss_stats = {
"protected": list(abyss_transformer.protected),
"skipped": list(abyss_transformer.skipped),
}
if abyss_transformer.assets:
abyss_runtime_stmts = build_abyss_runtime(encode_abyss_assets(abyss_transformer.assets, self.rng), self.rng)
seed_used = False
if self.opaque_predicates:
opi = OpaquePredicateInjector(self.rng, _SEED_NAME)
@@ -78,8 +94,10 @@ class Obfuscator:
runtime_stmts.extend(build_decrypt_helper(_DEC_S_NAME, _DEC_B_NAME))
_insert_runtime_stmts(tree, runtime_stmts)
if self.rename:
renamer = IdentifierRenamer(self.rng, keep=skip | self.keep_extra | {_RESOLVE_NAME})
renamer = IdentifierRenamer(self.rng, keep=skip | self.keep_extra | abyss_keep | {_RESOLVE_NAME})
tree = renamer.visit(tree)
if abyss_runtime_stmts:
_insert_runtime_stmts(tree, abyss_runtime_stmts)
ast.fix_missing_locations(tree)
user_code = compile(tree, '<patchwork>', 'exec')
if self.lazy_funcs:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "patchwork"
version = "0.5.0"
version = "0.6.0"
description = "Python source transformation tool with reproducible builds, static audit metadata, and manifest generation"
requires-python = ">=3.10"
license = {text = "MIT"}
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import ast
import contextlib
import io
import random
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from patchwork import Obfuscator
from patchwork.abyss import AbyssTransformer, build_runtime_stmts, encode_assets
def run_source(source: str) -> str:
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exec(compile(source, "<test>", "exec"), {})
return stdout.getvalue()
def run_file(path: Path) -> str:
result = subprocess.run([sys.executable, str(path)], capture_output=True, text=True, timeout=20)
if result.returncode != 0:
raise RuntimeError(f"{path} exited {result.returncode}\nstderr:\n{result.stderr}")
return result.stdout
class AbyssTests(unittest.TestCase):
def test_transform_removes_protected_body_and_preserves_behavior(self) -> None:
source = """
def secret(limit):
marker = "ABYSS_CLEAR_MARKER"
total = 0
for item in range(limit):
if item % 2:
total += item * 3
else:
total += item
return f"{marker}:{total}"
print(secret(7))
"""
expected = run_source(source)
rng = random.Random(2026)
tree = ast.parse(source)
transformer = AbyssTransformer(rng, targets={"secret"})
tree = transformer.protect(tree)
encoded = encode_assets(transformer.assets, rng)
tree.body = build_runtime_stmts(encoded, rng) + tree.body
ast.fix_missing_locations(tree)
transformed_source = ast.unparse(tree)
self.assertNotIn("ABYSS_CLEAR_MARKER", transformed_source)
self.assertIn("secret", transformer.protected)
self.assertEqual(expected, run_source(transformed_source))
def test_obfuscator_abyss_function_can_call_preserved_global(self) -> None:
source = """
def helper(value):
return value + 2
def secret(limit):
total = 0
for item in range(limit):
total += helper(item)
return total
def main():
print("abyss", secret(6))
if __name__ == "__main__":
main()
"""
obfuscator = Obfuscator(seed=99, abyss_functions={"secret"})
obfuscated = obfuscator.obfuscate(source)
self.assertEqual(["secret"], obfuscator.abyss_stats["protected"])
with tempfile.TemporaryDirectory() as raw:
directory = Path(raw)
src_path = directory / "sample.py"
obf_path = directory / "sample_obf.py"
src_path.write_text(source, encoding="utf-8")
obf_path.write_text(obfuscated, encoding="utf-8")
self.assertEqual(run_file(src_path), run_file(obf_path))
def test_cli_abyss_functions_output_runs(self) -> None:
source = """
def secret(left, right):
value = (left << 2) ^ right
return f"result={value}"
print(secret(9, 5))
"""
with tempfile.TemporaryDirectory() as raw:
directory = Path(raw)
src_path = directory / "sample.py"
out_path = directory / "sample_obf.py"
src_path.write_text(source, encoding="utf-8")
result = subprocess.run(
[
sys.executable,
"-m",
"patchwork",
str(src_path),
"-o",
str(out_path),
"--seed",
"17",
"--abyss-functions",
"secret",
"--quiet",
],
cwd=ROOT,
capture_output=True,
text=True,
timeout=20,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(run_file(src_path), run_file(out_path))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -68,7 +68,7 @@ class AuditTests(unittest.TestCase):
result = self.run_cli("--version")
self.assertEqual(0, result.returncode)
self.assertIn("patchwork 0.5.0", result.stdout)
self.assertIn("patchwork 0.6.0", result.stdout)
def test_config_dump_and_keep_file_merge_options(self) -> None:
with tempfile.TemporaryDirectory() as raw: