diff --git a/fifa17-recon/tools/atom_mapper_emu.py b/fifa17-recon/tools/atom_mapper_emu.py new file mode 100755 index 0000000..353a02b --- /dev/null +++ b/fifa17-recon/tools/atom_mapper_emu.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +"""Interpret FIFA 17's atom -> field-id dispatch functions instead of pattern-scanning them. + +WHY THIS EXISTS +--------------- +CardsDLL turns a JSON key into an "atom index" (a position in the string-pointer +table at .data 0x1802d2760), then a per-response-family mapper converts that index +into an internal field id with a chain of integer compares and jump tables. + +A previous attempt to recover each mapper's accepted atoms by scanning for +`sub ecx,K` / `cmp ecx,L` / `ja` patterns produced a confidently wrong answer: it +reported that no mapper accepts atom 424 (`manager`), while a live client plainly +holds a resident manager record. Pattern scanning cannot see control flow, so it +cannot tell which compares are actually reachable. + +This module executes the mappers instead. The modelled subset is exactly what these +functions use: the resolver call, integer cmp/sub/add/dec, conditional and computed +jumps, jump-table loads out of the image, lea, movsxd, and `mov eax,imm; ret`. +Anything outside that subset raises Unsupported, so a wrong field id is never +returned silently. + +TWO DECODER TRAPS THIS MODULE IS REQUIRED TO HANDLE +--------------------------------------------------- +1. ModRM rm==5 with mod!=0 is [rbp+disp], NOT RIP-relative. Only mod==0 with rm==5 + is RIP-relative. Treating all rm==5 as RIP-relative hides rbp-based DTO accesses. + Covered by test_rbp_relative_is_not_rip_relative. +2. A constant frequently arrives in a register (`mov r8d,0x4` ... later stored), so + searching for an immediate-to-memory store misses it. The interpreter tracks + register values, so propagated constants are followed. + Covered by test_constant_propagated_through_register. + +Run `--selftest` to execute the positive controls. Negative results from this tool +are only admissible when the selftest passes. +""" +from __future__ import annotations + +import argparse +import bisect +import struct +import sys +from pathlib import Path + +REGS = ("rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi", + "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15") + +ATOM_TABLE_BASE = 0x1802D2760 # validated against 6 known anchors, see anchors() +ATOM_RESOLVER = 0x180180D00 # key string -> atom index, returns in eax +ITEM_MAPPER = 0x18012FD40 # the DTO/item mapper: atom 568 'players' -> 1 + + +class Unsupported(Exception): + """The mapper used an instruction or address outside the modelled subset.""" + + +def s32(v: int) -> int: + v &= 0xFFFFFFFF + return v - 0x100000000 if v & 0x80000000 else v + + +class Image: + """A parsed PE, with VA<->file mapping and .pdata function bounds.""" + + def __init__(self, path: Path): + self.buf = path.read_bytes() + b = self.buf + pe = struct.unpack_from(" int: + o = self.va2off(va) + if o is None: + raise Unsupported(f"unmapped byte read 0x{va:x}") + return self.buf[o] + + def rd32(self, va: int) -> int: + o = self.va2off(va) + if o is None: + raise Unsupported(f"unmapped dword read 0x{va:x}") + return struct.unpack_from("= 0 and fs[i][0] <= va < fs[i][1]: + return fs[i] + return None + + def atom(self, index: int): + ptr = struct.unpack_from("> 6, modrm & 7 + dst = REGS[(((modrm >> 3) & 7) | ((rex & 4) << 1)) & 15] + n = 1 + if mod == 3: + return n, dst, None, REGS[(rm | ((rex & 1) << 3)) & 15] + base_v = idx_v = disp = 0 + if rm == 4: + sib = b[k + 1] + n += 1 + scale = 1 << (sib >> 6) + ir = ((sib >> 3) & 7) | ((rex & 2) << 2) + br = (sib & 7) | ((rex & 1) << 3) + if (ir & 15) != 4: + idx_v = r[REGS[ir & 15]] * scale + if (sib & 7) == 5 and mod == 0: + disp = struct.unpack_from(" bool: + a, b = last + sa, sb = s32(a), s32(b) + ua, ub = a & 0xFFFFFFFF, b & 0xFFFFFFFF + if cc == 0x4: return sa == sb + if cc == 0x5: return sa != sb + if cc == 0xF: return sa > sb + if cc == 0xD: return sa >= sb + if cc == 0xC: return sa < sb + if cc == 0xE: return sa <= sb + if cc == 0x7: return ua > ub + if cc == 0x3: return ua >= ub + if cc == 0x2: return ua < ub + if cc == 0x6: return ua <= ub + if cc == 0x8: return sa < sb + if cc == 0x9: return sa >= sb + raise Unsupported(f"condition code 0x{cc:x}") + + def run(self, start: int, atom: int, limit: int = 5000) -> int: + b = self.img.buf + r = {k: 0 for k in REGS} + last = (0, 0) + va = start + for _ in range(limit): + i0 = self.img.va2off(va) + if i0 is None: + raise Unsupported(f"pc unmapped 0x{va:x}") + j = i0 + while b[j] in (0x66, 0x67, 0xF2, 0xF3): + j += 1 + rex = 0 + if 0x40 <= b[j] <= 0x4F: + rex = b[j] + j += 1 + op = b[j] + pre = j - i0 + + if op == 0xC3: + return r["rax"] & 0xFFFFFFFF + if op == 0xCC: + raise Unsupported(f"int3 at 0x{va:x}: ran off the end of the function") + if op == 0xE8: + tgt = va + pre + 5 + struct.unpack_from("> 6 != 3: + raise Unsupported(f"{op:02x} memory form at 0x{va:x}") + reg = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15] + imm = struct.unpack_from("> 3) & 7 + cur = r[reg] & 0xFFFFFFFF + if ext == 7: + last = (cur, imm & 0xFFFFFFFF) + elif ext == 5: + r[reg] = (cur - imm) & 0xFFFFFFFF + last = (r[reg], 0) + elif ext == 0: + r[reg] = (cur + imm) & 0xFFFFFFFF + last = (r[reg], 0) + else: + raise Unsupported(f"{op:02x} /{ext} at 0x{va:x}") + va += pre + 2 + w + continue + if op == 0xFF and b[j + 1] >> 6 == 3: + ext = (b[j + 1] >> 3) & 7 + reg = REGS[((b[j + 1] & 7) | ((rex & 1) << 3)) & 15] + if ext == 1: + r[reg] = (r[reg] - 1) & 0xFFFFFFFF + last = (r[reg], 0) + va += pre + 2 + continue + if ext == 4: + va = r[reg] + continue + raise Unsupported(f"ff /{ext} at 0x{va:x}") + if op == 0x0F and b[j + 1] == 0xB6: + n, dst, addr, src = self._ea(j + 2, rex, r) + end = va + pre + 2 + n + if isinstance(addr, tuple): + addr = end + addr[1] + r[dst] = self.img.rd8(addr) if addr is not None else r[src] & 0xFF + va = end + continue + if op in (0x8B, 0x8D): + n, dst, addr, src = self._ea(j + 1, rex, r) + end = va + pre + 1 + n + if isinstance(addr, tuple): + addr = end + addr[1] + if op == 0x8D: + if addr is None: + raise Unsupported(f"lea with register operand at 0x{va:x}") + r[dst] = addr + else: + if addr is None: + # register form: mov r32, r32 (e.g. 8b c8 = mov ecx,eax) + r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF + else: + r[dst] = self.img.rd32(addr) + va = end + continue + if op == 0x89: + modrm = b[j + 1] + if modrm >> 6 != 3: + raise Unsupported(f"89 memory store at 0x{va:x}") + src = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15] + dst = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15] + r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF + va += pre + 2 + continue + if op == 0x63: + modrm = b[j + 1] + if modrm >> 6 != 3: + raise Unsupported(f"63 memory form at 0x{va:x}") + src = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15] + dst = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15] + r[dst] = s32(r[src]) & 0xFFFFFFFFFFFFFFFF + va += pre + 2 + continue + if op in (0x01, 0x03, 0x29, 0x2B, 0x39, 0x3B, + 0x09, 0x0B, 0x21, 0x23, 0x31, 0x33, 0x85): + modrm = b[j + 1] + if modrm >> 6 != 3: + raise Unsupported(f"{op:02x} memory form at 0x{va:x}") + a = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15] + c = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15] + m = 0xFFFFFFFFFFFFFFFF if rex & 8 else 0xFFFFFFFF + if op == 0x01: + r[a] = (r[a] + r[c]) & m + elif op == 0x03: + r[c] = (r[c] + r[a]) & m + elif op == 0x29: + r[a] = (r[a] - r[c]) & m + last = (r[a] & 0xFFFFFFFF, 0) + elif op == 0x2B: + r[c] = (r[c] - r[a]) & m + last = (r[c] & 0xFFFFFFFF, 0) + elif op in (0x09, 0x0B, 0x21, 0x23, 0x31, 0x33): + fn = {0x09: lambda x, y: x | y, 0x0B: lambda x, y: x | y, + 0x21: lambda x, y: x & y, 0x23: lambda x, y: x & y, + 0x31: lambda x, y: x ^ y, 0x33: lambda x, y: x ^ y}[op] + if op in (0x09, 0x21, 0x31): + r[a] = fn(r[a], r[c]) & m + last = (r[a] & 0xFFFFFFFF, 0) + else: + r[c] = fn(r[c], r[a]) & m + last = (r[c] & 0xFFFFFFFF, 0) + elif op == 0x85: + last = ((r[a] & r[c]) & 0xFFFFFFFF, 0) + elif op == 0x39: + last = (r[a] & 0xFFFFFFFF, r[c] & 0xFFFFFFFF) + else: + last = (r[c] & 0xFFFFFFFF, r[a] & 0xFFFFFFFF) + va += pre + 2 + continue + if op == 0x90: + va += pre + 1 + continue + if op == 0x0F and b[j + 1] == 0x1F: + n, _d, _a, _s = self._ea(j + 2, rex, r) + va += pre + 2 + n + continue + raise Unsupported(f"opcode {op:02x} at 0x{va:x}") + raise Unsupported("instruction limit reached") + + +def find_mappers(img: Image, resolver: int = ATOM_RESOLVER): + """Every function containing a direct call to the atom resolver.""" + sec = next(s for s in img.sections if s[0] == ".text") + _n, tva, _vsz, traw, trsz = sec + out = {} + for i in range(traw, traw + trsz - 5): + if img.buf[i] != 0xE8: + continue + va = img.base + tva + (i - traw) + if va + 5 + struct.unpack_from(" list: + """The atom table base must reproduce known anchors, or every index is wrong.""" + anchors = {11: "actives", 363: "itemData", 376: "kicktakers", + 424: "manager", 568: "players", 718: "squadActives"} + fails = [] + for idx, want in anchors.items(): + got = img.atom(idx) + if got != want: + fails.append(f"atom[{idx}] = {got!r}, expected {want!r}") + return fails + + +def test_rbp_relative_is_not_rip_relative(img: Image) -> list: + """TRAP 1. mod!=0 with rm==5 must resolve as [rbp+disp], not RIP-relative. + + Encoding under test: 8b 4d 20 == mov ecx,[rbp+0x20] (mod=01, rm=101). + A decoder that treats rm==5 as RIP-relative computes a wildly different + address and silently reads the wrong memory. + """ + m = Mapper(img) + r = {k: 0 for k in REGS} + r["rbp"] = 0x140000000 + saved = img.buf + try: + img.buf = bytes.fromhex("8b4d20") + n, dst, addr, _src = m._ea(1, 0, r) + finally: + img.buf = saved + fails = [] + if isinstance(addr, tuple): + fails.append("mod=01 rm=101 decoded as RIP-relative; must be [rbp+disp]") + elif addr != 0x140000020: + fails.append(f"[rbp+0x20] resolved to 0x{addr:x}, expected 0x140000020") + if dst != "rcx": + fails.append(f"destination decoded as {dst}, expected rcx") + if n != 2: + fails.append(f"modrm+disp8 consumed {n} bytes, expected 2") + return fails + + +def test_constant_propagated_through_register(img: Image) -> list: + """TRAP 2. A constant reaching a use through a register must be followed. + + Program: mov eax,0; mov r8d,4; mov eax,r8d; ret -> must yield 4, which is + only observable if register values propagate. Scanning for an immediate + store would see nothing. + """ + m = Mapper(img) + saved = img.buf + prog = bytes.fromhex("b800000000" "41b804000000" "4489c0" "c3") + try: + img.buf = prog + img_va2off = img.va2off + img.va2off = lambda va: va if 0 <= va < len(prog) else None + got = m.run(0, 0) + finally: + img.buf = saved + img.va2off = img_va2off + return [] if got == 4 else [f"register-propagated constant yielded {got}, expected 4"] + + +def test_item_mapper_controls(img: Image) -> list: + """Live/disassembly-verified behaviour of the item mapper.""" + m = Mapper(img) + fails = [] + got = m.run(ITEM_MAPPER, 568) + if got != 1: + fails.append(f"item mapper atom 568 'players' -> {got}, expected 1") + got = m.run(ITEM_MAPPER, 11) + if got != 0: + fails.append(f"item mapper atom 11 'actives' -> {got}, expected 0") + return fails + + +def test_manager_424_is_accepted_somewhere(img: Image) -> list: + """MANDATORY control. A live client holds a resident manager record, so some + mapper must map atom 424 to a non-zero field id. The previous pattern-scan + method failed exactly here, and any replacement must not.""" + m = Mapper(img) + accepting = [] + for start in find_mappers(img): + try: + if m.run(start, 424): + accepting.append(start) + except Unsupported: + continue + if not accepting: + return ["no mapper maps atom 424 'manager' to a non-zero field id, " + "which contradicts the live resident manager record"] + return [] + + +def selftest(img: Image) -> int: + checks = [ + ("atom table anchors", test_atom_anchors), + ("trap 1: rbp-relative modrm", test_rbp_relative_is_not_rip_relative), + ("trap 2: constant via register", test_constant_propagated_through_register), + ("item mapper positive controls", test_item_mapper_controls), + ("mandatory: manager atom 424 accepted", test_manager_424_is_accepted_somewhere), + ] + bad = 0 + for name, fn in checks: + try: + fails = fn(img) + except Exception as exc: # noqa: BLE001 - report, don't mask + fails = [f"raised {type(exc).__name__}: {exc}"] + if fails: + bad += 1 + print(f" FAIL {name}") + for f in fails: + print(f" {f}") + else: + print(f" ok {name}") + print("\n ALL PASS" if not bad else f"\n {bad} CHECK(S) FAILED - negative results are NOT admissible") + return 1 if bad else 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("image", type=Path, help="CardsDLL_Win64_retail.dll") + ap.add_argument("--selftest", action="store_true") + ap.add_argument("--atom", type=int, action="append", default=[], + help="atom index to resolve through every mapper") + ap.add_argument("--name", action="append", default=[], + help="atom name to resolve through every mapper") + args = ap.parse_args() + img = Image(args.image) + + if args.selftest: + return selftest(img) + + atoms = list(args.atom) + for nm in args.name: + idx = img.atom_index(nm) + if idx is None: + print(f" atom {nm!r} not found in the table") + return 2 + atoms.append(idx) + if not atoms: + ap.error("give --atom/--name, or --selftest") + + m = Mapper(img) + mappers = find_mappers(img) + print(f" {len(mappers)} mapper function(s) found\n") + for a in atoms: + print(f" === atom {a} ({img.atom(a)!r}) ===") + rows, unsup = [], 0 + for start in sorted(mappers): + try: + fid = m.run(start, a) + except Unsupported: + unsup += 1 + continue + if fid: + rows.append((start, fid)) + for start, fid in rows: + print(f" mapper 0x{start:x} -> field id {fid} (0x{fid:x})") + print(f" {len(rows)} mapper(s) accept it; {unsup} not modelled\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/live/probe_hunt.py b/fifa17-recon/tools/live/probe_hunt.py new file mode 100755 index 0000000..7a365bf --- /dev/null +++ b/fifa17-recon/tools/live/probe_hunt.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Hunt for specific wire instance ids anywhere in the client's writable memory. + +Answers whether a served item was materialised into a record at all, versus +materialised but not attached to a collection. A record is recognised by its +established layout: id at +0x08, resourceId at +0x18, cardtype at +0x4c. + +Read-only. Never writes. + +usage: probe_hunt.py PID id [id ...] +""" +import re, struct, sys + +PID = int(sys.argv[1]) +IDS = [int(a) for a in sys.argv[2:]] +if not IDS: + sys.exit("give at least one wire id") +mem = open(f"/proc/{PID}/mem", "rb", buffering=0) + +regions = [] +for ln in open(f"/proc/{PID}/maps"): + m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", ln) + if not m: + continue + lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4).strip() + if "w" not in perms: + continue + if path.startswith("/") and not path.endswith(".dll") and not path.endswith(".exe"): + continue + regions.append((lo, hi, perms, path)) +total = sum(hi - lo for lo, hi, _, _ in regions) +print(f" {len(regions)} writable regions, {total/2**20:.0f} MiB to scan") + +needles = {struct.pack(" 1000 + hits[wid].append((va, rec, ct, sub, cat, res, looks)) + a += n +print(f" scanned {scanned/2**20:.0f} MiB\n") +for wid in IDS: + hs = hits[wid] + recs = [h for h in hs if h[6]] + print(f" id {wid}: {len(hs)} raw occurrence(s), {len(recs)} record-shaped") + for va, rec, ct, sub, cat, res, _ in recs[:6]: + print(f" record {rec:#x}: cardtype={ct} subtype={sub} category={cat} resourceId={res}") + if not recs: + print(" NOT MATERIALISED as a record anywhere in writable memory") diff --git a/fifa17-recon/tools/live/probe_ids.py b/fifa17-recon/tools/live/probe_ids.py new file mode 100755 index 0000000..d99b9d4 --- /dev/null +++ b/fifa17-recon/tools/live/probe_ids.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Identify every resident record by its wire instance id. + +Record layout established from known wire values: + +0x08 id (wire instance) +0x18 resourceId +0x1c/+0x20 assetId + +0x38 discardValue +0x4c cardtype +0x50 cardsubtypeid + +0x5c itemState +0x60 category +0x94 teamid + +0xb4 rating +0xba teamkittypetechid (u16) + +Walks the contiguous 0x180-stride pool around the manager slot record so records +that are resident but not in any collection are still seen. Read-only. + +usage: probe_ids.py PID [expected_id ...] +""" +import re, struct, sys + +PID = int(sys.argv[1]) +WANT = {int(a) for a in sys.argv[2:]} +mem = open(f"/proc/{PID}/mem", "rb", buffering=0) + +def rd(a, n): + mem.seek(a); return mem.read(n) +def q(a): + return struct.unpack("3} {'addr':>12} {'id':>10} {'resource':>9} {'ct':>3} {'sub':>4} " + f"{'st':>3} {'cat':>4} {'team':>5} {'rate':>5} {'kt':>6}") +found = {} +k = 0 +addr = lo +while k < 48: + try: + d = dec(addr) + except OSError: + break + if d["id"] == 0 and d["ct"] == 0: + break + tag = "" + if d["ct"] == 7: + tag = " <== CARDTYPE 7" + if d["id"] in WANT: + tag += " <== WANTED" + found[d["id"]] = addr + slot = " [manager slot]" if addr == mgr_rec else "" + print(f" {k:>3} {addr:#12x} {d['id']:>10} {d['res']:>9} {d['ct']:>3} {d['sub']:>4} " + f"{d['st']:>3} {d['cat']:>4} {d['team']:>5} {d['rating']:>5} {d['kt']:>6}{tag}{slot}") + addr += RECSZ + k += 1 + +if WANT: + print(f"\n wanted ids: {sorted(WANT)}") + for w in sorted(WANT): + print(f" {w}: {'FOUND at ' + hex(found[w]) if w in found else 'NOT RESIDENT'}") diff --git a/fifa17-recon/tools/live/probe_layout2.py b/fifa17-recon/tools/live/probe_layout2.py new file mode 100755 index 0000000..9aa50b8 --- /dev/null +++ b/fifa17-recon/tools/live/probe_layout2.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Map FIFA 17 resident record offsets using UNIQUE wire values as ground truth. + +v2: identifies each record by its wire instance id (large, unique) and only +accepts a field mapping when the value is distinctive (>= 16) and the same +offset holds the right value for EVERY identified record. This avoids the v1 +failure where cardsubtypeid == 0 matched every zeroed field in the struct. + +Read-only. Never writes. + +usage: probe_layout2.py PID squad_active.json +""" +import re, struct, sys, json, collections + +PID = int(sys.argv[1]) +SQUAD = json.load(open(sys.argv[2])) +mem = open(f"/proc/{PID}/mem", "rb", buffering=0) + +def rd(a, n): + mem.seek(a); return mem.read(n) +def q(a): + return struct.unpack("= 5 and ok == total and len(distinct) >= 2: + consistent.setdefault(off, []).append((f, total, len(distinct))) + +print(f"\n === offsets consistently holding a distinctive wire field ===") +for off in sorted(consistent): + for f, total, nd in consistent[off]: + print(f" +0x{off:<4x} {f:14s} (matched {total}/{total} records, {nd} distinct values)") + +# --- dump the manager and the three club staff for comparison --- +print(f"\n === cardtype-2 slot (manager) ===") +h = q(mgr + 0xc0 + 0x10) +if h: + r = rd(h, RECSZ) + for off in sorted(consistent): + f = consistent[off][0][0] + print(f" +0x{off:<4x} {f:14s} = {struct.unpack_from(' 5000: + continue + seen.add(n) + try: + h = rd(n, 0x30) + except OSError: + continue + if len(h) < 0x30: + continue + nodes.append(n) + stack.append(struct.unpack_from("11} {'record':>12} {'id':>10} {'resource':>10} {'ct':>3} {'sub':>4} {'st':>4} {'cat':>4}") +hist = collections.Counter(); found = {} +rows = [] +for n in nodes: + key = q(n + 0x20) + rec = n + 0x28 + try: r = rd(rec, 0x180) + except OSError: continue + if len(r) < 0x180: continue + g = lambda o: struct.unpack_from("11} {rec:#12x} {rid:>10} {res:>10} {ct:>3} {sub:>4} {st:>4} {cat:>4}{tag}") +print(f"\n cardtype histogram: {dict(sorted(hist.items()))} total={sum(hist.values())}") +for w in sorted(WANT): + print(f" id {w}: {'RESIDENT' if w in found else 'ABSENT'}")