#!/usr/bin/env python3 """Trace the FIFA17 create-match publish boundary with hardware breakpoints. The tracer covers the client-local path after POST /match: response callback -> deserializer -> controller event 0x7546 -> FUT_CREATE_MATCH_DP 0x7563 FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the same native dispatcher. The generated GDB program uses only hardware-assisted execution breakpoints. It never writes client memory and never drives game input. match_transition_trace.py [pid] [--output PATH] match_transition_trace.py --print-script [pid] match_transition_trace.py --selftest """ from __future__ import annotations import argparse import glob import hashlib import os import shutil import sys CARDS_MODULE = "CardsDLL_Win64_retail.dll" PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c" RESPONSE_CALLBACK_RVA = 0x114D90 DESERIALIZE_SUCCESS_RVA = 0x118940 CREATE_MATCH_CONTROLLER_RVA = 0xBF950 PROVIDER_DISPATCH_RVA = 0x1A4CD0 CREATE_MATCH_CONTROLLER_EVENT = 0x7546 FUT_CREATE_MATCH_DP = 0x7563 FUT_GET_MATCH_KITS_DP = 0x7565 def find_pid() -> int | None: found = [] for directory in glob.glob("/proc/[0-9]*"): try: with open(os.path.join(directory, "comm"), encoding="utf-8") as handle: if handle.read().strip() != "FIFA17.exe": continue pid = int(os.path.basename(directory)) with open(os.path.join(directory, "statm"), encoding="utf-8") as handle: resident_pages = int(handle.read().split()[1]) found.append((resident_pages, pid)) except (OSError, ValueError, IndexError): continue return max(found)[1] if found else None def parse_cards_mapping(lines) -> tuple[int, str]: for line in lines: fields = line.split(maxsplit=5) path = fields[5].rstrip() if len(fields) == 6 else "" if not path.endswith(CARDS_MODULE): continue start = int(fields[0].split("-", 1)[0], 16) return start, path raise RuntimeError(f"{CARDS_MODULE} is not mapped") def cards_mapping(pid: int) -> tuple[int, str]: with open(f"/proc/{pid}/maps", encoding="utf-8") as handle: try: return parse_cards_mapping(handle) except RuntimeError as error: raise RuntimeError(f"{error} in PID {pid}") from error def sha256_file(path: str) -> str: digest = hashlib.sha256() with open(path, "rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def validate_cards(path: str) -> None: actual = sha256_file(path) if actual != PINNED_CARDS_SHA256: raise RuntimeError( f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}" ) def trace_addresses(base: int) -> dict[str, int]: return { "response": base + RESPONSE_CALLBACK_RVA, "deserialize": base + DESERIALIZE_SUCCESS_RVA, "controller": base + CREATE_MATCH_CONTROLLER_RVA, "provider": base + PROVIDER_DISPATCH_RVA, } def build_gdb_script(pid: int, base: int, output: str) -> str: if any(character in output for character in "\n\r"): raise ValueError("output path cannot contain a newline") address = trace_addresses(base) return f"""set pagination off set confirm off set print thread-events off set breakpoint always-inserted on set logging file {output} set logging overwrite on set logging redirect off set logging enabled on handle SIGSEGV nostop noprint pass handle SIGILL nostop noprint pass handle SIGFPE nostop noprint pass handle SIGPIPE nostop noprint pass handle SIGALRM nostop noprint pass handle SIGUSR1 nostop noprint pass handle SIGUSR2 nostop noprint pass attach {pid} hbreak *0x{address['response']:x} commands silent python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ") if $rdx != 0 printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp else printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp end continue end hbreak *0x{address['deserialize']:x} commands silent python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ") printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp continue end hbreak *0x{address['controller']:x} condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x} commands silent python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ") printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp continue end hbreak *0x{address['provider']:x} condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x} commands silent python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ") printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp continue end printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n" continue """ def selftest() -> None: base = 0x180000000 address = trace_addresses(base) assert address == { "response": 0x180114D90, "deserialize": 0x180118940, "controller": 0x1800BF950, "provider": 0x1801A4CD0, } mapping = parse_cards_mapping( [ "6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 " "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n" ] ) assert mapping == ( 0x6FFFFC0F0000, "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll", ) script = build_gdb_script(25718, base, "/tmp/match-transition.log") assert script.count("hbreak *") == 4 assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script assert "T3_RESPONSE_CALLBACK" in script assert "T4_DESERIALIZE_SUCCESS" in script assert "T5_CREATE_MATCH_CONTROLLER" in script assert "T6_PROVIDER_DISPATCH" in script assert "set *(" not in script print("match_transition_trace selftest: PASS") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pid", nargs="?", type=int) parser.add_argument("--output") parser.add_argument("--print-script", action="store_true") parser.add_argument("--selftest", action="store_true") args = parser.parse_args() if args.selftest: selftest() return 0 pid = args.pid or find_pid() if not pid: print("FIFA17.exe not found", file=sys.stderr) return 2 try: base, cards_path = cards_mapping(pid) validate_cards(cards_path) output = args.output or f"/tmp/fifa17-match-transition-{pid}.log" script = build_gdb_script(pid, base, output) except (OSError, RuntimeError, ValueError) as error: print(error, file=sys.stderr) return 2 if args.print_script: print(script, end="") return 0 if not shutil.which("gdb"): print("gdb not found", file=sys.stderr) return 2 script_path = f"/tmp/fifa17-match-transition-{pid}.gdb" with open(script_path, "w", encoding="utf-8") as handle: handle.write(script) os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path]) return 127 if __name__ == "__main__": raise SystemExit(main())