#!/usr/bin/env python3 """Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches.""" import glob, time, struct, sys # Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches import glob, time, os GATE2=0x1461361b0; GATE2_ORIG=bytes.fromhex("48895c"); GATE2_PATCH=bytes.fromhex("31c0c3") GATE1=0x146132548; GATE1_ORIG=bytes.fromhex("0f8576010000"); GATE1_PATCH=bytes.fromhex("90"*6) RET_TRUE = bytes.fromhex("b801000000c3") NOP2 = bytes.fromhex("9090") IMG_BASE = 0x180000000 STORE_PATCHES = { 0x1800f7fb0: RET_TRUE, 0x1800fb850: RET_TRUE, 0x180100500: RET_TRUE, 0x180013cf0: RET_TRUE, 0x180017543: bytes.fromhex("eb3f"), 0x180017487: NOP2, 0x180017490: NOP2, 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 } # Capability advertised to the launcher/backend once the resolver guard is VERIFIED # live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4). EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver" EMPTY_MYPACKS_RESOLVER_VERSION = 1 # The guarded site whose verified enforcement backs the capability above. RESOLVER_GUARD_VA = 0x180014858 # Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4). GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop) GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip) GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc//mem write raised GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log") def log(m): line=f"[{time.strftime('%H:%M:%S')}] {m}" print(line,flush=True); open(LOG,"a").write(line+"\n") def find_pids(): out=[] for d in glob.glob('/proc/[0-9]*'): try: if open(d+'/comm').read().strip()=='FIFA17.exe': out.append(int(d.split('/')[-1])) except: pass return out def cardsdll_base(pid): try: for line in open(f'/proc/{pid}/maps'): if 'CardsDLL' in line: return int(line.split('-')[0], 16) except: pass return None def rd(pid,va,n): with open(f'/proc/{pid}/mem','rb') as f: f.seek(va); return f.read(n) 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" def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after): """Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure). Mirrors guarded_action's decision, extended with post-write verification so the caller advertises the capability only on VERIFIED. No /proc access -- unit-testable. - cur_before == patch -> VERIFIED (already patched; guarded_action "noop") - cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the re-read is patch, else VERIFY_FAILED (guarded_action "patch") - otherwise -> UNSUPPORTED_BUILD (guarded_action "skip") """ if cur_before == patch: return GUARD_VERIFIED if cur_before == orig: if not wrote_ok: return GUARD_WRITE_FAILED if cur_after == patch: return GUARD_VERIFIED return GUARD_VERIFY_FAILED return GUARD_UNSUPPORTED_BUILD patched=set() store_patched=set() guard_reported=set() 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: 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) wrote_ok = True cur_after = cur if action == "patch": try: wr(pid, live, patch) log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)") except Exception as e: wrote_ok = False log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}") if wrote_ok: try: cur_after = rd(pid, live, len(patch)) except Exception: cur_after = b"" elif action == "skip": log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)") # action == "noop": already patched; nothing to write. if va == RESOLVER_GUARD_VA and pid not in guard_reported: state = guard_state_after(cur, orig, patch, wrote_ok, cur_after) if state == GUARD_VERIFIED: log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}") else: log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)") guard_reported.add(pid) 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)