Files
OpenFUT/fifa17-recon/tools/autopatch.py
T
funman300 b0d5e04bb9 fix(fifa17): guard missing store category resolution
Port the PROVEN empty-"My Packs" resolver crash-guard into the canonical
autopatch.py /proc-mem patcher. When no `mypacks` purchase group exists, a
fresh FIFA 17 client resolves category id -1; CardsDLL FUN_1800147f0 at RVA
0x14858 (`JNZ 0x14869`, bytes 75 0f) treats every non-zero category as
resolvable, calls FUN_180014420, gets NULL, and dereferences [NULL+0x48] at
0x180014882 (0xC0000005). Rewriting JNZ->JG (7f 0f) preserves positive-category
resolution (EDI>0) while routing zero/negative categories to the existing
Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.

- STORE_PATCHES_GUARDED table pins RVA 0x180014858 orig 75 0f -> patch 7f 0f.
- Applied every tick, fail-closed via guarded_action(): apply only when the
  live bytes are the known original; no-op when already patched; SKIP+log an
  unrecognised CardsDLL build (never blindly overwritten).
- Runtime watch loop moved under `if __name__ == "__main__"` so the module
  imports cleanly for unit testing; script behavior is unchanged. Existing
  ProtoSSL cert-gate and STORE_PATCHES enforcement are byte-identical (indent
  only).
- test_autopatch_guard.py: pure test covering PATCH/NOOP/SKIP and pinning the
  exact guarded RVA/bytes.

Proven on the tested build (CardsDLL 4706a881...) by a clean fresh-process
no-sentinel A/B (R1). Dormant while the backend active-sentinel is present.
See docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV.
2026-08-13 03:13:39 +00:00

149 lines
6.3 KiB
Python
Executable File

#!/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
}
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"
patched=set()
store_patched=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)
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}: store patch write failed: {e}")
time.sleep(1)