#!/usr/bin/env python3 """ OpenFUT clean-room LSX responder for FIFA 17 (replaces the Steampunks stp-origin_emu in-process stub on 127.0.0.1:4216). PROVENANCE / CLEAN-ROOM: every constant and algorithm here was recovered by static + dynamic analysis of binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in our own running process). Nothing is derived from the 2021 EA/FIFA leak. WIRE PROTOCOL (reversed from stp-origin_emu.dll @ base 0x6ffffc930000): transport : TCP 127.0.0.1:4216, each message is a NUL-terminated byte string (send length == strlen(msg)+1). handshake : server sends IN PLAINTEXT. client replies (plaintext) with response="..." and key="..." attrs. server replies where H = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED)) K_FIXED = 000102030405060708090a0b0c0d0e0f (emu .rdata 0x935038) session : every later message is hex_lower( AES128_ECB_encrypt( pkcs7_pad16( xml ) ) ) under SESSION_KEY, which both sides derive from H (see derive_session_key). Incoming messages are hex-decoded, decrypted, pad-stripped. USAGE: bind this BEFORE launching FIFA 17. The stub's bind() then fails and its server thread returns cleanly (it has no SO_REUSEADDR and no retry), so the game's OriginSDK connects to us instead. """ import socket, sys, re, os, threading from Crypto.Cipher import AES # ---------------------------------------------------------------- identity # SHARED CONSTANTS -- must stay byte-identical to stp-origin_emu.ini [Globals] # AND to the same block at the top of blaze_responder_v3.py. A mismatch between # what LSX reports here and what Blaze returns in LoginResponse.SESS.PDTL is # exactly what raises AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_ # PERSONA / AUTH_ERR_PERSONA_NOT_FOUND. PERSONA_ID = 33068179 PERSONA_NAME = "CAGE" USER_ID = 33068179 CONTENT_ID = "1027460" # FIFA 17 EA offer id ENTITLEMENT_TAG = "ONLINE_ACCESS" # TWO-LAYER ORDERING: this file is LAYER 1. It must be listening on # 127.0.0.1:4216 BEFORE FIFA 17 starts. Only once GetInternetConnectedState # answers connected="1" does origin.nav take the OriginIsOnlineTrue exit into # futBlazeLogin; only then does the client call GetAuthCode and put the result # in Blaze LoginRequest.AUTH (1/0x0A). Until then it sends # Authentication::logout (1/0x46) and blaze_responder_v3.py can do nothing. # The auth code we hand out is echoed to AUTHCODE_FILE purely so the Blaze log # can be correlated; blaze_responder_v3 accepts whatever AUTH arrives and never # validates it against Nucleus. AUTHCODE_FILE = "/tmp/openfut_authcode.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" # ---------------------------------------------------------------- crypto def msvcr_rand(seed): """MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61).""" s = seed & 0xFFFFFFFF while True: s = (s * 214013 + 2531011) & 0xFFFFFFFF yield (s >> 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)) def challenge_response(client_key_ascii: str) -> str: """H = hex(AES128-ECB(K_FIXED, PKCS7pad16(clientKeyAscii))). VERIFIED against a captured client ChallengeResponse (2026-07-30): the 32-char ASCII key is PKCS7-padded to 48 bytes (3 AES blocks, 96 hex), NOT zero-padded to 32. With server challenge key '2b8ee7fa...' this reproduces the client's response '00b9c8af...216684899' exactly.""" b = client_key_ascii.encode() pad = 16 - (len(b) % 16) # 32 -> +16 full block -> 48 bytes b += bytes([pad]) * pad return AES.new(K_FIXED, AES.MODE_ECB).encrypt(b).hex() 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") # ---------------------------------------------------------------- responses def resp(mid, body, sender=""): return f'<{body}/>' def build_reply(mid, req_name, attrs): """Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script).""" if req_name == "GetInternetConnectedState": # THE ONLINE GATE. Stub hardcoded connected="0" -> "log in to Origin". return resp(mid, 'InternetConnectedState connected="1"') if req_name == "GetAuthCode": # Stub never implemented this at all. Element name is (confirmed # in FIFA17.exe element table @0x143937ae0). Emit both plausible value attrs; # the client reads the one it knows and ignores the other. code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24) try: with open(AUTHCODE_FILE, "w") as fh: fh.write(code) except Exception: pass print(f"[lsx] *** GetAuthCode issued: {code} -- this is what should " f"arrive as Blaze LoginRequest.AUTH (1/0x0A) ***") return resp(mid, f'AuthCode Code="{code}" Return="{code}"', sender="EbisuSDK") if req_name == "QueryEntitlements": item = (f'') return (f'' f'{item}' f'') if req_name == "GetProfile": 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"', sender="EbisuSDK") 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": # "is the title up to date?" -- MUST be true or the client shows # "Your title version is outdated" and blocks all online features. return resp(mid, 'GetGameInfoResponse GameInfo="true"') # FREETRIAL etc. -> false (retail, not a trial) return resp(mid, 'GetGameInfoResponse GameInfo="false"') if req_name == "GetSetting": sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE (ENVIRONMENT/LANGUAGE) if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"): return resp(mid, 'GetSettingResponse Setting="production"') if sid == "LANGUAGE": return resp(mid, 'GetSettingResponse Setting="en_US"') return resp(mid, 'GetSettingResponse Setting="false"') if req_name == "GetConfig": return resp(mid, 'GetConfigResponse Config="false"', sender="EbisuSDK") if req_name == "IsProgressiveInstallationAvailable": return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" ' 'Available="false"') return resp(mid, 'ErrorSuccess Code="0" Description=""') REQ_RE = re.compile(r']*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>') ATTR_RE = re.compile(r'(\w+)="([^"]*)"') def serve(conn): # 1. plaintext Challenge chal = (f'') conn.sendall(chal.encode() + b"\0") # 2. plaintext ChallengeResponse from client data = conn.recv(4096) m = re.search(r'key="([^"]*)"', data.decode(errors="replace")) client_key = m.group(1) if m else CHALLENGE_KEY h = challenge_response(client_key) key = derive_session_key(h) print(f"[lsx] client key={client_key} response={h[:16]}... session_key={key.hex()}") # 3. plaintext ChallengeAccepted conn.sendall(resp(1, f'ChallengeAccepted response="{h}"', "EALS").encode() + b"\0") # 4. encrypted request/response loop while True: data = conn.recv(65536) if not data: break for chunk in filter(None, data.split(b"\0")): try: xml = lsx_decrypt(chunk + b"\0", key) except Exception as e: print("[lsx] decrypt fail:", e) continue mm = REQ_RE.search(xml) if not mm: print("[lsx] <<", xml) continue mid, name, rest = mm.group(1), mm.group(2), mm.group(3) attrs = dict(ATTR_RE.findall(rest)) reply = build_reply(mid, name, attrs) print(f"[lsx] << id={mid} {name} {attrs}\n[lsx] >> {reply}") conn.sendall(lsx_encrypt(reply, key)) def main(): s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("127.0.0.1", 4216)) s.listen(8) print("[lsx] listening on 127.0.0.1:4216 (start FIFA 17 now)") while True: c, a = s.accept() print("[lsx] connection from", a) threading.Thread(target=serve, args=(c,), daemon=True).start() if __name__ == "__main__": main()