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.
This commit is contained in:
@@ -24,6 +24,30 @@ STORE_PATCHES = {
|
|||||||
0x1800175aa: 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")
|
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
|
||||||
|
|
||||||
def log(m):
|
def log(m):
|
||||||
@@ -52,16 +76,30 @@ def wr(pid,va,b):
|
|||||||
with open(f'/proc/{pid}/mem','r+b') as f:
|
with open(f'/proc/{pid}/mem','r+b') as f:
|
||||||
f.seek(va); f.write(b)
|
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()
|
patched=set()
|
||||||
store_patched=set()
|
store_patched=set()
|
||||||
|
|
||||||
launcher_pid = None
|
if __name__ == "__main__":
|
||||||
if "--launcher-pid" in sys.argv:
|
launcher_pid = None
|
||||||
|
if "--launcher-pid" in sys.argv:
|
||||||
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||||
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
||||||
|
|
||||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||||
while True:
|
while True:
|
||||||
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
||||||
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
||||||
break
|
break
|
||||||
@@ -90,6 +128,17 @@ while True:
|
|||||||
if rd(pid, live, len(data)) != data:
|
if rd(pid, live, len(data)) != data:
|
||||||
wr(pid, live, data)
|
wr(pid, live, data)
|
||||||
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
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:
|
if pid not in store_patched:
|
||||||
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
||||||
store_patched.add(pid)
|
store_patched.add(pid)
|
||||||
|
|||||||
Executable
+38
@@ -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)")
|
||||||
Reference in New Issue
Block a user