Harden Abyss assets with sealed packets · bikini/patchwork@fc95438 · GitHub
Skip to content

Commit fc95438

Browse files
Harden Abyss assets with sealed packets
1 parent a86cd89 commit fc95438

6 files changed

Lines changed: 175 additions & 19 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions

patchwork/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from .core import Obfuscator, obfuscate, obfuscate_file
22
from .audit import analyze_source, build_manifest, build_stats, verify_manifest
33

4-
__version__ = '0.6.0'
4+
__version__ = '0.7.0'
55

66
__all__ = [
77
'Obfuscator',

patchwork/abyss.py

Lines changed: 127 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ class EncodedAbyssAssets:
8989
opcodes: dict[str, int]
9090

9191

92+
_JUMP_OPS = {
93+
"JUMP",
94+
"JUMP_IF_FALSE",
95+
"JUMP_IF_TRUE_KEEP",
96+
"JUMP_IF_FALSE_KEEP",
97+
"FOR_ITER",
98+
}
99+
100+
_PACKET_LAYOUTS = (
101+
(0, 1, 2),
102+
(0, 2, 1),
103+
(1, 0, 2),
104+
(1, 2, 0),
105+
(2, 0, 1),
106+
(2, 1, 0),
107+
)
108+
109+
92110
class _Label:
93111
def __init__(self) -> None:
94112
self.index: int | None = None
@@ -237,7 +255,7 @@ def compile(self, node: ast.FunctionDef) -> dict[str, Any]:
237255
return {
238256
"name": node.name,
239257
"code": self.emitter.resolve(),
240-
"consts": [_encode_const(value) for value in self.constants],
258+
"consts": list(self.constants),
241259
"globals": sorted(self.global_names),
242260
"locals": sorted(self.local_names),
243261
"externals": sorted(self.external_names),
@@ -628,22 +646,78 @@ def _encode_const(value: Any) -> dict[str, Any]:
628646
raise UnsupportedAbyssNode(f"unsupported constant type {type(value).__name__}")
629647

630648

649+
def _json_bytes(value: Any) -> bytes:
650+
return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8")
651+
652+
653+
def _seal_value(value: Any, rng: random.Random, salt: int) -> list[Any]:
654+
raw = _json_bytes(value)
655+
key = gen_bytes(rng, rng.randint(9, 24))
656+
add = rng.randrange(256)
657+
step = rng.randrange(1, 256, 2)
658+
encoded = bytes(((byte ^ key[index % len(key)]) + add + ((index + salt) * step)) & 255 for index, byte in enumerate(raw))
659+
share = gen_bytes(rng, len(encoded))
660+
other = bytes(left ^ right for left, right in zip(encoded, share))
661+
return [
662+
base64.b85encode(share).decode("ascii"),
663+
base64.b85encode(other).decode("ascii"),
664+
base64.b85encode(key).decode("ascii"),
665+
add,
666+
step,
667+
]
668+
669+
670+
def _tag_for(opcode: int, argc: int, index: int, salt: int) -> int:
671+
return ((opcode * 2654435761) ^ (argc * 2246822519) ^ ((index + salt) * 3266489917)) & 0xFFFFFFFF
672+
673+
674+
def _encode_packet(inst: list[Any], index: int, rng: random.Random, salt: int) -> list[Any]:
675+
opcode = inst[0]
676+
args = inst[1:]
677+
tag = _tag_for(opcode, len(args), index, salt)
678+
fields = [
679+
_seal_value(opcode, rng, salt + index * 5 + 1),
680+
_seal_value(args, rng, salt + index * 5 + 2),
681+
_seal_value(tag, rng, salt + index * 5 + 3),
682+
]
683+
layout_id = rng.randrange(len(_PACKET_LAYOUTS))
684+
layout = _PACKET_LAYOUTS[layout_id]
685+
return [layout_id, fields[layout[0]], fields[layout[1]], fields[layout[2]]]
686+
687+
688+
def _rebased_instruction(inst: list[Any], base: int) -> list[Any]:
689+
op = inst[0]
690+
if op in _JUMP_OPS:
691+
return [op, base + inst[1], *inst[2:]]
692+
return list(inst)
693+
694+
631695
def encode_assets(assets: list[dict[str, Any]], rng: random.Random) -> EncodedAbyssAssets:
632696
values = rng.sample(range(1, 251), len(_OPS))
633697
opcodes = dict(zip(_OPS, values))
634698
encoded_funcs: list[dict[str, Any]] = []
635-
for asset in assets:
636-
encoded_code = [[opcodes[inst[0]], *inst[1:]] for inst in asset["code"]]
699+
merged_code: list[list[Any]] = []
700+
salt = rng.randrange(1 << 30)
701+
for fn_index, asset in enumerate(assets):
702+
entry = len(merged_code)
703+
rebased_code = [_rebased_instruction(inst, entry) for inst in asset["code"]]
704+
merged_code.extend(rebased_code)
637705
encoded_funcs.append(
638706
{
639-
"n": asset["name"],
640-
"c": asset["consts"],
641-
"g": asset["globals"],
642-
"l": asset["locals"],
643-
"b": encoded_code,
707+
"c": [
708+
_seal_value(_encode_const(value), rng, salt + (fn_index + 1) * 100000 + const_index)
709+
for const_index, value in enumerate(asset["consts"])
710+
],
711+
"e": _seal_value(entry, rng, salt + 200000 + fn_index),
712+
"g": _seal_value(asset["globals"], rng, salt + 300000 + fn_index),
713+
"l": _seal_value(asset["locals"], rng, salt + 400000 + fn_index),
644714
}
645715
)
646-
document = json.dumps({"v": 1, "f": encoded_funcs}, separators=(",", ":"), sort_keys=True).encode("utf-8")
716+
encoded_packets = [
717+
_encode_packet([opcodes[inst[0]], *inst[1:]], index, rng, salt)
718+
for index, inst in enumerate(merged_code)
719+
]
720+
document = _json_bytes({"v": 2, "m": {"s": salt}, "f": encoded_funcs, "p": encoded_packets})
647721
key = gen_bytes(rng, rng.randint(24, 48))
648722
encrypted = bytes(byte ^ key[index % len(key)] for index, byte in enumerate(document))
649723
return EncodedAbyssAssets(
@@ -657,10 +731,42 @@ def build_runtime_stmts(encoded: EncodedAbyssAssets, rng: random.Random) -> list
657731
blocks = _runtime_dispatch_blocks(encoded.opcodes)
658732
rng.shuffle(blocks)
659733
dispatch_blocks = "\n".join(blocks)
734+
layouts_src = repr(_PACKET_LAYOUTS)
660735
source = f'''
661736
{ASSETS_NAME} = ({encoded.payload!r}, {encoded.key!r})
662737
__pw_ab_cache__ = None
663738
739+
def __pw_ab_open__(_box, _salt):
740+
_base64 = __import__('base64')
741+
_json = __import__('json')
742+
_share = _base64.b85decode(_box[0].encode('ascii'))
743+
_other = _base64.b85decode(_box[1].encode('ascii'))
744+
_key = _base64.b85decode(_box[2].encode('ascii'))
745+
_add = _box[3]
746+
_step = _box[4]
747+
_encoded = bytes(_a ^ _b for _a, _b in zip(_share, _other))
748+
_raw = bytearray(len(_encoded))
749+
for _i, _b in enumerate(_encoded):
750+
_raw[_i] = ((_b - _add - ((_i + _salt) * _step)) & 255) ^ _key[_i % len(_key)]
751+
return _json.loads(bytes(_raw).decode('utf-8'))
752+
753+
def __pw_ab_tag__(_opcode, _argc, _index, _salt):
754+
return ((_opcode * 2654435761) ^ (_argc * 2246822519) ^ ((_index + _salt) * 3266489917)) & 4294967295
755+
756+
def __pw_ab_packet__(_packet, _index, _meta):
757+
_layouts = {layouts_src}
758+
_layout = _layouts[_packet[0] % len(_layouts)]
759+
_fields = [None, None, None]
760+
for _pos, _slot in enumerate(_layout):
761+
_fields[_slot] = _packet[_pos + 1]
762+
_salt = _meta['s']
763+
_opcode = __pw_ab_open__(_fields[0], _salt + _index * 5 + 1)
764+
_args = __pw_ab_open__(_fields[1], _salt + _index * 5 + 2)
765+
_tag = __pw_ab_open__(_fields[2], _salt + _index * 5 + 3)
766+
if _tag != __pw_ab_tag__(_opcode, len(_args), _index, _salt):
767+
raise RuntimeError('invalid abyss packet')
768+
return [_opcode, *_args]
769+
664770
def __pw_ab_const__(_x):
665771
_t = _x['t']
666772
if _t == 'none':
@@ -689,8 +795,12 @@ def __pw_ab_load__():
689795
_raw_key = _base64.b85decode(_key.encode('ascii'))
690796
_raw = bytes(_b ^ _raw_key[_i % len(_raw_key)] for _i, _b in enumerate(_enc))
691797
_doc = _json.loads(_raw.decode('utf-8'))
692-
for _fn in _doc['f']:
693-
_fn['c'] = [__pw_ab_const__(_item) for _item in _fn['c']]
798+
_meta = _doc['m']
799+
for _fi, _fn in enumerate(_doc['f']):
800+
_fn['c'] = [__pw_ab_const__(__pw_ab_open__(_item, _meta['s'] + (_fi + 1) * 100000 + _ci)) for _ci, _item in enumerate(_fn['c'])]
801+
_fn['e'] = __pw_ab_open__(_fn['e'], _meta['s'] + 200000 + _fi)
802+
_fn['g'] = __pw_ab_open__(_fn['g'], _meta['s'] + 300000 + _fi)
803+
_fn['l'] = __pw_ab_open__(_fn['l'], _meta['s'] + 400000 + _fi)
694804
__pw_ab_cache__ = _doc
695805
return __pw_ab_cache__
696806
@@ -790,26 +900,27 @@ def __pw_ab_format__(_value, _conversion, _has_spec, _stack):
790900
_value = ascii(_value)
791901
return format(_value, _spec)
792902
793-
def __pw_ab_exec__(_fn, _initial_locals, _globals):
903+
def __pw_ab_exec__(_doc, _fn, _initial_locals, _globals):
794904
_builtins = _globals.get('__builtins__', __builtins__)
795905
if not isinstance(_builtins, dict):
796906
_builtins = _builtins.__dict__
797907
_declared_globals = set(_fn.get('g', ()))
798908
_declared_locals = set(_fn.get('l', ()))
799909
_locals = dict(_initial_locals)
800910
_consts = _fn['c']
801-
_code = _fn['b']
911+
_code = _doc['p']
912+
_meta = _doc['m']
802913
_stack = []
803-
_ip = 0
914+
_ip = _fn['e']
804915
while True:
805-
_inst = _code[_ip]
916+
_inst = __pw_ab_packet__(_code[_ip], _ip, _meta)
806917
_op = _inst[0]
807918
{dispatch_blocks}
808919
raise RuntimeError('invalid abyss opcode')
809920
810921
def {DISPATCH_NAME}(_fid, _env):
811922
_doc = __pw_ab_load__()
812-
return __pw_ab_exec__(_doc['f'][_fid], _env, globals())
923+
return __pw_ab_exec__(_doc, _doc['f'][_fid], _env, globals())
813924
'''
814925
return ast.parse(source).body
815926

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "patchwork"
7-
version = "0.6.0"
7+
version = "0.7.0"
88
description = "Python source transformation tool with reproducible builds, static audit metadata, and manifest generation"
99
requires-python = ">=3.10"
1010
license = {text = "MIT"}

tests/test_abyss.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

33
import ast
4+
import base64
45
import contextlib
56
import io
7+
import json
68
import random
79
import subprocess
810
import sys
@@ -60,6 +62,31 @@ def secret(limit):
6062
self.assertIn("secret", transformer.protected)
6163
self.assertEqual(expected, run_source(transformed_source))
6264

65+
def test_encoded_assets_have_second_layer_sealing(self) -> None:
66+
source = """
67+
def secret(limit):
68+
marker = "ABYSS_SECOND_LAYER_MARKER"
69+
total = 0
70+
for item in range(limit):
71+
total += item
72+
return f"{marker}:{total}"
73+
"""
74+
rng = random.Random(7007)
75+
tree = ast.parse(source)
76+
transformer = AbyssTransformer(rng, targets={"secret"})
77+
transformer.protect(tree)
78+
encoded = encode_assets(transformer.assets, rng)
79+
outer = base64.b85decode(encoded.payload.encode("ascii"))
80+
key = base64.b85decode(encoded.key.encode("ascii"))
81+
decoded = bytes(byte ^ key[index % len(key)] for index, byte in enumerate(outer))
82+
document = json.loads(decoded.decode("utf-8"))
83+
84+
self.assertEqual(2, document["v"])
85+
self.assertIn("p", document)
86+
self.assertNotIn("ABYSS_SECOND_LAYER_MARKER", decoded.decode("utf-8"))
87+
self.assertNotIn("CONST", decoded.decode("utf-8"))
88+
self.assertNotIn("JUMP", decoded.decode("utf-8"))
89+
6390
def test_obfuscator_abyss_function_can_call_preserved_global(self) -> None:
6491
source = """
6592
def helper(value):

tests/test_audit.py

Lines changed: 1 addition & 1 deletion

0 commit comments

Comments
 (0)