diff --git a/fifa17-recon/tools/match_advance_trace.py b/fifa17-recon/tools/match_advance_trace.py new file mode 100755 index 0000000..7aa2210 --- /dev/null +++ b/fifa17-recon/tools/match_advance_trace.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Trace FIFA17 ACTION_ADVANCE dispatch after MATCH_CREATED delivery. + +Stage one (`match_transition_trace.py`) proves the HTTP response, deserializer, +and FUT_CREATE_MATCH_DP delivery. This stage uses that provider callback to +capture the screen key, then records every nested global UI dispatch for that +same screen until the positive-control FUT_GET_MATCH_KITS_DP arrives. It also +captures the low-level create event and final native-to-UI provider bridge. + +The generated GDB program uses hardware-assisted execution breakpoints only. +It never writes client memory and never drives game input. + + match_advance_trace.py [pid] [--output PATH] + match_advance_trace.py --print-script [pid] + match_advance_trace.py --selftest +""" +from __future__ import annotations + +import argparse +import hashlib +import os +from pathlib import Path +import shutil +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import match_transition_trace as transition + +FIFA_MODULE = "FIFA17.exe" +PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899" +GLOBAL_UI_DISPATCH_RVA = 0x80D1070 +CREATE_MATCH_CONTROLLER_RVA = 0xBF950 +PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41 + + +def module_mapping(pid: int, module: str) -> tuple[int, str]: + with open(f"/proc/{pid}/maps", encoding="utf-8") as handle: + for line in handle: + fields = line.split(maxsplit=5) + path = fields[5].rstrip() if len(fields) == 6 else "" + if not path.endswith(module): + continue + return int(fields[0].split("-", 1)[0], 16), path + raise RuntimeError(f"{module} is not mapped in PID {pid}") + + +def validate_file(path: str, expected: str, label: str) -> None: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + actual = digest.hexdigest() + if actual != expected: + raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}") + + +def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]: + return { + "provider": cards_base + transition.PROVIDER_DISPATCH_RVA, + "global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA, + "controller": cards_base + CREATE_MATCH_CONTROLLER_RVA, + "bridge": cards_base + PROVIDER_BRIDGE_CALL_RVA, + } + + +def build_gdb_script(pid: int, cards_base: int, fifa_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(cards_base, fifa_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} +set $screen_key = 0 +set $target_seen = 0 + +hbreak *0x{address['provider']:x} +condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x} +commands + silent + if $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} + set $screen_key = $r8 + set $target_seen = 1 + end + python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d provider=%#x screen_key=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp + continue +end + +hbreak *0x{address['global_dispatch']:x} +condition 2 $target_seen != 0 && $r8 == $screen_key +commands + silent + python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d id=%#x screen_key=%p registry=%p caller=%p\\n", $_thread, $rdx, $r8, $rcx, *(void**)$rsp + continue +end + +hbreak *0x{address['controller']:x} +condition 3 $edx == 0x7546 +commands + silent + python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp + continue +end + +hbreak *0x{address['bridge']:x} +condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x} +commands + silent + python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ") + printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48) + continue +end + +printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n" +continue +""" + + +def selftest() -> None: + address = trace_addresses(0x180000000, 0x140000000) + assert address == { + "provider": 0x1801A4CD0, + "global_dispatch": 0x1480D1070, + "controller": 0x1800BF950, + "bridge": 0x1801A4D41, + } + script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log") + assert script.count("hbreak *") == 4 + assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script + assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script + assert "$r8 == $screen_key" in script + assert "CREATE_MATCH_CONTROLLER" in script + assert "PROVIDER_BRIDGE" in script + assert "set *(" not in script + print("match_advance_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 transition.find_pid() + if not pid: + print("FIFA17.exe not found", file=sys.stderr) + return 2 + try: + cards_base, cards_path = transition.cards_mapping(pid) + transition.validate_cards(cards_path) + fifa_base, fifa_path = module_mapping(pid, FIFA_MODULE) + validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE) + output = args.output or f"/tmp/fifa17-match-advance-{pid}.log" + script = build_gdb_script(pid, cards_base, fifa_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-advance-{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()) diff --git a/fifa17-recon/tools/match_transition_trace.py b/fifa17-recon/tools/match_transition_trace.py new file mode 100755 index 0000000..4cf2508 --- /dev/null +++ b/fifa17-recon/tools/match_transition_trace.py @@ -0,0 +1,233 @@ +#!/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())