mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
165 lines
6.2 KiB
Python
165 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Check that obfuscating the September client cannot break a by-name lookup.
|
|
|
|
Obfuscar renames types and members, but it cannot see reflection. Anywhere this plugin hands a
|
|
*name* to a reflection API for one of its OWN types, that name has to survive the rename - hence
|
|
the SkipMethod rules in 2023Patch/RadicadePatch/obfuscar.xml.
|
|
|
|
This script enumerates the names that must survive (from the C# sources) and then checks each one
|
|
against Obfuscar's Mapping.txt to prove it was kept. Run after an obfuscation pass:
|
|
|
|
Tools/check-obfuscation-safety.py
|
|
|
|
Exit status is 0 only when every name was preserved.
|
|
"""
|
|
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
CLIENT = ROOT / "2023Patch" / "RadicadePatch"
|
|
SHARED = ROOT / "2023Patch" / "RadicadeEacBypass"
|
|
MAPPING = CLIENT / "bin" / "obfuscated" / "Mapping.txt"
|
|
|
|
SOURCES = [p for d in (CLIENT, SHARED) for p in d.rglob("*.cs") if "/obj/" not in p.as_posix() and "/bin/" not in p.as_posix()]
|
|
|
|
|
|
def source_texts():
|
|
for path in SOURCES:
|
|
yield path, path.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
|
def our_type_names():
|
|
"""Type names declared in the plugin and the shared folder (the ones Obfuscar can rename)."""
|
|
names = set()
|
|
pattern = re.compile(r"^\s*(?:public|internal|private|protected|static|sealed|abstract|partial|\s)*\b(?:class|struct|enum|interface|record)\s+([A-Za-z_]\w*)", re.M)
|
|
for _, text in source_texts():
|
|
names.update(pattern.findall(text))
|
|
return names
|
|
|
|
|
|
def names_resolved_at_runtime():
|
|
"""Every member name handed to reflection for one of our own types."""
|
|
ours = our_type_names()
|
|
found = {}
|
|
|
|
def add(name, where, why):
|
|
found.setdefault(name, (where, why))
|
|
|
|
for path, text in source_texts():
|
|
lines = text.splitlines()
|
|
for index, line in enumerate(lines, 1):
|
|
for match in re.finditer(r"nameof\(\s*([A-Za-z_][\w\.]*)\s*\)", line):
|
|
qualified = match.group(1)
|
|
owner = qualified.split(".")[0] if "." in qualified else None
|
|
member = qualified.split(".")[-1]
|
|
# nameof(SomeGameType.Member) is the game's own name and survives untouched.
|
|
if owner is not None and owner not in ours:
|
|
continue
|
|
add(member, f"{path.name}:{index}", f"nameof({qualified})")
|
|
|
|
# A literal handed to a reflection call on one of our own types.
|
|
for match in re.finditer(r"(?:typeof\(\s*([A-Za-z_]\w*)\s*\)|GetType\(\s*\))[^;\n]*?[\"']([A-Za-z_]\w*)[\"']", line):
|
|
owner, literal = match.group(1), match.group(2)
|
|
if owner in ours:
|
|
add(literal, f"{path.name}:{index}", f'"{literal}" on {owner}')
|
|
|
|
return found
|
|
|
|
|
|
def harmony_patch_methods():
|
|
"""Methods carrying a Harmony attribute.
|
|
|
|
Harmony resolves the special parameters __result/__instance/__state/__args/___field by NAME,
|
|
and Obfuscar nulls a parameter's name whenever it renames the declaring method (see
|
|
ShouldSkipParams). A renamed patch method therefore turns its own __result into P_0 and the
|
|
patch throws at Apply time - which aborts the rest of Plugin.Load, so later patches never arm.
|
|
Keeping the method name keeps the parameter names with it, so every one of these must be
|
|
pinned.
|
|
"""
|
|
attribute = re.compile(r"^\s*\[\s*Harmony")
|
|
declaration = re.compile(r"^\s*(?:[\w<>,\[\]\.\?]+\s+)+(?<![\w.])([A-Za-z_]\w*)\s*\(")
|
|
found = {}
|
|
for path, text in source_texts():
|
|
lines = text.splitlines()
|
|
for index, line in enumerate(lines):
|
|
match = declaration.match(line)
|
|
if not match or line.strip().startswith("["):
|
|
continue
|
|
name = match.group(1)
|
|
if name in ("if", "for", "while", "foreach", "switch", "catch", "using", "return", "lock"):
|
|
continue
|
|
above = []
|
|
cursor = index - 1
|
|
while cursor >= 0 and len(above) < 6:
|
|
stripped = lines[cursor].strip()
|
|
if not stripped:
|
|
cursor -= 1
|
|
continue
|
|
above.append(stripped)
|
|
if stripped.startswith("["):
|
|
cursor -= 1
|
|
continue
|
|
break
|
|
if any(attribute.match(a) for a in above):
|
|
found.setdefault(name, f"{path.name}:{index + 1}")
|
|
return found
|
|
|
|
|
|
def kept_by_obfuscar():
|
|
"""Members Obfuscar reported as skipped (i.e. not renamed)."""
|
|
kept = set()
|
|
for line in MAPPING.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if "skipped:" not in line:
|
|
continue
|
|
match = re.search(r"::([A-Za-z_]\w*)(?:\[|\()", line)
|
|
if match:
|
|
kept.add(match.group(1))
|
|
return kept
|
|
|
|
|
|
def renamed_by_obfuscar():
|
|
renamed = set()
|
|
for line in MAPPING.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if " -> " not in line or "::" not in line:
|
|
continue
|
|
match = re.search(r"::([A-Za-z_]\w*)(?:\[|\()", line)
|
|
if match:
|
|
renamed.add(match.group(1))
|
|
return renamed
|
|
|
|
|
|
def main():
|
|
if not MAPPING.exists():
|
|
print(f"no mapping at {MAPPING}; run obfuscar first", file=sys.stderr)
|
|
return 2
|
|
|
|
required = names_resolved_at_runtime()
|
|
patches = harmony_patch_methods()
|
|
for name, where in patches.items():
|
|
required.setdefault(name, (where, "Harmony patch method (keeps __parameters)"))
|
|
|
|
kept = kept_by_obfuscar()
|
|
renamed = renamed_by_obfuscar()
|
|
|
|
broken = sorted(n for n in required if n in renamed and n not in kept)
|
|
print(f"by-name lookups that must survive : {len(required)}")
|
|
print(f" of which Harmony patch methods : {len(patches)}")
|
|
print(f"members Obfuscar kept : {len(kept)}")
|
|
for name in sorted(required):
|
|
where, why = required[name]
|
|
status = "BROKEN" if name in broken else "kept"
|
|
print(f" [{status:6}] {name:36} {why:46} {where}")
|
|
|
|
if broken:
|
|
print(f"\nFAIL: {len(broken)} name(s) renamed but still looked up at runtime: {', '.join(broken)}")
|
|
return 1
|
|
|
|
print("\nOK: every by-name lookup points at a name Obfuscar preserved.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|