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.
- rust_deobf_premium/__init__.py +33 -0
- rust_deobf_premium/deobf_cff.py +177 -0
- rust_deobf_premium/deobf_deflatten.py +682 -0
- rust_deobf_premium/deobf_expr.py +224 -0
- rust_deobf_premium/deobf_opaque.py +719 -0
- rust_deobf_premium/devirt_emulate.py +922 -0
- rust_deobf_premium/lift_vm.py +1178 -0
- rust_deobf_premium/opt_reverse.py +673 -0
- rust_deobf_premium-0.1.0.dist-info/METADATA +77 -0
- rust_deobf_premium-0.1.0.dist-info/RECORD +14 -0
- rust_deobf_premium-0.1.0.dist-info/WHEEL +5 -0
- rust_deobf_premium-0.1.0.dist-info/licenses/LICENSE +235 -0
- rust_deobf_premium-0.1.0.dist-info/licenses/LICENSE-COMMERCIAL.md +12 -0
- rust_deobf_premium-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|