diff --git a/fifa17-recon/tools/classify_calls.py b/fifa17-recon/tools/classify_calls.py new file mode 100755 index 0000000..6c73fe3 --- /dev/null +++ b/fifa17-recon/tools/classify_calls.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Classify call sites of the 130000/130001 provider stubs. + +A call whose result is COMPARED implements a predicate ("is this the FUT custom +club?"). Only a call whose result is STORED can assign a team id. This turns an +unreadable 81-site list into the handful that could actually introduce 130000 +into a struct. + + classify_calls.py [more_targets...] +""" +import re +import sys + +asm = sys.argv[1] +targets = [t.lower().lstrip("0x") for t in sys.argv[2:]] + +lines = [] +for l in open(asm, errors="replace"): + m = re.match(r"\s*([0-9a-f]+):\s+((?:[0-9a-f]{2} )+)\s*(.*)", l) + if m: + lines.append((int(m.group(1), 16), m.group(3).strip())) +idx = {a: i for i, (a, _t) in enumerate(lines)} + +STORE = re.compile(r"^mov\s+(?:DWORD PTR |QWORD PTR )?\[[^\]]+\],(eax|rax)\b") +CMP = re.compile(r"^(cmp|sub|test)\b.*\b(eax|rax)\b") +MOVREG = re.compile(r"^mov\s+(e[a-z]{2}|r\d+d|r[a-z]{2}),(eax|rax)\b") + +for tgt in targets: + print(f"\n ===== callers of 0x{tgt} =====") + stores, cmps, other = [], [], [] + for i, (a, txt) in enumerate(lines): + if not txt.startswith("call") or tgt not in txt: + continue + # look at the next few instructions for the fate of eax + window = [lines[j][1] for j in range(i + 1, min(i + 7, len(lines)))] + verdict, detail = "other", window[0] if window else "" + for w in window: + if STORE.match(w): + verdict, detail = "STORE", w + break + if CMP.match(w): + verdict, detail = "compare", w + break + if MOVREG.match(w): + verdict, detail = "movreg", w + break + rec = (a, detail) + (stores if verdict == "STORE" else cmps if verdict == "compare" else other).append(rec) + print(f" STORE (can assign) : {len(stores)}") + for a, d in stores: + print(f" 0x{a:x} {d}") + print(f" compare (predicate) : {len(cmps)}") + print(f" other/moved to reg : {len(other)}") + for a, d in other[:14]: + print(f" 0x{a:x} {d}") diff --git a/fifa17-recon/tools/immstore.py b/fifa17-recon/tools/immstore.py new file mode 100755 index 0000000..45a3de6 --- /dev/null +++ b/fifa17-recon/tools/immstore.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Find IMMEDIATE stores of a constant to a struct offset, in a live module. + + immstore.py [disp_hex|any] [--exe] + +Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a +field; `89 /r` merely propagates one. Emits image VAs so they can be fed to +ldis.py. Read-only. +""" +import glob +import os +import re +import struct +import sys + +CARDS_IMG = 0x180000000 +EXE_IMG = 0x140000000 + + +def pid(): + for d in glob.glob("/proc/[0-9]*"): + try: + if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe": + return int(os.path.basename(d)) + except OSError: + pass + raise SystemExit("FIFA17.exe not running") + + +P = pid() + + +def module_base(n): + for l in open(f"/proc/{P}/maps"): + if n.lower() in l.lower(): + return int(l.split("-")[0], 16) + raise SystemExit(f"{n} not mapped") + + +def text_spans(base): + out = [] + started = False + for l in open(f"/proc/{P}/maps"): + m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l) + if not m: + continue + lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4) + if lo == base: + started = True + continue + if started: + if not path.strip() and "x" in perms: + out.append((lo, hi)) + elif out: + break + return out + + +args = [a for a in sys.argv[1:] if a != "--exe"] +exe = "--exe" in sys.argv +imm = int(args[0], 0) +want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16) +img = EXE_IMG if exe else CARDS_IMG +base = module_base("FIFA17.exe" if exe else "CardsDLL") + +mem = open(f"/proc/{P}/mem", "rb", 0) +immb = struct.pack("= 0: + modrm = buf[i + 1] if i + 1 < len(buf) else 0 + if (modrm & 0x38) == 0: # /0 + mod, rm = modrm >> 6, modrm & 7 + if mod == 1 and i + 7 <= len(buf): # disp8 + disp, ib = buf[i + 2], i + 3 + sz = 7 + elif mod == 2 and i + 10 <= len(buf): # disp32 + disp, ib = struct.unpack_from(" [nbytes] [--exe] disassemble + ldis.py --bytes [nbytes] hexdump + ldis.py --map show module bases + +CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000. +Live address = module_base + (image_va - img_base). Sections map 1:1 for both, +but this is recomputed and printed so the offset trap stays visible. +""" +import re +import subprocess +import sys +import tempfile + +PID = None +CARDS_IMG = 0x180000000 +EXE_IMG = 0x140000000 + + +def pid(): + global PID + if PID is None: + import glob, os + for d in glob.glob("/proc/[0-9]*"): + try: + if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe": + PID = int(os.path.basename(d)) + break + except OSError: + pass + if PID is None: + raise SystemExit("FIFA17.exe not running") + return PID + + +def module_base(needle): + """Base = the NAMED PE-header mapping for the module (Wine maps the rest + anonymously, so never trust the mapping that merely CONTAINS an address).""" + for l in open(f"/proc/{pid()}/maps"): + if needle.lower() in l.lower(): + return int(l.split("-")[0], 16) + raise SystemExit(f"module {needle} not mapped") + + +def live(va, exe=False): + if exe: + return module_base("FIFA17.exe") + (va - EXE_IMG) + return module_base("CardsDLL") + (va - CARDS_IMG) + + +def read(va, n, exe=False): + la = live(va, exe) + with open(f"/proc/{pid()}/mem", "rb", 0) as m: + m.seek(la) + return la, m.read(n) + + +def main(): + a = sys.argv[1:] + if not a or a[0] == "--map": + print(f" pid = {pid()}") + print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})") + print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})") + return + hexdump = a[0] == "--bytes" + if hexdump: + a = a[1:] + exe = "--exe" in a + a = [x for x in a if x != "--exe"] + va = int(a[0], 16) + n = int(a[1]) if len(a) > 1 else 160 + la, buf = read(va, n, exe) + print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)") + if hexdump: + for i in range(0, len(buf), 16): + c = buf[i:i + 16] + print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} " + + "".join(chr(b) if 32 <= b < 127 else "." for b in c)) + return + with tempfile.NamedTemporaryFile(suffix=".bin") as f: + f.write(buf) + f.flush() + out = subprocess.run( + ["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel", + f"--adjust-vma=0x{va:x}", f.name], + capture_output=True, text=True).stdout + for line in out.splitlines(): + if re.match(r"\s+[0-9a-f]+:", line): + print(" " + line.strip()) + + +main() diff --git a/fifa17-recon/tools/scan_mt.py b/fifa17-recon/tools/scan_mt.py new file mode 100755 index 0000000..a442ddc --- /dev/null +++ b/fifa17-recon/tools/scan_mt.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Scan for FIFA17 match-team records by the invariant header prefix. + +Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a +per-record marker whose value varies between sessions (-1 on 2026-08-24, +344065/344064 on 2026-08-25), and filtering on it produced a false negative. + + scan_mt.py [pid] +""" +import glob +import os +import re +import struct +import sys + +PAT = struct.pack("<5i", 11, 7, 0, 0, 76) + + +def find_pid(): + for d in glob.glob("/proc/[0-9]*"): + try: + if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe": + return int(os.path.basename(d)) + except OSError: + pass + return None + + +pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid() +if not pid: + print(" no FIFA17.exe") + raise SystemExit(2) + +mem = open(f"/proc/{pid}/mem", "rb", 0) +found = [] +for line in open(f"/proc/{pid}/maps"): + m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line) + if not m or m.group(3)[0] != "r": + continue + lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4) + if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024: + continue + try: + mem.seek(lo) + buf = mem.read(hi - lo) + except (OSError, ValueError, OverflowError): + continue + i = buf.find(PAT) + while i >= 0: + rec = buf[i:i + 0x80] + if len(rec) >= 0x80: + tid = struct.unpack_from(" [before] [after] +""" +import glob +import os +import re +import struct +import sys + +CARDS_IMG = 0x180000000 + + +def pid(): + for d in glob.glob("/proc/[0-9]*"): + try: + if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe": + return int(os.path.basename(d)) + except OSError: + pass + raise SystemExit("no FIFA17.exe") + + +P = pid() +BASE = [int(l.split("-")[0], 16) for l in open(f"/proc/{P}/maps") if "CardsDLL" in l][0] + + +def img2live(va): + return BASE + (va - CARDS_IMG) + + +def live2img(la): + return CARDS_IMG + (la - BASE) + + +slot = int(sys.argv[1], 16) +before = int(sys.argv[2]) if len(sys.argv) > 2 else 10 +after = int(sys.argv[3]) if len(sys.argv) > 3 else 10 + +mem = open(f"/proc/{P}/mem", "rb", 0) +start = slot - before * 8 +mem.seek(img2live(start)) +buf = mem.read((before + after) * 8) +print(f" vtable neighbourhood of image 0x{slot:x}") +target = None +for k in range(0, len(buf) - 7, 8): + a = start + k + p = struct.unpack_from(" " + + (f"image 0x{ivа:x}" if ivа else f"raw 0x{p:x}") + mark) + +# Find every other .rdata slot pointing at a DIFFERENT function but whose +# neighbours overlap this vtable -> sibling implementations of the same slot. +print("\n === sibling vtables: same neighbour, different slot function ===") +mem.seek(img2live(0x1801e5000)) +rdata = mem.read(0x28a000 - 0x1e5000) +# take the two neighbours around the slot as a signature +sig_prev = struct.unpack_from("= 0: + if i % 8 == 0: + here = 0x1801e5000 + i + # the slot in THIS vtable at the same relative position + off = i + (8 if name == "preceding" else -8) + if 0 <= off <= len(rdata) - 8: + fn = struct.unpack_from(" image 0x{fimg:x} DIFFERENT") + found += 1 + i = rdata.find(pat, i + 1) +print(f" {found} sibling implementation(s)") diff --git a/fifa17-recon/tools/xref.py b/fifa17-recon/tools/xref.py new file mode 100755 index 0000000..9a0c965 --- /dev/null +++ b/fifa17-recon/tools/xref.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Find references to an image VA inside a live module's .text/.rdata/.data. + + xref.py [--exe] + +Reports: + call rel32 (e8) / jmp rel32 (e9) -- direct callers + lea rip-rel (48 8d 0x) -- address-taken + absolute 8-byte pointer -- vtable / table slot + +Read-only. Section ranges are recomputed from /proc//maps every run. +""" +import glob +import os +import re +import struct +import sys + +CARDS_IMG = 0x180000000 +EXE_IMG = 0x140000000 + + +def pid(): + for d in glob.glob("/proc/[0-9]*"): + try: + if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe": + return int(os.path.basename(d)) + except OSError: + pass + raise SystemExit("FIFA17.exe not running") + + +P = pid() + + +def module_base(needle): + for l in open(f"/proc/{P}/maps"): + if needle.lower() in l.lower(): + return int(l.split("-")[0], 16) + raise SystemExit(f"{needle} not mapped") + + +def spans(base, limit=0x400000): + """Contiguous mappings belonging to this module, as (live_lo, live_hi, perms).""" + out = [] + for l in open(f"/proc/{P}/maps"): + m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l) + if not m: + continue + lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4) + if lo == base: + out.append((lo, hi, perms)) + continue + if out and lo == out[-1][1] and not path.strip(): + out.append((lo, hi, perms)) + elif out and lo > out[-1][1]: + break + return out + + +def main(): + a = [x for x in sys.argv[1:] if x != "--exe"] + exe = "--exe" in sys.argv + target = int(a[0], 16) + img = EXE_IMG if exe else CARDS_IMG + base = module_base("FIFA17.exe" if exe else "CardsDLL") + tgt_live = base + (target - img) + + mem = open(f"/proc/{P}/mem", "rb", 0) + print(f" pid={P} module_base=0x{base:x} target image 0x{target:x} live 0x{tgt_live:x}") + hits = 0 + for lo, hi, perms in spans(base): + try: + mem.seek(lo) + buf = mem.read(hi - lo) + except (OSError, ValueError): + continue + img_lo = img + (lo - base) + # rel32 call/jmp + for op, name in ((0xE8, "call"), (0xE9, "jmp ")): + i = buf.find(bytes([op])) + while i >= 0: + if i + 5 <= len(buf): + rel = struct.unpack_from("= 0: + if i + 7 <= len(buf): + modrm = buf[i + 2] + if (modrm & 0xC7) == 0x05: + rel = struct.unpack_from("= 0: + if i % 8 == 0: + print(f" abs ptr slot at image 0x{img_lo+i:x} [{perms}]") + hits += 1 + i = buf.find(pat, i + 1) + print(f" {hits} reference(s)") + + +main()