#!/usr/bin/env python3 """ OpenFUT clean-room LSX responder for FIFA 17 -- v2 (EVENT-PUSHING). v2 vs v1 (lsx_responder.py): v1 was REQUEST-DRIVEN ONLY. It answered every verb the client asked for and never sent an unsolicited frame. That is exactly why the client never issued GetAuthCode and never sent Blaze Authentication::login. THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS: (1) "internet is reachable" -> OriginMgr online byte [0x1448a3ac0] fed by the REQUEST verb GetInternetConnectedState -> connected="1" (v1 already beat this; live-confirmed == 1) (2) "a user is LOGGED IN" -> OriginMgr.m_isLoggedIn [OriginMgr+0x13] fed ONLY by a server-PUSHED There is NO request verb that can set it. v1 fed (1) and never fed (2), so m_isLoggedIn was 0 for the whole session, FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever (both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately, OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH could never be filled -> no Blaze login -> "Unable to retrieve account information." BINARY EVIDENCE (all re-verified byte-for-byte from our own live dumps, not from any leak; see the PROVENANCE block at the bottom of this docstring): * Origin event dispatcher @0x146f1e060, case edx==2 (OriginEventT::Login) is the ONLY case in the whole dispatcher that mutates state: 146f1e09e: 41 83 39 01 cmp DWORD PTR [r9],0x1 ; IsLoggedIn==1 146f1e0ab: c6 41 13 01 mov BYTE PTR [rcx+0x13],1 ; m_isLoggedIn=TRUE 146f1e0af: c7 41 14 00.. mov DWORD PTR [rcx+0x14],0 ; clear login error 146f1e0b8: c6 41 13 00 mov BYTE PTR [rcx+0x13],0 ; else FALSE * element matcher @0x147102880: - reads attribute "sender" (literal @0x143938028) off the node via vtbl+0x70; `test rax,rax; je fail` -> sender MUST be present - inline strcmp of that value against the handler's registered sender -> a mismatched sender is SILENTLY DROPPED - then requires the child element name == "Login" (@0x14393d0ac) * Handler sender strings come from the service-name tables. NOTE there are TWO parallel structures, so do not "fix" one stride into the other: - the const char* INIT table @0x144341420 is STRIDE 8; - the runtime std::string array the SDK actually indexes (sdk+0x3b0, via GetServiceName @0x1470e4870 with `shl rax,0x5`) is STRIDE 0x20, max index 0x21. Both resolve index 14 == LOGIN_EVENT, so the conclusion is the same. Verified contents of the index space: idx 0 SDK 1 PROFILE 2 PRESENCE 3 FRIENDS 4 COMMERCE idx 5 RECENTPLAYER 6 IGO 7 MISC 8 LOGIN idx 9 UTILITY 10 XMPP 11 CHAT 12 IGO_EVENT idx13 EALS_EVENTS 14 LOGIN_EVENT 15 INVITE_EVENT idx16 PROFILE_EVENT ... 27 ONLINE_STATUS_EVENT "LOGIN_EVENT" (@0x14394c790) is referenced from EXACTLY ONE place in the whole image: table slot 0x144341490 == index 14. Likewise "ONLINE_STATUS_EVENT" (@0x14394c868) only from 0x1443414f8 == index 27. * attribute parser @0x147138660: opens namespace "lsx", reads attribute "IsLoggedIn" (@0x14394e0f0), then @0x14713ffa0 does strcmp(value,"false"); setne al; mov BYTE PTR [rdi],al -> ANY value except the literal string "false" means TRUE. * parser @0x147139e00 reads attribute "isOnline" (@0x14394e180, lower-case i) in the same shape. * Symbols proving the handler templates are instantiated in this build: Origin::EventHandler::HandleMessage Origin::EventHandler::HandleMessage (payload type `unsigned int` matches `cmp DWORD PTR [r9],1` above.) * Structural proof that unsolicited frames are consumable: the LSX handshake itself is one -- . WHAT WE DELIBERATELY DID NOT CHANGE * The crypto (challenge / session-key derivation / AES-ECB+PKCS7+hex+NUL) is byte-verified against a captured real session; it is copied verbatim. * Every verb v1 answered is answered identically. Nothing was removed. WIRE PROTOCOL (unchanged, reversed from stp-origin_emu.dll @ 0x6ffffc930000): transport : TCP 127.0.0.1:4216, each message NUL-terminated (send strlen+1). handshake : server sends IN PLAINTEXT; client replies plaintext with response=/key=; server replies where H = hex(AES128_ECB(K_FIXED, PKCS7pad16(clientKeyAscii))) K_FIXED = 000102...0f (emu .rdata 0x935038) session : every later frame (BOTH directions, Responses AND Events) is hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0" SESSION_KEY derived from H via the MSVCR srand/rand LCG. USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's bind() fails. (This file does NOT auto-start anything; the main session owns processes.) PROVENANCE / CLEAN ROOM: every constant and algorithm here was recovered by our own static+dynamic analysis of binaries we own (FIFA17.exe unpacked in our own process, stp-origin_emu.dll as loaded) plus traffic we ourselves captured. Nothing is derived from the 2021 EA/FIFA leak. """ import os import re import socket import sys import threading import time from Crypto.Cipher import AES sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fut_account import ACCOUNT # noqa: E402 # ---------------------------------------------------------------- identity # SOURCED FROM fut_account.ACCOUNT, shared with blaze_responder_v3b.py, # fut_store.py, fut_seed.py and utas_server.py. # # THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX # reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what # UTAS serves as userInfo.personaId. (The previous comment blamed a mismatch for # AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / # AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are # the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or # dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They # stay the defaults because they are what the working stack asserts.) PERSONA_ID = ACCOUNT.persona_id PERSONA_NAME = ACCOUNT.persona_name USER_ID = ACCOUNT.user_id # derived from persona_id CONTENT_ID = ACCOUNT.CONTENT_ID # FIFA 17 EA offer id ENTITLEMENT_TAG = ACCOUNT.ENTITLEMENT_TAG LOCALE = ACCOUNT.locale AUTHCODE_FILE = "/tmp/openfut_authcode.txt" CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt" # Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038 K_FIXED = bytes(range(16)) # 000102030405060708090a0b0c0d0e0f # Emu's own advertised challenge (any 32 hex chars work; the client echoes it back) CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32" BUILD = "release" VERSION = "10,4,13,6637" # ------------------------------------------------------------- event tuning # Pushes are idempotent state notifications, so re-sending is harmless and is # cheap insurance against FIFA registering its handler later than our # first push. Set OPENFUT_LSX_EVENTS=0 to fall back to v1 behaviour (useful as # an A/B control if you want to prove the events are what moved the needle). EVENTS_ENABLED = os.environ.get("OPENFUT_LSX_EVENTS", "1") != "0" EVENT_HEARTBEAT_SECS = float(os.environ.get("OPENFUT_LSX_EVENT_PERIOD", "5")) EVENT_HEARTBEAT_COUNT = int(os.environ.get("OPENFUT_LSX_EVENT_COUNT", "24")) # EXPERIMENT: push the Login Event in PLAINTEXT right after ChallengeAccepted # (before the stream goes encrypted) instead of via the encrypted heartbeat. # Tests the workflow's strongest remaining hypothesis -- that FIFA drops # encrypted mid-session Events (the emu's only Event, the Challenge, is plaintext # and pre-key). See serve() step 3b and REPACK_INTEL.md sec.4 step 2. LOGIN_PLAINTEXT = os.environ.get("OPENFUT_LSX_LOGIN_PLAINTEXT", "0") != "0" # A/B-control integrity: v1 (lsx_responder.py) answered GetGameInfo # FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had # silently changed it to "true", which meant OPENFUT_LSX_EVENTS=0 was NOT a # byte-identical control any more. Keep it OFF by default so events-off == # v1 exactly; flip OPENFUT_LSX_FULLGAME=1 to run the FULLGAME="true" experiment # on its own. FULLGAME_PURCHASED_TRUE = os.environ.get("OPENFUT_LSX_FULLGAME", "0") != "0" def log(*a): print("[lsx]", *a, flush=True) _SECRET_ATTR_RE = re.compile( r'(?i)\b(AuthCode|AuthToken|SessionKey|Token|Sid)="[^"]*"') _AUTH_CODE_ATTR_RE = re.compile(r'(?i)\b(value|Code|Return)="[^"]*"') _CHALLENGE_ATTR_RE = re.compile(r'(?i)\b(response)="[^"]*"') def safe_xml_for_log(xml): """Redact credential-bearing LSX attributes from ordinary diagnostics.""" safe = _SECRET_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), xml) if "> 16) & 0x7FFF def derive_session_key(resp_hex: str) -> bytes: """Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101). srand(7); r0 = rand() -> r0 == 61 bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap) srand(bx + r0) key[i] = (uint8_t)rand() for i in 0..15 """ r0 = next(msvcr_rand(7)) # == 61 bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF g = msvcr_rand((bx + r0) & 0xFFFFFFFF) return bytes(next(g) & 0xFF for _ in range(16)) # AES128-ECB(K_FIXED, 0x10*16) -- the constant the emu appends as the 3rd hex # block (== PKCS7 pad block of an aligned 32-byte key). See REPACK_INTEL.md sec.0-B. _TAIL_CONST = AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex() def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str: """Emu-exact ChallengeAccepted.response (stp-origin_emu.dll 0x180001f10). The emu computes only TWO AES blocks from the 32-ASCII client key, then strcat_s's the client's OWN response[64:] verbatim (@0x1800020a9) -> 96 hex. Our older 3-block PKCS7 form is numerically identical *while the client PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed byte-exact). We now reproduce the emu exactly and, when the client's response= is available, echo its tail and assert the constant so a future client that randomises block 3 fails LOUDLY instead of silently.""" two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex() if len(client_response_attr) >= 64: tail = client_response_attr[64:] assert tail == _TAIL_CONST, f"unexpected ChallengeResponse tail {tail!r}" return two + tail return two + _TAIL_CONST def lsx_encrypt(xml: str, key: bytes) -> bytes: """pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated.""" b = xml.encode() pad = 16 - (len(b) % 16) # emu always pads (pad==16 when aligned) b += bytes([pad]) * pad return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0" def lsx_decrypt(data: bytes, key: bytes) -> str: h = data.split(b"\0")[0].strip() raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode())) pad = raw[-1] if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]): raw = raw[:-pad] return raw.split(b"\0")[0].decode(errors="replace") # ------------------------------------------------------- PUSHED EVENTS (NEW) # # Frame shape is identical to the server-initiated that already # works, i.e. # No id attribute (the Challenge has none; the matcher never reads one). # # `sender` is strcmp'd against the handler's registered service name. A # mismatch is silently dropped -- costs us nothing -- so for the Login element # we emit BOTH candidate senders: "LOGIN_EVENT" (table index 14, the one the # event-handler factory uses) and "LOGIN" (table index 8, the plain service # name). Exactly one of them will match; the other is a no-op. # Event handlers are keyed on serviceNames[facility] too (registrar 0x14710df80); # with our empty GetConfigResponse those names are "", so the handlers expect # sender="". "" first; the named variants are harmless no-ops (dropped silently) # and become correct once GetConfigResponse populates the table (RANK 2). LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN") ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT") def event(sender: str, element: str) -> str: return f'<{element}/>' def login_event_frames() -> list: """The frames that flip OriginMgr.m_isLoggedIn ([OriginMgr+0x13]) to 1. IsLoggedIn is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE. Keep the value literally "true" anyway: it is what a real Origin client sends and it keeps the log readable.""" out = [event(s, 'Login IsLoggedIn="true"') for s in LOGIN_EVENT_SENDERS] out += [event(s, 'OnlineStatusEvent isOnline="true"') for s in ONLINE_EVENT_SENDERS] return out class Conn: """Socket + session key + a send lock. The lock matters: pushes come from a heartbeat thread while the request loop may be writing a Response. LSX frames are NUL-delimited, so two interleaved sendall()s would corrupt the stream and the client would drop the connection (which would look exactly like a protocol bug).""" def __init__(self, sock, addr): self.sock = sock self.addr = addr self.key = None self.lock = threading.Lock() self.alive = True self.pushed_login = False # Set True once GetAuthCode has been issued, so the heartbeat stops # re-pushing Login/OnlineStatus events. Re-pushing after the auth code # is granted re-enters FIFA's state-mutating Origin event dispatcher # (case 2 @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts # on the FE bus) ~24 more times DURING Blaze login, which we do not want. self.stop_events = False def send_plain(self, xml: str): with self.lock: self.sock.sendall(xml.encode() + b"\0") def send_enc(self, xml: str): with self.lock: self.sock.sendall(lsx_encrypt(xml, self.key)) def push_login_state(self, why: str): if not EVENTS_ENABLED: return for frame in login_event_frames(): try: self.send_enc(frame) except Exception as e: self.alive = False log("push failed:", e) return log(f"PUSH ({why}) >> {frame}") if not self.pushed_login: self.pushed_login = True log("*** first pushed. Watch for " "GetAuthCode next. ***") def heartbeat(self): """Re-push the login state a bounded number of times. FIFA builds its Origin event handlers lazily; if our first push lands before the handler is registered the matcher simply finds no handler and drops it. Re-pushing removes that race without needing to guess the exact registration moment.""" for _ in range(EVENT_HEARTBEAT_COUNT): time.sleep(EVENT_HEARTBEAT_SECS) if not self.alive or self.stop_events: return self.push_login_state("heartbeat") # ---------------------------------------------------------------- responses def resp(mid, body, sender=""): return f'<{body}/>' def build_reply(mid, req_name, attrs, conn, recipient=""): """Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script). CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher 0x1471189b0 rejects any whose `sender` attribute does not byte-equal the `recipient` the client put on the matching (it reads serviceNames[facility]; with our empty GetConfigResponse all 34 names are "" so recipient="" for every verb after GetConfig, which itself uses the hard-coded literal "EbisuSDK"). We were answering GetProfile/GetAuthCode/ QueryEntitlements with sender="EbisuSDK" -> silently discarded -> GetProfile (the SOLE writer of OriginSDK+0x3a0 default-user) never took -> the whole online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO the request's recipient back as the response sender. This local `resp` shadows the module one and makes `sender` default to `recipient`.""" def resp(mid, body, sender=None): s = recipient if sender is None else sender return f'<{body}/>' if req_name == "GetInternetConnectedState": # FLAG (1): "internet is reachable". Stub hardcoded connected="0" # -> "log in to Origin". This is NOT the logged-in flag; see the # module docstring. return resp(mid, 'InternetConnectedState connected="1"') if req_name == "GetAuthCode": # Request shape is built at 0x14713b8d0: # # Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode". # # THE ATTRIBUTE NAME IS "value" -- verified, not guessed: # the "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0 # -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one # attribute name (ns-prefix for "lsx" @0x14394def0, then "value" # @0x1436c7768, concat at 0x14712d130) and does exactly ONE # get-attribute-as-string call 0x14713fe50(node, "value", &dest). # dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands at # +0xc8 -- which is what OriginRequestAuthCodeSync's impl 0x1470e67f0 # reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as *out_len. # Code=/Return= are NEVER read; with them alone the parsed string is # empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the OSDK classifier # 0x14717d5d0 falls into its `test rbp,rbp / je` arm and reports # OSDK_UNDERAGE_ERROR (a mislabelled "no auth code" fallback). # Code=/Return= are kept only as harmless padding. client_id = attrs.get("ClientId", "") scope = attrs.get("Scope", "") code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24) # Only touch the run's success-signal files on a REAL request (conn is a # live socket). --selftest calls build_reply(..., conn=None); if it # wrote these files it would pre-satisfy watch-step "authcode.txt becomes # non-empty" and make a non-event read as success on the next live run. if conn is not None: for path, val in ((AUTHCODE_FILE, code), (CLIENTID_FILE, client_id)): try: with open(path, "w") as fh: fh.write(val) except Exception: pass # GetAuthCode has fired: stop the heartbeat so we do not keep # re-pushing Login/OnlineStatus events during Blaze login. conn.stop_events = True log("*** GetAuthCode ISSUED ***") log(f" ClientId={client_id!r} Scope={scope!r}") log(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)") return resp(mid, f'AuthCode value="{code}" Code="{code}" Return="{code}"') if req_name == "QueryEntitlements": item = (f'') return (f'' f'{item}' f'') if req_name == "GetProfile": # This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8] # (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona # @0x1470da680 are bare reads of those fields, written only by # OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete. # ONLY PersonaId/UserId/Persona are substituted from ACCOUNT; the rest # of this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/ # AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4 # and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim. return resp(mid, f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" ' f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" ' f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" ' f'CommerceCurrency="USD"') if req_name == "GetGameInfo": gi = attrs.get("GameInfoId") if gi == "LANGUAGES": return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,' 'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,' 'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"') if gi == "UPTODATE": # MUST be true or the client shows "Your title version is # outdated" and blocks all online features. return resp(mid, 'GetGameInfoResponse GameInfo="true"') if gi == "FULLGAME_PURCHASED" and FULLGAME_PURCHASED_TRUE: # OFF by default: v1 answered "false" here (fell through to default). # Keeping this gated makes OPENFUT_LSX_EVENTS=0 byte-identical to v1. return resp(mid, 'GetGameInfoResponse GameInfo="true"') # FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a trial; # matches v1 exactly) return resp(mid, 'GetGameInfoResponse GameInfo="false"') if req_name == "GetSetting": sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"): return resp(mid, 'GetSettingResponse Setting="production"') if sid == "LANGUAGE": return resp(mid, f'GetSettingResponse Setting="{LOCALE}"') return resp(mid, 'GetSettingResponse Setting="false"') if req_name == "GetConfig": return resp(mid, 'GetConfigResponse Config="false"') if req_name == "IsProgressiveInstallationAvailable": return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" ' 'Available="false"') return resp(mid, 'ErrorSuccess Code="0" Description=""') # Trigger points: push right after answering these verbs. GetProfile is the # earliest safe moment -- by then the SDK has built its handler set and has a # default user, so a Login event has somewhere to land. PUSH_AFTER = { "GetProfile": "after GetProfile", "GetInternetConnectedState": "after GetInternetConnectedState", "GetGameInfo": "after GetGameInfo UPTODATE", } REQ_RE = re.compile(r']*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>') ATTR_RE = re.compile(r'(\w+)="([^"]*)"') # The response `sender` must byte-equal the request's `recipient` (matcher # 0x1471189b0). Captured separately (default "") so a frame that ever lacks # `recipient` still gets answered fast instead of a 15s stall. RECIP_RE = re.compile(r']*\brecipient="([^"]*)"') def serve(sock, addr): conn = Conn(sock, addr) hb = None try: # 1. plaintext Challenge conn.send_plain(f'') # 2. plaintext ChallengeResponse from client. The emu parses response=" # BEFORE key=" (0x180001f10); extract both so challenge_response can # echo the client's own 3rd block (REPACK_INTEL.md C1/C2). data = sock.recv(4096) txt = data.decode(errors="replace") mk = re.search(r'key="([^"]*)"', txt) mr = re.search(r'response="([^"]*)"', txt) client_key = mk.group(1) if mk else CHALLENGE_KEY client_resp = mr.group(1) if mr else "" h = challenge_response(client_key, client_resp) conn.key = derive_session_key(h) log("handshake accepted; session crypto initialized") # 3. plaintext ChallengeAccepted conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS")) # 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY # unsolicited Event is the plaintext, pre-session-key Challenge; there # is zero evidence an *encrypted mid-session* Event routes to the same # parser (REPACK_INTEL.md sec.4 step 2). So push the Login Event here, # in PLAINTEXT, right after ChallengeAccepted -- before the stream goes # encrypted -- and suppress the encrypted heartbeat to keep the A/B clean. if LOGIN_PLAINTEXT and EVENTS_ENABLED: conn.stop_events = True for frame in login_event_frames(): conn.send_plain(frame) log(f"PUSH (plaintext post-accept) >> {frame}") # 4. encrypted request/response loop buf = b"" while True: data = sock.recv(65536) if not data: break # Buffer partial frames: a 64 KiB recv can straddle a NUL boundary, # and split() would silently drop the trailing partial (C3). buf += data *frames, buf = buf.split(b"\0") for chunk in filter(None, frames): try: xml = lsx_decrypt(chunk + b"\0", conn.key) except Exception as e: log("decrypt fail:", e) continue mm = REQ_RE.search(xml) if not mm: log("<<", safe_xml_for_log(xml)) continue mid, name, rest = mm.group(1), mm.group(2), mm.group(3) attrs = dict(ATTR_RE.findall(rest)) rm = RECIP_RE.search(xml) recip = rm.group(1) if rm else "" reply = build_reply(mid, name, attrs, conn, recip) log(f"<< id={mid} {name} recipient={recip!r} {attrs}") log(">>", safe_xml_for_log(reply)) conn.send_enc(reply) why = PUSH_AFTER.get(name) if why and EVENTS_ENABLED: # For GetGameInfo only fire on UPTODATE, otherwise we would # push three times per boot for FREETRIAL/LANGUAGES too. if name != "GetGameInfo" or attrs.get("GameInfoId") == "UPTODATE": conn.push_login_state(why) if hb is None: hb = threading.Thread(target=conn.heartbeat, daemon=True) hb.start() except Exception as e: log("connection error:", e) finally: conn.alive = False try: sock.close() except Exception: pass log("connection closed", addr) def main(): s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("127.0.0.1", 4216)) s.listen(8) log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)") log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}" f" (period={EVENT_HEARTBEAT_SECS}s count={EVENT_HEARTBEAT_COUNT})") while True: c, a = s.accept() log("connection from", a) threading.Thread(target=serve, args=(c, a), daemon=True).start() # ---------------------------------------------------------------- self-test def selftest(): """No live game needed. Proves the crypto is untouched and the new event frames encrypt/decrypt cleanly through our own codec.""" h = challenge_response("18a70055a3541fb27ab8e0f47afad18c") assert h.startswith("e4f5166209929e15"), h k = derive_session_key(h) assert k.hex() == "6a9da3e78615153cc2f10eec25ae6382", k.hex() print("[ok] crypto matches the captured 2026-07-30 session verbatim") frames = login_event_frames() assert len(frames) == len(LOGIN_EVENT_SENDERS) + len(ONLINE_EVENT_SENDERS) for f in frames: assert lsx_decrypt(lsx_encrypt(f, k), k) == f print("[ok] round-trip:", f) # "" sender first (correct for the current empty service-name table) assert '' in frames[0] r = build_reply(42, "GetAuthCode", {"ClientId": "X", "Scope": "Y"}, None) # 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0) # actually reads; Code=/Return= are legacy padding. assert '') assert "secret" not in redacted and redacted.count("[REDACTED]") == 3, redacted status = safe_xml_for_log('') assert 'Code="0"' in status, status print("[ok] GetAuthCode response shape and log redaction") print("[ok] selftest passed") if __name__ == "__main__": if "--selftest" in sys.argv: selftest() else: main()