rust-deobf-premium 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # SPDX-FileCopyrightText: Ve (VAHPEM) <ve0912084386@gmail.com>
3
+ #
4
+ # Dual-licensed: AGPL-3.0-or-later OR a commercial license.
5
+ # Commercial licensing: Ve (VAHPEM) <ve0912084386@gmail.com>
6
+ """rust_deobf_premium - the installed form of rust-deobf's premium/ directory (Tier-4 modules).
7
+
8
+ Distributed separately as `rust-deobf-premium` (pip install "rust-deobf[premium]"), version-locked
9
+ to the `rust-deobf` core it depends on. The `rust-deobf` command runs these tools as their own
10
+ process (`rust-deobf opaque ...`); this file only matters when a program imports them as a library:
11
+ it puts the core package directory on sys.path (the premium modules import core modules such as
12
+ `recover_emulate` by bare name) and lets `import rust_deobf_premium.deobf_opaque` return the same
13
+ module object as the bare `deobf_opaque` the premium modules import among themselves.
14
+ Premium depends on the core, never the reverse (premium/README.md).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path as _Path
19
+
20
+ try:
21
+ from importlib.metadata import version as _version
22
+ __version__ = _version("rust-deobf-premium")
23
+ except Exception: # noqa: BLE001 - a clone imported as a package: no distribution metadata
24
+ __version__ = "0+unknown"
25
+
26
+ try:
27
+ import rust_deobf as _core
28
+ except ImportError: # a clone without the core installed: the scripts' own sys.path
29
+ _core = None # wiring (premium/ + ../scripts) is all that is needed there
30
+
31
+ if _core is not None and hasattr(_core, "register_package_dir"):
32
+ _core.register_package_dir(__name__, str(_Path(__file__).resolve().parent))
33
+ _core.ensure_package_dirs_on_path()
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ # SPDX-FileCopyrightText: Ve (VAHPEM) <ve0912084386@gmail.com>
4
+ #
5
+ # Dual-licensed: AGPL-3.0-or-later OR a commercial license.
6
+ # Commercial licensing: Ve (VAHPEM) <ve0912084386@gmail.com>
7
+ """deobf_cff.py - Tier 4: static detection of control-flow flattening (CFF).
8
+
9
+ CFF rewrites a function's straight-line/branching body into a `while(1){ switch(state) }`:
10
+ every original block becomes a switch case, blocks no longer fall through to each other
11
+ but instead set a state variable and jump back to a central dispatcher. The giveaway,
12
+ visible without a full CFG, is a dispatcher: one address that a large number of sites
13
+ jump to, reached through a ladder of `cmp <reg>, <imm32>` / conditional-jump on a single
14
+ state register, or an indexed jump table.
15
+
16
+ This is a TRIAGE detector -- it flags and locates likely-flattened regions and the
17
+ dispatcher so an analyst (or an SMT/emulation deflattener) can focus there. It does not
18
+ rebuild the original control flow. Observational: reads the binary, runs nothing.
19
+
20
+ python deobf_cff.py <binary> [--min-cases 12] [--json]
21
+ python deobf_cff.py --selftest
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import sys as _sys
26
+ from pathlib import Path as _Path
27
+ # premium module: find both premium/ (sibling premium modules) and the Apache core in scripts/
28
+ # ../scripts only in a source checkout (premium/ next to scripts/); installed, it would name
29
+ # site-packages/scripts -- not ours, and first on sys.path (VALIDATION §76). The installed core is
30
+ # put on the path by rust_deobf_premium/__init__.py (library) or PYTHONPATH (the CLI).
31
+ _h = _Path(__file__).resolve().parent
32
+ for _p in [str(_h)] + ([str(_h.parent / 'scripts')] if _h.name == 'premium' and (_h.parent / 'scripts' / 'recover_emulate.py').is_file() else []):
33
+ if _p not in _sys.path:
34
+ _sys.path.insert(0, _p)
35
+
36
+ import argparse, collections, json, sys
37
+ from pathlib import Path
38
+
39
+
40
+ def _load_text(path):
41
+ import lief
42
+ b = lief.parse(str(path))
43
+ if b is None:
44
+ raise ValueError("LIEF could not parse the file")
45
+ base = b.optional_header.imagebase if "PE" in b.format.name else 0
46
+ for s in b.sections:
47
+ if s.name == ".text" or (getattr(s, "characteristics", 0) and ".text" in s.name):
48
+ va = base + s.virtual_address
49
+ return bytes(s.content), va
50
+ # fall back to the first executable section
51
+ for s in b.sections:
52
+ if getattr(s, "virtual_address", 0):
53
+ return bytes(s.content), base + s.virtual_address
54
+ raise ValueError("no .text section")
55
+
56
+
57
+ def analyze(code, base, min_cases=12):
58
+ """Find dispatcher candidates: a target jumped-to from many sites, plus a nearby
59
+ cmp-imm ladder on one register. Returns a list of candidate dict regions."""
60
+ from capstone import (Cs, CS_ARCH_X86, CS_MODE_64, CS_OP_IMM, CS_OP_REG,
61
+ CS_GRP_JUMP)
62
+ md = Cs(CS_ARCH_X86, CS_MODE_64); md.detail = True
63
+ insns = list(md.disasm(code, base))
64
+
65
+ jump_targets = collections.Counter() # direct jmp/jcc target -> count
66
+ jump_sites = collections.defaultdict(list) # target -> [site addrs]
67
+ cmps = [] # (addr, reg, imm)
68
+ for ins in insns:
69
+ m = ins.mnemonic
70
+ if m.startswith("j"): # jmp/je/jne/...
71
+ imm = next((op.imm for op in ins.operands if op.type == CS_OP_IMM), None)
72
+ if imm is not None:
73
+ jump_targets[imm] += 1
74
+ jump_sites[imm].append(ins.address)
75
+ elif m == "cmp" and len(ins.operands) == 2 and ins.operands[0].type == CS_OP_REG \
76
+ and ins.operands[1].type == CS_OP_IMM:
77
+ cmps.append((ins.address, ins.reg_name(ins.operands[0].reg), ins.operands[1].imm))
78
+
79
+ def local_ladder(sites, reg_hint=None, window=0x600):
80
+ """Distinct cmp-immediates seen on a single register within `window` bytes of the
81
+ blocks that jump to this dispatcher -- i.e. the ladder actually feeding IT, not the
82
+ whole .text. Returns (best_reg, distinct_count)."""
83
+ lo, hi = min(sites) - window, max(sites) + window
84
+ by_reg = collections.defaultdict(set)
85
+ for addr, reg, imm in cmps:
86
+ if lo <= addr <= hi:
87
+ by_reg[reg].add(imm)
88
+ if not by_reg:
89
+ return None, 0
90
+ reg = max(by_reg, key=lambda r: len(by_reg[r]))
91
+ return reg, len(by_reg[reg])
92
+
93
+ cands = []
94
+ for tgt, deg in jump_targets.most_common(12):
95
+ if deg < min_cases:
96
+ continue
97
+ reg, distinct = local_ladder(jump_sites[tgt])
98
+ # CFF signature: a dispatcher with high in-degree AND a local state ladder of
99
+ # comparable size. A big in-degree with a SMALL local ladder is more likely a
100
+ # shared error/landing pad or a hot loop header, not flattening.
101
+ score = min(deg, distinct)
102
+ cands.append({
103
+ "dispatcher_va": hex(tgt), "in_degree": deg,
104
+ "state_reg": reg, "local_state_consts": distinct, "score": score,
105
+ "flattened_likely": score >= min_cases,
106
+ })
107
+ cands.sort(key=lambda c: c["score"], reverse=True)
108
+ # NOTE: a compiler-generated large `match`/switch or jump table (common in Rust/C++)
109
+ # produces the same shape; this flags candidates for review, it does not prove obfuscation.
110
+ return {"insns": len(insns), "candidates": cands}
111
+
112
+
113
+ def _selftest():
114
+ # assemble a tiny flattened-looking stub: a dispatcher at L, reached from many
115
+ # cmp eax,imm / je L sites. We emit raw bytes with keystone-free hand encoding via a
116
+ # known sequence: use `cmp eax, imm32` (3D xx xx xx xx) + `je rel32` targeting one addr,
117
+ # repeated, so one target dominates in-degree and eax has many distinct consts.
118
+ import struct
119
+ base = 0x1000
120
+ code = bytearray()
121
+ sites = []
122
+ N = 20
123
+ # first lay down the body, recording where each je lands (all to the same dispatcher)
124
+ for i in range(N):
125
+ code += b"\x3d" + struct.pack("<I", 0x1000 + i) # cmp eax, imm32
126
+ sites.append(len(code))
127
+ code += b"\x0f\x84\x00\x00\x00\x00" # je rel32 (patch later)
128
+ disp_off = len(code)
129
+ code += b"\x90" # dispatcher landing (nop)
130
+ # patch every je rel32 to point at disp_off
131
+ for s in sites:
132
+ rel = disp_off - (s + 6)
133
+ code[s + 2:s + 6] = struct.pack("<i", rel)
134
+ r = analyze(bytes(code), base, min_cases=12)
135
+ c = r["candidates"][0]
136
+ assert c["in_degree"] >= N, c
137
+ assert c["local_state_consts"] >= N, c
138
+ assert c["flattened_likely"], c
139
+ print(f"selftest OK: dispatcher @ {c['dispatcher_va']} in_degree={c['in_degree']} "
140
+ f"state_reg={c['state_reg']} local_consts={c['local_state_consts']}")
141
+
142
+
143
+ def main():
144
+ ap = argparse.ArgumentParser(description=__doc__,
145
+ formatter_class=argparse.RawDescriptionHelpFormatter)
146
+ ap.add_argument("binary", nargs="?")
147
+ ap.add_argument("--min-cases", type=int, default=12)
148
+ ap.add_argument("--json", action="store_true")
149
+ ap.add_argument("--selftest", action="store_true")
150
+ a = ap.parse_args()
151
+ if a.selftest:
152
+ _selftest(); return
153
+ if not a.binary:
154
+ ap.error("binary required or --selftest")
155
+ code, base = _load_text(Path(a.binary))
156
+ r = analyze(code, base, a.min_cases)
157
+ if a.json:
158
+ print(json.dumps(r, indent=2)); return
159
+ print(f"{Path(a.binary).name}: {r['insns']} insns disassembled")
160
+ flat = [c for c in r["candidates"] if c["flattened_likely"]]
161
+ if not flat:
162
+ print(f"no dispatcher signature above threshold (min-cases={a.min_cases}). "
163
+ "Top jump targets by in-degree:")
164
+ for c in r["candidates"][:3]:
165
+ print(f" {c['dispatcher_va']} in_degree={c['in_degree']} "
166
+ f"local_state_consts={c['local_state_consts']}")
167
+ return
168
+ print("dispatcher candidates for review (high in-degree + local state ladder):")
169
+ for c in flat:
170
+ print(f" {c['dispatcher_va']} score={c['score']} in_degree={c['in_degree']} "
171
+ f"state_reg={c['state_reg']} local_state_consts={c['local_state_consts']}")
172
+ print("\nnote: a large compiler `match`/switch or jump table has the same shape; "
173
+ "these are candidates to inspect, not proof of obfuscation.")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()