From b25761ea319c09a24acb97256827c77dd75cd3e0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 13 Aug 2026 04:03:37 +0000 Subject: [PATCH] feat(fifa17): negotiate clean empty My Packs mode Backend side of the handshake: suppress the synthetic 65534 My-Packs sentinel ONLY for a session whose client has registered a verified resolver-guard capability. Additive; the P2 active-sentinel path is retained as the else-branch and the universal default. Fail-closed everywhere. - Per-client state keyed by source IP (client_address[0]; the only per-connection discriminator in this single-account, stateless backend): _FIFA17_STORE[ip] = {resolver, mode}; mode in {None, "sentinel", "clean-v1"}, guarded by a lock. - New POST /openfut/fifa17/capability endpoint: accepts only {"capability":"empty_mypacks_resolver","version":1,...}; unknown capability or version => 400 and records nothing (=> sentinel). - account_sync (the launcher's required per-launch call) resets the per-ip record => a new FIFA process starts unfrozen with no inherited capability. - Store topology is frozen at the FIRST /store/purchasegroup per session: clean-v1 iff a v1 capability is registered, else sentinel; immutable thereafter (late capability logged + ignored this session; a disappeared capability does not un-freeze a clean session). This enforces the SESSION-STABLE invariant. - store_catalog zero-owned-packs branch: clean-v1 emits NO mypacks group (the client guard routes category -1 to Browse); every other case emits the existing active 65534 sentinel verbatim. PACK_CATALOG / pack 70 / normal packs / profile untouched. FIFA-17 only; not lifted into game-independent Core. - Tests: full matrix A-J incl. concurrency isolation (two IPs, no global leak) and no cross-process capability leak. Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md. --- .../tools/test_capability_negotiation.py | 229 ++++++++++++++++++ fifa17-recon/tools/utas_server.py | 183 +++++++++++--- 2 files changed, 374 insertions(+), 38 deletions(-) create mode 100755 fifa17-recon/tools/test_capability_negotiation.py diff --git a/fifa17-recon/tools/test_capability_negotiation.py b/fifa17-recon/tools/test_capability_negotiation.py new file mode 100755 index 0000000..82c2520 --- /dev/null +++ b/fifa17-recon/tools/test_capability_negotiation.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Tests for the FIFA 17 verified-patched-client capability negotiation. + +Pins the additive empty-My-Packs switch built on top of the P2 65534 sentinel: +the sentinel is suppressed for a session ONLY when the launcher has registered a +verified resolver capability (v1) for the CURRENT FIFA process, bound to the peer +IP, and the decision is frozen at the first /store/purchasegroup. Every failure / +unknown / late / cross-process case is fail-closed to the active sentinel. + +Covers matrix A-J from docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (Task 12/15): + A no-capability, zero packs -> sentinel + B verified v1, zero packs -> clean (no 65534) + C real unopened pack + no capability -> genuine pack, no sentinel + D real unopened pack + capability -> genuine pack, no sentinel + E unsupported version -> endpoint 400 AND mode sentinel + F late capability after sentinel freeze -> stays sentinel + G capability disappears after clean freeze-> stays clean (immutable) + H two concurrent IPs (A verified, B none) -> A clean, B sentinel (no global leak) + I new session via reset clears capability -> fresh unpatched process -> sentinel + J autopatch mismatch => never registers -> sentinel + +Standalone unit test in the project style: `python3 test_capability_negotiation.py`. +""" +import importlib +import json +import os +import sys +import tempfile + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +SENTINEL_ID = 65534 +REAL_PACK_ID = 1 + + +class _H: + """Minimal request-handler stand-in: peer IP + optional JSON body.""" + + def __init__(self, ip, body=None): + self.client_address = (ip, 54321) + self._body = json.dumps(body).encode("utf-8") if body is not None else b"" + + +def _ids(catalog): + return [p["id"] for p in catalog["purchase"]] + + +def main(): + with tempfile.TemporaryDirectory() as state: + os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json") + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ.pop("FUT_PROFILE", None) + + import fut_account + import fut_store + import fut_accounts + import utas_server + importlib.reload(fut_account) + importlib.reload(fut_store) + importlib.reload(fut_accounts) + importlib.reload(utas_server) + + fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"}) + + # ---- deterministic pack topology helpers ------------------------------- + _orig_visible = utas_server.visible_unopened_packs + + def set_zero_packs(): + utas_server.visible_unopened_packs = lambda: [] + + def set_real_pack(): + utas_server.visible_unopened_packs = lambda: [REAL_PACK_ID] + + def reset_state(): + """Fresh capability store between cases (no cross-case leakage).""" + utas_server._FIFA17_STORE.clear() + + def register(ip, version, persona=42, pid=4242): + return utas_server.fifa17_capability_route(_H(ip, { + "capability": "empty_mypacks_resolver", + "version": version, + "personaId": persona, + "fifaPid": pid, + })) + + def store(ip): + status, cat = utas_server.store_catalog(_H(ip)) + assert status == 200, status + return _ids(cat) + + try: + # ---- A. no capability, zero packs -> sentinel ---------------------- + reset_state() + set_zero_packs() + ip = "10.0.0.1" + utas_server.fifa17_reset_session(ip) + ids = store(ip) + assert SENTINEL_ID in ids, ids + assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL + print("A no-capability zero-packs -> sentinel: OK") + + # ---- B. verified v1, zero packs -> clean --------------------------- + reset_state() + set_zero_packs() + ip = "10.0.0.2" + utas_server.fifa17_reset_session(ip) + st, body = register(ip, 1) + assert st == 200 and body == {"status": "OK"}, (st, body) + ids = store(ip) + assert SENTINEL_ID not in ids, ids + assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_CLEAN + print("B verified-v1 zero-packs -> clean: OK") + + # ---- C. real pack + no capability -> genuine, no sentinel ---------- + reset_state() + set_real_pack() + ip = "10.0.0.3" + utas_server.fifa17_reset_session(ip) + ids = store(ip) + assert SENTINEL_ID not in ids, ids + assert REAL_PACK_ID in ids, ids + print("C real-pack no-capability -> genuine, no sentinel: OK") + + # ---- D. real pack + capability -> genuine, no sentinel ------------- + reset_state() + set_real_pack() + ip = "10.0.0.4" + utas_server.fifa17_reset_session(ip) + register(ip, 1) + ids = store(ip) + assert SENTINEL_ID not in ids, ids + assert REAL_PACK_ID in ids, ids + print("D real-pack capability -> genuine, no sentinel: OK") + + # ---- E. unsupported version -> 400 AND mode sentinel --------------- + reset_state() + set_zero_packs() + ip = "10.0.0.5" + utas_server.fifa17_reset_session(ip) + for bad in (2, 99): + st, body = register(ip, bad) + assert st == 400 and "error" in body, (bad, st, body) + # nothing recorded -> resolver stays None + assert utas_server._FIFA17_STORE[ip]["resolver"] is None + ids = store(ip) + assert SENTINEL_ID in ids, ids + assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL + # a bad capability string with the right version is also rejected + st, body = utas_server.fifa17_capability_route(_H("10.0.0.55", { + "capability": "something_else", "version": 1})) + assert st == 400, (st, body) + print("E unsupported version -> 400 + sentinel: OK") + + # ---- F. late capability after sentinel freeze -> stays sentinel ---- + reset_state() + set_zero_packs() + ip = "10.0.0.6" + utas_server.fifa17_reset_session(ip) + ids = store(ip) # freeze: sentinel + assert SENTINEL_ID in ids, ids + register(ip, 1) # arrives late; ignored for session + ids = store(ip) + assert SENTINEL_ID in ids, "late capability must not flip a frozen sentinel" + assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL + print("F late capability after sentinel freeze -> sentinel: OK") + + # ---- G. capability disappears after clean freeze -> stays clean ---- + reset_state() + set_zero_packs() + ip = "10.0.0.7" + utas_server.fifa17_reset_session(ip) + register(ip, 1) + ids = store(ip) # freeze: clean + assert SENTINEL_ID not in ids, ids + utas_server._FIFA17_STORE[ip]["resolver"] = None # capability vanishes + ids = store(ip) + assert SENTINEL_ID not in ids, "frozen clean mode must be immutable" + assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_CLEAN + print("G capability disappears after clean freeze -> clean: OK") + + # ---- H. two concurrent IPs -> no global leak ----------------------- + reset_state() + set_zero_packs() + ip_a, ip_b = "10.0.1.1", "10.0.1.2" + utas_server.fifa17_reset_session(ip_a) + utas_server.fifa17_reset_session(ip_b) + register(ip_a, 1) # A verified, B never registers + ids_a = store(ip_a) + ids_b = store(ip_b) + assert SENTINEL_ID not in ids_a, ids_a + assert SENTINEL_ID in ids_b, ids_b + assert utas_server._FIFA17_STORE[ip_a]["mode"] == utas_server.FIFA17_MODE_CLEAN + assert utas_server._FIFA17_STORE[ip_b]["mode"] == utas_server.FIFA17_MODE_SENTINEL + print("H concurrent IPs (A clean, B sentinel) -> no global leak: OK") + + # ---- I. reset clears capability across sessions -------------------- + reset_state() + set_zero_packs() + ip = "10.0.2.1" + utas_server.fifa17_reset_session(ip) + register(ip, 1) + ids = store(ip) # session 1: clean + assert SENTINEL_ID not in ids, ids + utas_server.fifa17_reset_session(ip) # relaunch: fresh unpatched process + assert utas_server._FIFA17_STORE[ip] == {"resolver": None, "mode": None} + ids = store(ip) # session 2: no re-register -> sentinel + assert SENTINEL_ID in ids, "capability must not leak across sessions" + print("I new session clears capability -> sentinel: OK") + + # ---- J. autopatch mismatch => never registers -> sentinel ---------- + reset_state() + set_zero_packs() + ip = "10.0.3.1" + utas_server.fifa17_reset_session(ip) # autopatch verify FAILED: no register + ids = store(ip) + assert SENTINEL_ID in ids, ids + assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL + print("J autopatch mismatch (never registers) -> sentinel: OK") + finally: + utas_server.visible_unopened_packs = _orig_visible + + print("capability negotiation matrix A-J: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 3ab9b0a..61e1dab 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0): * body must parse as JSON (else err 0x3E6); 204 + empty body is accepted. * [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET. """ -import copy, datetime, json, os, random, re, sys, http.server +import copy, datetime, json, os, random, re, sys, threading, http.server from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -52,6 +52,65 @@ def visible_unopened_packs(): return STORE.unopened_packs() + list(_OPENED_PACK_GRACE) +# ---- FIFA17 empty-My-Packs capability negotiation (per-IP, session-stable) ---- +# The synthetic 65534 sentinel (store_catalog) is the universal P2 fallback. It is +# suppressed for a session ONLY when the launcher has registered that the CURRENT +# FIFA process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG). +# Verification is per-FIFA-process; the backend binds it to the peer IP and freezes +# a per-session decision at the first /store/purchasegroup. Fail-closed: any unknown +# / late / absent / wrong-version capability resolves to the active sentinel. +# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§7/§9/§11). +FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION = 1 +FIFA17_MODE_SENTINEL = "sentinel" +FIFA17_MODE_CLEAN = "clean-v1" +_FIFA17_STORE = {} # ip -> {"resolver": Optional[int], "mode": Optional[str]} +_FIFA17_STORE_LOCK = threading.Lock() + + +def _fifa17_client_ip(h): + """Peer IP for the request handler, or None when unavailable (e.g. h is None).""" + try: + return h.client_address[0] + except Exception: + return None + + +def fifa17_reset_session(ip): + """Session boundary (/openfut/account/sync): clear capability + unfreeze mode.""" + with _FIFA17_STORE_LOCK: + _FIFA17_STORE[ip] = {"resolver": None, "mode": None} + + +def fifa17_register_capability(ip, version): + """Register a verified resolver capability for ip. Returns the current mode. + + If the session's mode is already frozen, the capability is logged as late and + ignored for this session (mode is immutable after the first store request).""" + with _FIFA17_STORE_LOCK: + rec = _FIFA17_STORE.setdefault(ip, {"resolver": None, "mode": None}) + rec["resolver"] = version + if rec["mode"] is not None: + log("[fifa17-store] capability arrived after mode freeze; ignored for " + "current session (ip %s)" % ip) + return rec["mode"] + + +def fifa17_empty_mypacks_mode(ip): + """Resolve (and freeze on first call) the empty-My-Packs mode for ip. + + Freeze point = first /store/purchasegroup: clean-v1 iff a matching-version + resolver capability is already registered, else the sentinel fallback.""" + with _FIFA17_STORE_LOCK: + rec = _FIFA17_STORE.setdefault(ip, {"resolver": None, "mode": None}) + if rec["mode"] is None: + rec["mode"] = (FIFA17_MODE_CLEAN + if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION + else FIFA17_MODE_SENTINEL) + log("[fifa17-store] session %s empty-mypacks mode frozen: %s" + % (ip, rec["mode"])) + return rec["mode"] + + def now(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -198,6 +257,12 @@ def auth_body(h=None): def account_sync_route(h): """Launcher-only active-profile selection, before LSX/Blaze login starts.""" + # Session boundary: each launcher account-sync starts a fresh per-IP FIFA17 + # capability session (unfreeze mode + clear any prior capability). A new FIFA + # process must re-verify; nothing leaks across processes. + ip = _fifa17_client_ip(h) + fifa17_reset_session(ip) + log(" ACCOUNT: reset FIFA17 empty-mypacks capability session for ip %s" % ip) try: body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} account = activate_account(body) @@ -209,6 +274,33 @@ def account_sync_route(h): return 200, {"account": account, "status": "OK"} +def fifa17_capability_route(h): + """POST /openfut/fifa17/capability -- launcher registers a verified resolver + capability for the current FIFA process (bound to the peer IP). Fail-closed: + anything but capability==empty_mypacks_resolver && version==current is a 400 + that records NOTHING (the session stays on the sentinel fallback).""" + try: + body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} + except Exception: + return 400, {"error": "unsupported capability"} + if not isinstance(body, dict): + return 400, {"error": "unsupported capability"} + try: + version = int(body.get("version")) + except (TypeError, ValueError): + return 400, {"error": "unsupported capability"} + if (body.get("capability") != "empty_mypacks_resolver" + or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION): + return 400, {"error": "unsupported capability"} + ip = _fifa17_client_ip(h) + fifa17_register_capability(ip, version) + persona = body.get("personaId", "?") + fifa_pid = body.get("fifaPid", "?") + log("[fifa17-store] registered capability empty_mypacks_resolver=%s for %s " + "(persona %s, fifa_pid %s)" % (version, ip, persona, fifa_pid)) + return 200, {"status": "OK"} + + def current_squad(): """The squad the client should see: the persisted one (item refs re-embedded from the club) or the seed ladder squad on first run. @@ -1203,6 +1295,10 @@ ROUTES = [ # Launcher control-plane endpoint. It is intentionally outside /ut so FIFA # never calls it; launch is blocked unless this succeeds first. (re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)), + # Launcher registers a verified per-FIFA-process resolver capability (bound to + # peer IP). Adjacent to account/sync, above the generic /ut routes; FIFA never + # calls it. Fail-closed: absent/late/wrong-version => sentinel (store_catalog). + (re.compile(r"^/openfut/fifa17/capability$"), lambda m, h: fifa17_capability_route(h)), # ---- FUT item-definition endpoints (must precede generic /item, /user) ---- (re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)), (re.compile(G + r"/defid"), lambda m, h: defs_route(h)), @@ -3426,43 +3522,54 @@ def store_catalog(h): if owned: packs.append(_pack_body(owned, idx, owned=True)) if not owned_ids: - # EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2). - # - # The Store/Scaleform path RESOLVES the `mypacks` category even when the - # account owns zero unopened packs (the category is chosen client-side from - # the movie's CATEGORY_ID -> screen+0x290; no server field gates it). - # CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null - # guard, so if no `mypacks` group exists the client CRASHES - # (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by - # minidump). We therefore MUST emit a `mypacks` group when empty. - # - # state="inactive" avoids the crash but makes the client report the pack - # unavailable immediately on Store entry and bounce to the Hub. state="active" - # keeps the group structurally valid AND lets the Store open normally; the - # empty tile renders as "0 items" and an explicit open is rejected - # CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend - # request and mutates nothing. - # - # id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns - # None and store_buy()/purchased_items() cannot open it, grant items/coins, - # or add it to unopenedPackIds. This is a compatibility shim for FIFA 17 - # client behavior, NOT an EA-authentic empty-My-Packs representation, and it - # is FIFA17-specific (do not lift into game-independent Core). A fully clean - # zero-pack UX requires a client-side fix -- see - # docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in - # docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md. - sentinel = { - "id": 65534, - "name": "", - "price": 0, - "count": 0, - "gold": True, - "specialChance": 0.0, - } - empty = _pack_body(sentinel, 1, owned=True) - empty["state"] = "active" - empty["unopened"] = False - packs.append(empty) + # ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md + # §7/§9). This is the session-freeze point: the empty-mypacks decision for + # this peer IP is committed here at the first /store/purchasegroup and is + # immutable for the session thereafter. + mode = fifa17_empty_mypacks_mode(_fifa17_client_ip(h)) + if mode == FIFA17_MODE_CLEAN: + # Verified patched client: emit NO mypacks group; the CardsDLL resolver + # guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of + # dereferencing a null group. (append nothing) + pass + else: + # EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2). + # + # The Store/Scaleform path RESOLVES the `mypacks` category even when the + # account owns zero unopened packs (the category is chosen client-side from + # the movie's CATEGORY_ID -> screen+0x290; no server field gates it). + # CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null + # guard, so if no `mypacks` group exists the client CRASHES + # (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by + # minidump). We therefore MUST emit a `mypacks` group when empty. + # + # state="inactive" avoids the crash but makes the client report the pack + # unavailable immediately on Store entry and bounce to the Hub. state="active" + # keeps the group structurally valid AND lets the Store open normally; the + # empty tile renders as "0 items" and an explicit open is rejected + # CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend + # request and mutates nothing. + # + # id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns + # None and store_buy()/purchased_items() cannot open it, grant items/coins, + # or add it to unopenedPackIds. This is a compatibility shim for FIFA 17 + # client behavior, NOT an EA-authentic empty-My-Packs representation, and it + # is FIFA17-specific (do not lift into game-independent Core). A fully clean + # zero-pack UX requires a client-side fix -- see + # docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in + # docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md. + sentinel = { + "id": 65534, + "name": "", + "price": 0, + "count": 0, + "gold": True, + "specialChance": 0.0, + } + empty = _pack_body(sentinel, 1, owned=True) + empty["state"] = "active" + empty["unopened"] = False + packs.append(empty) return 200, {"purchase": packs, "timestamp": 1596326400}