diff --git a/fifa17-recon/tools/autopatch.py b/fifa17-recon/tools/autopatch.py index 49dcf62..28dc036 100755 --- a/fifa17-recon/tools/autopatch.py +++ b/fifa17-recon/tools/autopatch.py @@ -24,6 +24,30 @@ STORE_PATCHES = { 0x1800175aa: NOP2, } +# Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the +# tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and +# docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md). +# +# When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL +# FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero +# category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the +# [NULL+0x48] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves +# positive-category resolution (EDI>0 branch) while routing zero/negative categories through +# the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs. +# +# CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid +# ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent) +# DURING one running FIFA process -- that reproduced the same crash in the confounded run F3. +# The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE +# invariant in the client-fix plan). +# +# Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f); +# already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly +# overwritten), so an unrecognised CardsDLL build is not patched. +STORE_PATCHES_GUARDED = { + 0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869 +} + LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log") def log(m): @@ -52,48 +76,73 @@ def wr(pid,va,b): with open(f'/proc/{pid}/mem','r+b') as f: f.seek(va); f.write(b) +def guarded_action(cur, orig, patch): + """Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED). + + Returns "noop" when the live bytes are already patched, "patch" when they are the + known original (safe to apply), or "skip" for anything else -- an unrecognised + CardsDLL build that must never be blindly overwritten. + """ + if cur == patch: + return "noop" + if cur == orig: + return "patch" + return "skip" + patched=set() store_patched=set() -launcher_pid = None -if "--launcher-pid" in sys.argv: - try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1]) - except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid") +if __name__ == "__main__": + launcher_pid = None + if "--launcher-pid" in sys.argv: + try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1]) + except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid") -log("=== AUTOPATCH watching for FIFA17.exe ===") -while True: - if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"): - log(f"launcher pid {launcher_pid} exited; stopping autopatch") - break - for pid in find_pids(): - if pid not in patched: - try: - g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6) - except Exception: - continue # code not mapped yet - if g2==GATE2_PATCH and g1==GATE1_PATCH: - log(f"pid {pid}: cert gates already patched"); patched.add(pid) - elif g2==GATE2_ORIG and g1==GATE1_ORIG: + log("=== AUTOPATCH watching for FIFA17.exe ===") + while True: + if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"): + log(f"launcher pid {launcher_pid} exited; stopping autopatch") + break + for pid in find_pids(): + if pid not in patched: try: - wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH) - log(f"pid {pid}: PATCHED cert gates") - patched.add(pid) + g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6) + except Exception: + continue # code not mapped yet + if g2==GATE2_PATCH and g1==GATE1_PATCH: + log(f"pid {pid}: cert gates already patched"); patched.add(pid) + elif g2==GATE2_ORIG and g1==GATE1_ORIG: + try: + wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH) + log(f"pid {pid}: PATCHED cert gates") + patched.add(pid) + except Exception as e: + log(f"pid {pid}: cert patch write failed: {e}") + + # Continuously enforce store patches every tick + cbase = cardsdll_base(pid) + if cbase is not None: + try: + for va, data in STORE_PATCHES.items(): + live = cbase + (va - IMG_BASE) + if rd(pid, live, len(data)) != data: + wr(pid, live, data) + log(f"pid {pid}: ENFORCED store patch @ {live:#x}") + for va, (orig, patch) in STORE_PATCHES_GUARDED.items(): + live = cbase + (va - IMG_BASE) + cur = rd(pid, live, len(patch)) + action = guarded_action(cur, orig, patch) + if action == "noop": + continue + if action == "patch": + wr(pid, live, patch) + log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)") + else: + log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)") + if pid not in store_patched: + log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}") + store_patched.add(pid) except Exception as e: - log(f"pid {pid}: cert patch write failed: {e}") + log(f"pid {pid}: store patch write failed: {e}") - # Continuously enforce store patches every tick - cbase = cardsdll_base(pid) - if cbase is not None: - try: - for va, data in STORE_PATCHES.items(): - live = cbase + (va - IMG_BASE) - if rd(pid, live, len(data)) != data: - wr(pid, live, data) - log(f"pid {pid}: ENFORCED store patch @ {live:#x}") - if pid not in store_patched: - log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}") - store_patched.add(pid) - except Exception as e: - log(f"pid {pid}: store patch write failed: {e}") - - time.sleep(1) + time.sleep(1) diff --git a/fifa17-recon/tools/test_autopatch_guard.py b/fifa17-recon/tools/test_autopatch_guard.py new file mode 100755 index 0000000..e6c1932 --- /dev/null +++ b/fifa17-recon/tools/test_autopatch_guard.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Pure unit test for the empty-My-Packs store resolver guard in autopatch.py. + +Covers the fail-closed guard decision (original -> PATCH, already-patched -> NOOP, +unknown -> SKIP) and pins the guarded patch table to the exact RVA/bytes proven on +the tested FIFA 17 build (JNZ 0x14869 -> JG 0x14869 at CardsDLL RVA 0x14858). + +Run: python3 test_autopatch_guard.py +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import autopatch # importable: runtime loop is guarded by `if __name__ == "__main__"` + +GUARD_VA = 0x180014858 +ORIG = bytes.fromhex("750f") # JNZ 0x14869 +PATCH = bytes.fromhex("7f0f") # JG 0x14869 + + +def test_table_exact(): + assert autopatch.STORE_PATCHES_GUARDED == {GUARD_VA: (ORIG, PATCH)}, \ + autopatch.STORE_PATCHES_GUARDED + # Byte-level pin so a bad hex literal cannot slip through. + assert ORIG == b"\x75\x0f" and PATCH == b"\x7f\x0f" + + +def test_decision(): + assert autopatch.guarded_action(ORIG, ORIG, PATCH) == "patch" # apply + assert autopatch.guarded_action(PATCH, ORIG, PATCH) == "noop" # already patched + assert autopatch.guarded_action(b"\x00\x00", ORIG, PATCH) == "skip" # build mismatch + assert autopatch.guarded_action(b"\x90", ORIG, PATCH) == "skip" # wrong length + + +if __name__ == "__main__": + test_table_exact() + test_decision() + print("OK: autopatch guard table + fail-closed decision (PATCH/NOOP/SKIP)")