#!/usr/bin/env python3 """FIFA17 Blaze redirector + SESSION SERVER (v3b) -- offline forged authentication. WHAT IS NEW vs v3 (this file; applies the adversarial-verify blocker/major fixes) --------------------------------------------------------------------------------- * BLOCKER: blazesdk_config() now serves the four client-id keys (blazeSdkClientId/blazeServerClientId/blazeSdkClientSecret/identityRedirectUri). Without a non-empty blazeSdkClientId, OriginRequestAuthCodeSync rejects the request before any LSX GetAuthCode is issued. (Also mirrored into OSDK_NUCLEUS.) * BLOCKER: implemented getAccount (1/0x1E) -> AccountInfo (16 members, reversed from descriptor 0x14487c810), getPersona (1/0x5A) -> GetPersonaResponse and listPersonas (1/0x64) -> ListPersonasResponse. Previously these fell through to an empty reply and reproduced the account-info popup one layer later. * MAJOR: dropped 9 PHANTOM OSDK config keys (not string literals in FIFA17.exe); added the real SV_ENABLE_SERVER_VERSIONING/SV_CLIENT_CHANGELIST/SV_SERVER_VERSION to OSDK_CORE (read by LoginStateVersionCheck, state 700); pointed the real NUCLEUS_CREATE_URL/NUCLEUS_ADDED_URL at the local stub; added the netres CFID. * MINOR: UserIdentification.ALOC now echoes sess.account_locale (was a hardcoded fallback, could diverge from UserAuthenticated for a non-enUS client). * The logout (1/0x46) log no longer shouts "FAILURE SIGNAL": it is the normal LoginStateLogout (state 500) between Connect (400) and PCLogin (800). WHAT IS NEW vs v2 ----------------- * Util::fetchClientConfig (9/1) answered for real, per-CFID, with a proper FetchConfigResponse{CONF: map}. Unknown CFID -> EMPTY MAP (a present-but-empty CONF), never an empty frame. * Authentication (component 0x0001): login (1/0x0A) -> forged offline LoginResponse (persona 33068179/CAGE) logout (1/0x46) -> empty REPLY, and logged as a FAILURE SIGNAL getAuthToken (1/0x24), listUserEntitlements2 (1/0x1D) * UserSessions (0x7802) NOTIFICATIONs pushed unsolicited: 0x0008 UserAuthenticated (Blaze::UserSessionLoginInfo) 0x0001 UserSessionExtendedDataUpdate 0x0002 UserAdded (Blaze::UserData) * Util::postAuth (9/8), setClientState (9/0x1C), userSettingsLoad (9/0x0A), setClientMetrics (9/0x16), AssociationLists::getLists (25/6), UserSessions::updateNetworkInfo (0x7802/0x14). * Optional local Nucleus OAuth stub on 42131 serving POST /connect/token, with nucleusConnect / nucleusConnectTrusted in the BlazeSDK config pointed at it. * PingResponse now carries ONLY STIM (FIFA 17's PingResponse @0x144875560 has exactly one member; v2's extra TIME was Mirror's-Edge-Catalyst's field). TWO-LAYER ORDERING -- READ THIS FIRST ------------------------------------- This file is layer 2. Layer 1 is Origin/LSX on 127.0.0.1:4216. The client's `origin.nav` gates FUT on `OriginIsOnlineTrue` and only then runs `futBlazeLogin`; without a layer-1 auth code the client sends `Authentication::logout` (1/0x46) instead of `login` and shows "Unable to connect to the EA Servers ... log in to Origin in Online Mode". Start `lsx_responder.py` (before FIFA) or apply `lsx_force_online.py` FIRST. Receiving 1/0x46 here means layer 1 is still broken. CLEAN ROOM. Every TDF tag/type below comes from FIFA17.exe's own in-process TDF reflection metadata that we walked in live memory, plus our own captured wire bytes. Independent third-party clean-room BlazeSDK-15.x reimplementations were consulted only to cross-check *structure*. No EA/FIFA leaked source was used. Run: python3 blaze_responder_v3.py (binds 42127 + 42130 [+ 42131]) Selftest: python3 blaze_responder_v3.py --selftest Log: /tmp/blaze_responder.log Frames: /tmp/blaze_rx/ """ from __future__ import annotations import binascii import json import os import random import socket import ssl import string import struct import sys import threading import time from collections import OrderedDict sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import heat2 # noqa: E402 from heat2 import ( # noqa: E402 INT, STRING, BLOB, STRUCT, LIST, MAP, encode_tdf, decode_tdf, ) # ================================================================== identity # SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py. # Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language). # A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26), # AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND. PERSONA_ID = 33068179 PERSONA_NAME = "CAGE" USER_ID = 33068179 # blazeId / userId; same value keeps BUID==UID==PID EXT_ID = 33068179 # XREF externalId EMAIL = "cage@openfut.local" PERSONA_NAMESPACE = "cem_ea_id" # must equal PreAuthResponse.NASP CLIENT_PLATFORM = 4 # Blaze::ClientPlatformType -> pc PERSONA_STATUS = 2 # PersonaStatus::Code -> ACTIVE (verified live: table 0x14487ad20, ACTIVE==2) USER_SESSION_TYPE = 0 # Blaze::UserSessionType -> normal/console user ACCOUNT_LOCALE_FALLBACK = 0x656E5553 # 'enUS'; overwritten by the client's own # PreAuthRequest LANG/LOC when we see it. CONTENT_ID = "1027460" # FIFA 17 EA offer id (retail) ENTITLEMENT_TAG = "ONLINE_ACCESS" # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe ENTITLEMENT_GROUP = "FIFA17PCBoxContent" # was "FIFA17PC" -> matched NEITHER strstr # needle in EntitlementComponent::onListEntitlements (0x146f27440): FUT keeps an # entitlement only if GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC" (needles # @0x144334030), TAG non-empty, STAT==1. "FIFA17PC" survived none -> empty store. TITLE_ID = "309111" CLIENT_ID = "FIFA17-PC-SERVER-BLAZE" PLATFORM = "pc" SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" # ================================================================== config HOST = "127.0.0.1" REDIR_PORT = 42127 BLAZE_PORT = 42130 NUCLEUS_PORT = 42131 BLAZE_IP_STR = "127.0.0.1" BLAZE_IP_U32 = (127 << 24) | 1 LOG = "/tmp/blaze_responder.log" RXDIR = "/tmp/blaze_rx" HERE = os.path.dirname(os.path.abspath(__file__)) CERT = os.path.join(HERE, "redir_cert.pem") KEY = os.path.join(HERE, "redir_key.pem") # Serve a local OAuth stub and advertise it as nucleusConnect. The client's # LoginStateMachineImpl builds "/connect/token", POSTs # grant_type=client_credentials, and scrapes '"access_token" : "'. NUCLEUS_STUB_ENABLED = True EMIT_NUCLEUS_URLS = True NUCLEUS_BASE = "http://%s:%d" % (HOST, NUCLEUS_PORT) # Any RPC we do not implement still gets an empty REPLY so the client's request # never times out. Flip to False to find out what it truly blocks on. REPLY_EMPTY_TO_UNKNOWN = True # Push the UserAuthenticated notification BEFORE writing the login reply # (grid-blaze order) or after (pamplona order). Both are reported to work. NOTIFY_BEFORE_LOGIN_REPLY = False DUMP_FRAMES = True _log_lock = threading.Lock() def log(m: str) -> None: line = "[%s] %s" % (time.strftime("%H:%M:%S"), m) with _log_lock: print(line, flush=True) try: with open(LOG, "a") as fh: fh.write(line + "\n") except Exception: pass def hexdump(b: bytes, limit: int = 512) -> str: out = [] for i in range(0, min(len(b), limit), 16): chunk = b[i:i + 16] txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk) out.append(" %04x: %-47s %s" % (i, binascii.hexlify(chunk, " ").decode(), txt)) if len(b) > limit: out.append(" ... (%d more bytes)" % (len(b) - limit)) return "\n".join(out) # ================================================================== Fire2 # # CORRECTED 16-byte big-endian header (heat2.build_fire2_frame / # heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them): # # [0:4] u32 payload length # [4:6] u16 metadata length # [6:8] u16 component # [8:10] u16 command (== notification id on NOTIFICATION) # [10:13] u24 msgNum # [13] u8 (msgType << 5) | (userIndex & 0x1F) # [14] u8 options # [15] u8 reserved # wire = header(16) || metadata || payload # # There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame). FIRE2_HDR = 16 MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6) MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION", 3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"} COMP_AUTH = 0x0001 COMP_GAMEMANAGER = 0x0004 COMP_REDIRECTOR = 0x0005 COMP_STATS = 0x0007 COMP_UTIL = 0x0009 COMP_MESSAGING = 0x000F COMP_ASSOCLISTS = 0x0019 COMP_GAMEREPORTING = 0x001C COMP_USERSESSIONS = 0x7802 COMP_CENSUSDATA = 0x000A # id built at 0x147e639cd; "CensusDataComponent" @0x143b72368 # CensusData (0x000A) command table, from getCommandName @0x147e64170. CMD_SUBSCRIBETOCENSUSDATAUPDATES = 0x0005 CENSUSDATA_CMDS = { 0x01: "subscribeToCensusData", 0x02: "unsubscribeFromCensusData", 0x03: "getRegionCounts", 0x04: "getLatestCensusData", 0x05: "subscribeToCensusDataUpdates", } NOTIFY_SERVER_CENSUS_DATA = 0x0001 # getNotificationName @0x147e645e0 # ---- Util (0x0009) command table, recovered from the binary's own # getCommandName switch (jump table 0x141b17af4). CMD_FETCHCLIENTCONFIG = 0x0001 CMD_PING = 0x0002 CMD_PREAUTH = 0x0007 CMD_POSTAUTH = 0x0008 CMD_USERSETTINGSLOAD = 0x000A CMD_USERSETTINGSSAVE = 0x000B CMD_SETCLIENTMETRICS = 0x0016 CMD_SETCLIENTSTATE = 0x001C UTIL_CMDS = { 0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData", 0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer", 0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad", 0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll", 0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser", 0x14: "filterForProfanity", 0x15: "fetchQosConfig", 0x16: "setClientMetrics", 0x17: "setConnectionState", 0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing", 0x1C: "setClientState", } # ---- Authentication (0x0001) command table. Recovered by CALLING the client's # own getCommandName (0x146e0d2a0) in-process over ids 1..320 -- the name # pool is Denuvo-mutated and statically unrecoverable. Validated against # Util (reproduced preAuth=7/ping=2/fetchClientConfig=1) and cross-checked # against a static REST-binding struct (0x143896a80 -> trustedLogin=0x0B). CMD_LOGIN = 0x000A CMD_TRUSTEDLOGIN = 0x000B CMD_GETACCOUNT = 0x001E CMD_LISTUSERENTITLEMENTS2 = 0x001D CMD_GETAUTHTOKEN = 0x0024 CMD_EXPRESSLOGIN = 0x003C CMD_LOGOUT = 0x0046 # <-- the "we gave up" RPC, NOT a login CMD_GETPERSONA = 0x005A CMD_LISTPERSONAS = 0x0064 AUTH_CMDS = { 0x0A: "login", 0x0B: "trustedLogin", 0x14: "updateAccount", 0x15: "upgradeAccount", 0x1D: "listUserEntitlements2", 0x1E: "getAccount", 0x1F: "grantEntitlement", 0x20: "listEntitlements", 0x22: "getUseCount", 0x23: "decrementUseCount", 0x24: "getAuthToken", 0x26: "getPasswordRules", 0x27: "grantEntitlement2", 0x2B: "modifyEntitlement2", 0x2C: "consumecode", 0x2D: "passwordForgot", 0x2F: "getPrivacyPolicyContent", 0x30: "listPersonaEntitlements2", 0x33: "checkAgeReq", 0x34: "getOptIn", 0x35: "enableOptIn", 0x36: "disableOptIn", 0x3C: "expressLogin", 0x46: "logout", 0x5A: "getPersona", 0x64: "listPersonas", 0x65: "expressCreateAccount", 0xE6: "createWalUserSession", 0xF1: "acceptLegalDocs", 0xF2: "getEmailOptInSettings", 0xF6: "getTermsOfServiceContent", 0x104: "getOriginPersona", 0x10E: "checkEmail", 0x118: "getPersonaNameSuggestions", 0x122: "guestLogin", } # ---- UserSessions (0x7802). Commands and NOTIFICATIONS live in separate # number spaces. Notification ids decoded statically from the client's # own getNotificationName jump table at 0x141b03f70 (clean, unmutated). NOTIFY_USER_EXTENDED_DATA_UPDATE = 0x0001 NOTIFY_USER_ADDED = 0x0002 NOTIFY_USER_REMOVED = 0x0003 NOTIFY_USER_UPDATED = 0x0005 NOTIFY_USER_AUTHENTICATED = 0x0008 NOTIFY_USER_UNAUTHENTICATED = 0x0009 NOTIFY_SERVER_DRAINING = 0x000C USERSESSIONS_NOTIFY_NAMES = { 0x01: "UserSessionExtendedDataUpdate", 0x02: "UserAdded", 0x03: "UserRemoved", 0x05: "UserUpdated", 0x08: "UserAuthenticated", 0x09: "UserUnauthenticated", 0x0C: "ServerDraining", } CMD_UPDATENETWORKINFO = 0x0014 # UserSessions command space CMD_GETLISTS = 0x0006 # AssociationLists COMP_NAMES = { COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager", COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util", COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists", COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions", COMP_CENSUSDATA: "CensusData", 0x000B: "Clubs", 0x081C: "SponsoredEvents", 0x08C9: "OSDKSettings", } def rpc_name(component: int, command: int, msg_type: int = MESSAGE) -> str: comp = COMP_NAMES.get(component, "Component:0x%04x" % component) if component == COMP_USERSESSIONS and msg_type == NOTIFICATION: cmd = USERSESSIONS_NOTIFY_NAMES.get(command, "notify:0x%04x" % command) return "%s::<%s>" % (comp, cmd) if component == COMP_UTIL: cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command) elif component == COMP_AUTH: cmd = AUTH_CMDS.get(command, "cmd:0x%04x" % command) elif component == COMP_CENSUSDATA: cmd = CENSUSDATA_CMDS.get(command, "cmd:0x%04x" % command) else: cmd = "cmd:0x%04x" % command return "%s::%s" % (comp, cmd) def fire2(component: int, command: int, msg_num: int, msg_type: int, payload: bytes = b"", metadata: bytes = b"", user_index: int = 0, options: int = 0) -> bytes: h = bytearray(16) struct.pack_into(">I", h, 0, len(payload)) struct.pack_into(">H", h, 4, len(metadata)) struct.pack_into(">H", h, 6, component & 0xFFFF) struct.pack_into(">H", h, 8, command & 0xFFFF) h[10] = (msg_num >> 16) & 0xFF h[11] = (msg_num >> 8) & 0xFF h[12] = msg_num & 0xFF h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F) h[14] = options & 0xFF h[15] = 0 return bytes(h) + metadata + payload def parse_fire2_header(buf: bytes) -> dict: return dict( payload_len=struct.unpack_from(">I", buf, 0)[0], metadata_len=struct.unpack_from(">H", buf, 4)[0], component=struct.unpack_from(">H", buf, 6)[0], command=struct.unpack_from(">H", buf, 8)[0], msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12], msg_type=(buf[13] >> 5) & 0x07, user_index=buf[13] & 0x1F, options=buf[14], reserved=buf[15], ) def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes: """A Blaze reply echoes component/command/msgNum/userIndex verbatim and only overwrites the msgType bits (byte[13] = 0x20 for REPLY + userIndex 0).""" return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type, payload, user_index=hdr["user_index"]) def notification(component: int, notify_id: int, payload: bytes = b"", user_index: int = 0) -> bytes: """Unsolicited server push: msgType = NOTIFICATION (2) -> byte[13] = 0x40, msgNum = 0 (notifications are not correlated to a request).""" return fire2(component, notify_id, 0, NOTIFICATION, payload, user_index=user_index) # ================================================================== session class Session(object): """Per-connection forged session state.""" def __init__(self): self.session_key = make_session_key() self.auth_code = "" # whatever LoginRequest.AUTH carried self.account_locale = ACCOUNT_LOCALE_FALLBACK self.service_name = "fifa-2017-pc" self.logged_in = False self.login_time = 0 def make_session_key() -> str: """Real Blaze session keys look like <16 hex>_<44 base64-ish chars>. The client never validates it -- grid-blaze literally ships "0" -- but the SAME string must appear in LoginResponse.SESS.KEY and in the UserAuthenticated notification's KEY, so we mint it once per session.""" alpha = string.ascii_letters + string.digits + "$*" return ("%016x_" % random.getrandbits(64)) + \ "".join(random.choice(alpha) for _ in range(44)) # ============================================== Util::fetchClientConfig (9/1) # # Response type: Blaze::Util::FetchConfigResponse @0x1448752e0 -- a SINGLE # member `CONF` : map. NOT double-nested: the extra nesting only # exists inside PreAuthResponse, where CONF is itself a FetchConfigResponse # whose own single member is also called CONF. Easy to get wrong. # Keys verified present as string literals in FIFA17.exe (owner in comment). # Anything not in the binary is silently ignored, so the map is kept minimal. # All time values are MICROSECONDS -- the client divides by 1000 to get ms. def blazesdk_config() -> list: cfg = [ # --- BLOCKER FIX (verify: identity-and-next-gate) --------------------- # OriginRequestAuthCodeSync (real impl 0x1470e67f0) hard-rejects an empty # ClientId BEFORE any LSX GetAuthCode is issued: # 1470e6831: test r8,r8 / je (NULL clientId) # 1470e683a: cmp BYTE [r8],0 / je (empty clientId) # Its caller 0x147237440 sources the clientId from THIS BlazeSDK config # map: 147237484 lea rdx,->"blazeSdkClientId"; call [rax+0x48]. Without # a non-empty value here, flipping m_isLoggedIn alone can NEVER produce a # GetAuthCode. All four key names verified present as string literals in # FIFA17.exe (blazeSdkClientId@0x1439726a8, blazeServerClientId@0x143972690, # blazeSdkClientSecret@0x1439726c0, identityRedirectUri@0x1439726d8). # Non-empty is the only hard requirement; the actual value the client # presents will be echoed back to /tmp/openfut_lsx_clientid.txt. ("blazeSdkClientId", "FIFA17PC"), ("blazeServerClientId", "FIFA17PC-SERVER"), ("blazeSdkClientSecret", "openfut-secret"), ("identityRedirectUri", "http://127.0.0.1/login_successful.html"), # --------------------------------------------------------------------- # CONF value TYPES matter. Keys read via ConnMgr vt+0x50 (0x146e1bb80) go # through atoi -> plain integers are fine. Keys read via vt+0x58 (0x146e1bda0) # go through TimeValue::parse (0x1479b2d50), which REQUIRES a unit suffix # (y/d/h/m/ms/s) and silently yields 0 for a bare integer (0x146e1bdca discards # the parser's bool). Sending "30000000" landed defaultRequestTimeout=0 and # connIdleTimeout=0 -> every Blaze conn died at first idle -> "Unable to connect # to the EA servers" on the go-online preAuth. Durations MUST be unit-suffixed. ("associationListSkipInitialSet", "1"), # 0x143b6eb88 AssocListAPI (atoi) ("autoReconnectEnabled", "1"), # 0x1438a0a68 ConnMgr (atoi) ("connIdleTimeout", "90s"), # 0x1438a0a58 TimeValue -> 90000ms ("defaultRequestTimeout", "30s"), # 0x1438a0a40 TimeValue -> 30000ms ("enableQosBandwidthTest", "false"), # 0x1438a0a08 clears bit1 ("enableQosFirewallTest", "false"), # 0x1438a09f0 clears bit0 ("maxReconnectAttempts", "5"), # 0x1438a0a80 ConnMgr (atoi) ("pingPeriod", "20s"), # 0x1438a0a30 TimeValue -> 20000ms ("userManagerMaxCachedUsers", "128"), # UserManager ("voipHeadsetUpdateRate", "0"), # VoIP ] if EMIT_NUCLEUS_URLS: # LoginStateMachineImpl (0x14389fd50-0x14389fef8) builds # "/connect/token", POSTs grant_type=client_credentials, # and scrapes '"access_token" : "' out of the reply. Point it at our own # stub (see nucleus_handle) so it can never reach a real EA host. cfg += [ ("nucleusConnect", NUCLEUS_BASE), # 0x14389fef8 ("nucleusConnectTrusted", NUCLEUS_BASE), # 0x14389fdf8 ] return sorted(cfg) # The OSDK_* sections are NOT Blaze plumbing. They are FIFA's own OSDK # (8.01.03.00-fifa.01) ResourceLoader tuning maps; every key falls back to a # built-in default, which is why our empty replies did not by themselves kill # the login. Answering them non-empty is cheap insurance and removes a # variable. Key names are literals observed in FIFA17.exe. OSDK_CORE = [ ("OSDK_PRESENCE_DELAY", "5"), # verified string literal in image ("OSDK_PRESENCE_POLL", "60"), # verified ("OSDK_ANTIGRIEFING_MAX_COUNT", "0"), # verified ("OSDK_ARENA_ENABLED", "0"), # verified # --- MAJOR FIX (verify: identity-and-next-gate) ---------------------- # LoginStateVersionCheck (OSDK state 700, runs right after the normal # Logout 500) reads these three SV_* keys; a version mismatch trips the # "Client/server version mismatch!" string @0x14395d1d0. All three are # REAL string literals in FIFA17.exe (SV_ENABLE_SERVER_VERSIONING # @0x14395d148 xref 0x14717f8fa, SV_CLIENT_CHANGELIST @0x14395d168, # SV_SERVER_VERSION @0x14395d180). Disable server versioning so the # check cannot fail. ("SV_ENABLE_SERVER_VERSIONING", "0"), ("SV_CLIENT_CHANGELIST", "0"), ("SV_SERVER_VERSION", "0"), ] OSDK_CLIENT = [ ("OSDK_CLUBS_MAX_SEARCH_RESULT", "50"), ("OSDK_CLUBS_LOAD_MEMBER_PAGE_SIZE", "25"), ("OSDK_CLUBS_MAX_USERS_FOR_GAME", "22"), ("OSDK_CLUBS_LEADERBOARD_CLUB_MAX", "100"), ("OSDK_CLUBS_INCOME_SEARCH_MAX", "100"), ] # --- MAJOR FIX (verify: identity-and-next-gate) -------------------------- # The four OSDK_NUCLEUS_* keys below were PHANTOM -- none of them exist as # string literals in FIFA17.exe, so serving them was cosmetic (the log said # "4 key(s)" but the client ignored every one). Dropped. The only real # NUCLEUS_*_URL literals in the image are NUCLEUS_CREATE_URL (0x14395eb60) # and NUCLEUS_ADDED_URL (0x14395ebb0); point them at the local stub. The four # blazeSDK client-id keys are mirrored here as zero-cost insurance in case the # client sources ClientId from this CFID rather than BlazeSDK (which CFID is # authoritative is an inference; the LSX log will report which value wins). OSDK_NUCLEUS = [ ("NUCLEUS_CREATE_URL", NUCLEUS_BASE), # verified literal @0x14395eb60 ("NUCLEUS_ADDED_URL", NUCLEUS_BASE), # verified literal @0x14395ebb0 ("blazeSdkClientId", "FIFA17PC"), ("blazeServerClientId", "FIFA17PC-SERVER"), ("blazeSdkClientSecret", "openfut-secret"), ("identityRedirectUri", "http://127.0.0.1/login_successful.html"), ] # OSDK_WEBOFFER_ENABLED / OSDK_WEBOFFER_URL were BOTH phantom (absent in the # image). Nothing real to serve here -> empty map (still a present CONF). OSDK_WEBOFFER = [] # OSDK_ABUSE_REPORTING_ENABLED was phantom; OSDK_ABUSE_NUM_TYPES is real. OSDK_ABUSE_REPORTING = [ ("OSDK_ABUSE_NUM_TYPES", "0"), # verified literal in image ] # OSDK_TICKER_ENABLED was phantom -> empty map. OSDK_TICKER = [] # --- FUT LOADING GATE (COMPONENT: FUT_LOADING_PLAN.md) -------------------- # FIFA fetches fetchClientConfig(CFID='OSDK_ROSTER') at FUT entry (RX #22). # ROSTERUPDATE_URL is the ONLY remaining source for the FUT roster-XML URL -- # the two ini keys (FUT/ROSTERUPDATE_URL @0x143af1810, ROSTERUPDATE_URL # @0x143af1828) are absent on disk, so the resolver 0x147a77410 falls through to # cfg->getString("ROSTERUPDATE_URL","",...) @0x147a77562; an EMPTY result makes # 0x147a77577 return WITHOUT issuing the HTTP request -> flow CheckFUTRosterUpdateXML # never gets advance/back -> the silent FUT loading-screen hang. The store is the # MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this # branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url. # Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject # http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK. ROSTER_HOST = "127.0.0.1:8081" OSDK_ROSTER = [ ("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST), ("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0 ("ROSTER_VER", "0"), # @0x143973ab0 ("ROSTER_CSUM", ""), # @0x143973ad0 ] # "netres" is itself a CFID (config section) -- it sits immediately before # OSDK_CORE in the CFID-name table @0x143962be0, so the client may request # fetchClientConfig(CFID="netres"). We have no verified keys for it; serve a # present-but-empty map (unknown CFID would already do this, but make it # explicit so the log shows it was handled, not defaulted). OSDK_NETRES = [] # Not requested by FIFA 17 in our capture, but both independent clean-room # emulators answer it identically; harmless to have ready. IDENTITY_PARAMS = [ ("display", "console2/welcome"), ("redirect_uri", "http://127.0.0.1/success"), ] CLIENT_CONFIGS = { "BlazeSDK": None, # built dynamically, see below "netres": OSDK_NETRES, # CFID (verified @0x143962be0) "OSDK_CORE": OSDK_CORE, "OSDK_CLIENT": OSDK_CLIENT, "OSDK_NUCLEUS": OSDK_NUCLEUS, "OSDK_WEBOFFER": OSDK_WEBOFFER, "OSDK_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, "OSDK_XMS_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, "OSDK_TICKER": OSDK_TICKER, "OSDK_ROSTER": OSDK_ROSTER, # FUT roster-XML URL (loading gate) "IdentityParams": IDENTITY_PARAMS, } # --- FUT / UTAS (RS4) BASE URL (FUT_UTAS_PLAN.md) -------------------------- # CardsDLL RS4::ServerSettings::resolve @0x180124270 sets each of the 125 endpoint # descriptors' baseUrl in passes, last non-empty wins: (1) hardcoded default # "http://easw.easports.com:8099/" (DEAD host -> "error connecting"); (2) cfg # ["FUT_RS4_APIURL_"]; (3) cfg["FUT_RS4_URL_"]. Value used # VERBATIM when it has "://" + a trailing '/' forced. Belt-and-braces alongside the # /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/" # (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT # (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code). UTAS_BASE = "http://127.0.0.1:8099/" FUT_RS4_MODULES = [ "AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD", "DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER", "DELETEUSER", "ITEMS", "ITEMS_BY_RES", "DELETEITEMS", "MATCH", "SBC", "TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASON", "SEASONUSER", "SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE", "WATCHLIST", "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE", "MARKETDATA", "CLIENTDATA", "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA", "TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE", "ADMIN", "DEBUG", "MAINTENANCE", ] FUT_RS4_CALLS_BOOT = [ "GETSETTINGS", "AUTHENTICATION", "LOGIN", "LOGOUT", "CREATEUSER", "GETUSERINFO", "GETUSERDATA", "GETUSERCREDITS", "USERRELIABILITYINFO", "GETHUBDATA", "GETUSERMASSINFO", "LOADACTIVESQUAD", "SQUADLIST", "GETSQUADINFO", "GETCLUBINFO", "KEEPALIVE", "SEASONHISTORY", ] FUT_RS4_CONFIG = ( [("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES] + [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS_BOOT] + [("FUT_RS4_BASE_URL", UTAS_BASE)] # STORE gate: FIFA shows "store not available" unless these config flags are # true. The store-screen entitlement checks (CardsDLL 0x18001749d vtable+0x138 / # 0x1800175a2 vtable+0x280) read the IS_*/*_PURCHASE_ENABLED booleans -- SEPARATE # from storeEnabled. Full confirmed list from ENDPOINT_MAP store ยง (grepped in # cardsdll.strings). (Also gated by GetSystemMetrics > 1024x768, client-side.) + [(k, "1") for k in ( "storeEnabled", "cardPackStoreEnabled", "pointsPackStoreEnabled", "cardPackStoreEnabled_JP", "coinEnabled", "coinEnabled_JP", "IS_STORE_ENABLED", "IS_COIN_PURCHASABLE", "IS_FIFAPOINT_AVAILABLE", "IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY", "COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED", )] # NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any # response -- proven inert (wf_96b6c0c5): they are JSON field names that route # to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md. ) def client_config_for(cfid: str) -> list: """-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we still wrap in a present CONF field -- never an empty frame). FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section CardsDLL reads is unproven, so serve them everywhere).""" if cfid == "BlazeSDK": return sorted(blazesdk_config() + FUT_RS4_CONFIG) return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG) def fetch_config_response_fields(cfid: str) -> "OrderedDict": """Blaze::Util::FetchConfigResponse -- single member CONF : map.""" return OrderedDict([ ("CONF", (MAP, (STRING, STRING, client_config_for(cfid)))), ]) # ================================================== Util::preAuth (9/7) reply COMPONENT_IDS = [ COMP_AUTH, COMP_GAMEMANAGER, COMP_REDIRECTOR, COMP_STATS, COMP_UTIL, COMP_MESSAGING, COMP_ASSOCLISTS, COMP_GAMEREPORTING, COMP_USERSESSIONS, ] def qos_config() -> "OrderedDict": """Blaze::QosConfigInfo -- 4 members per reflection (FIFA 17's descriptor has NO SVID, unlike Mirror's Edge Catalyst).""" return OrderedDict([ ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo ("PSA", (STRING, "127.0.0.1")), ("PSP", (INT, 17502)), ]))), ("LNP", (INT, 10)), ("LTPS", (MAP, (STRING, STRUCT, []))), ("TIME", (INT, 5000000)), ]) def subscribe_census_data_updates_response_fields() -> "OrderedDict": """Blaze::CensusData::SubscribeToCensusDataUpdatesResponse (classinfo 0x144c11dc0, tdfId 0x746128a3, 3 members, ALL TDF type 0x0e TimeValue -> Heat2 INT varint of MICROSECONDS). The client reply cb 0x147e57050 computes delay_ms = (CNP + NTMT) / 1000 and arms a FunctorJob (0x147e57380) that re-sends this RPC with RSUB=1. All three default to 0, so our old EMPTY reply gave delay=0 -> the scheduler's zero-delay branch (0x146dbae7c) put it on the READY list -> one re-subscribe per idle tick = the observed ~30/s storm. 30s+90s -> the client re-subscribes every 120s instead. Tags ascend (8eec00 < bb4b74 < cb4b74) = correct Heat2 order.""" return OrderedDict([ ("CNP", (INT, 30 * 1000000)), # censusNotificationPeriod ("NTMT", (INT, 90 * 1000000)), # notificationTimeout ("RTMT", (INT, 300 * 1000000)), # resubscribeTimeout (error path only) ]) def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict": return OrderedDict([ ("ASRC", (STRING, TITLE_ID)), # authenticationSource ("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds ("CLID", (STRING, CLIENT_ID)), # clientId ("CONF", (STRUCT, fetch_config_response_fields("BlazeSDK"))), ("ESRC", (STRING, TITLE_ID)), # entitlementSource ("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN ("MAID", (INT, 0)), # machineId ("MINR", (INT, 0)), # underageSupported = false ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace ("PILD", (STRING, "")), # legalDocGameIdentifier ("PLAT", (STRING, PLATFORM)), # platform ("QOSS", (STRUCT, qos_config())), # qosSettings ("RSRC", (STRING, TITLE_ID)), # registrationSource ("SVER", (STRING, SERVER_VERSION)), # serverVersion ]) def ping_response_fields() -> "OrderedDict": """Blaze::Util::PingResponse @0x144875560 has EXACTLY ONE member: STIM (serverTime, uint32). v2 also sent TIME -- that is MEC's field, not FIFA 17's. Dropped.""" return OrderedDict([("STIM", (INT, int(time.time())))]) # ==================================== Authentication::login (1/0x0A) -- FORGED # # Blaze::Authentication::LoginResponse @0x14487d170 -- EXACTLY 5 members. # NOTE the divergence from both MEC emulators: they emit CNTX, ERRC and a # top-level SKEY. FIFA 17's LoginResponse has NONE of those -- CNTX/ERRC are # the Blaze *error metadata* block, and the session key lives at SESS.KEY. def persona_details_fields(now: int) -> "OrderedDict": """Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members.""" return OrderedDict([ ("DSNM", (STRING, PERSONA_NAME)), # displayName MUST be "CAGE" ("LAST", (INT, now)), # lastAuthenticated uint32 ("PID", (INT, PERSONA_ID)), # personaId int64 MUST be 33068179 ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform enum -> pc ("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE ("XREF", (INT, EXT_ID)), # extId uint64 ]) def user_login_info_fields(sess: Session, now: int) -> "OrderedDict": """Blaze::Authentication::UserLoginInfo @0x14487cb00 -- 8 members. ('1CON' packs to 0x11 which sorts BELOW 'A'=0x21, so it is first.)""" return OrderedDict([ ("1CON", (INT, 0)), # isFirstConsoleLogin = false ("BUID", (INT, USER_ID)), # blazeUserId -- MUST be != 0 ("FRST", (INT, 0)), # isFirstLogin = false ("KEY", (STRING, sess.session_key)), # sessionKey -- MUST be non-empty ("LLOG", (INT, now)), # lastLoginDateTime ("MAIL", (STRING, EMAIL)), # email ("PDTL", (STRUCT, persona_details_fields(now))), ("UID", (INT, USER_ID)), # userId -- MUST be != 0 ]) def login_response_fields(sess: Session) -> "OrderedDict": """Blaze::Authentication::LoginResponse @0x14487d170 -- 5 members only.""" now = int(time.time()) return OrderedDict([ ("ANON", (INT, 0)), # isAnonymous -- 1 would give a guest session ("NTOS", (INT, 0)), # needsLegalDoc -- 1 diverts to the legal-doc flow ("SESS", (STRUCT, user_login_info_fields(sess, now))), ("SPAM", (INT, 1)), # isOfLegalContactAge ("UNDR", (INT, 0)), # isUnderage -- 1 strips online features ]) # =========================== Authentication::getAccount (1/0x1E) -- FORGED # # BLOCKER FIX (verify: identity-and-next-gate). getAccount was NOT implemented # (it fell through to REPLY_EMPTY_TO_UNKNOWN -> an AccountInfo with UID=0/CO=""), # which reproduces the "Unable to retrieve account information" popup one layer # later. Blaze::Authentication::AccountInfo @0x14487c810 has EXACTLY 16 members, # reversed byte-for-byte from FIFA17.exe's own reflection metadata: # * class name + member count (16) read from the class descriptor header; # * member tags read from the tag table @0x1448775a0 (stride 0x30, tag # immediate at entry+0x20 in the 0xTTTTTT00 packing); # * per-member WIRE TYPE read from each member's subtype-descriptor pointer: # subtype 0x144867628 == string -> ASRC CO DOB DTCR LATH LN MAIL PML (8) # int / enum subtypes -> AMU GOPT RC STAS STAT TPOT UDU UID (8) # Enum values resolved from the in-image enum tables: # STAS = AccountStatus::Code ACTIVE = 1 (table 0x14487a6c0) # STAT = EmailStatus::Code VERIFIED = 2 (table 0x14487aa00) # RC = StatusReason::Code none = 0 # Ascending packed-tag order (what heat2 emits): AMU ASRC CO DOB DTCR GOPT LATH # LN MAIL PML RC STAS STAT TPOT UDU UID. IDENTITY: MAIL/UID/ASRC MUST byte-match # LoginResponse.SESS.MAIL/UID and PreAuthResponse.NASP. Empty request. ACCOUNT_LOCALE_STR = "en_US" # AccountInfo.LN (language) def account_info_fields(sess: "Session", now: int) -> "OrderedDict": day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) return OrderedDict([ ("AMU", (INT, 0)), # (int) ("ASRC", (STRING, PERSONA_NAMESPACE)), # == PreAuthResponse.NASP ("CO", (STRING, "US")), # country ("DOB", (STRING, "1990-01-01T00:00:00Z")), # dateOfBirth (ISO-8601) ("DTCR", (STRING, "2016-09-01T00:00:00Z")), # dateCreated (ISO-8601) ("GOPT", (INT, 0)), # globalOptin ("LATH", (STRING, day)), # lastAuth (ISO-8601) ("LN", (STRING, ACCOUNT_LOCALE_STR)), # language ("MAIL", (STRING, EMAIL)), # == LoginResponse.SESS.MAIL ("PML", (STRING, "")), # parentalEmail ("RC", (INT, 0)), # StatusReason::Code ("STAS", (INT, 1)), # AccountStatus ACTIVE = 1 ("STAT", (INT, 2)), # EmailStatus VERIFIED = 2 ("TPOT", (INT, 0)), # thirdPartyOptin ("UDU", (INT, 0)), # underageUser ("UID", (INT, USER_ID)), # == LoginResponse.SESS.UID ]) # ============= Authentication::getPersona (1/0x5A) / listPersonas (1/0x64) # # Blaze::Authentication::PersonaInfo @0x14487c7c0 -- 7 members (tags from the # reflection table @0x144877430): DSNM DTCR LADT NSNM PID STAS STRC. DSNM/DTCR/ # NSNM share the string subtype 0x144867628; PID is int; STAS is # PersonaStatus::Code (ACTIVE = 2, table 0x14487ad20); STRC is StatusReason::Code. # LADT (lastAuthenticated) has a distinct subtype -- emitted as an INT timestamp # (best-guess; only reached via getPersona/listPersonas, off the critical # getAccount path, so a wrong type here cannot re-raise the account-info popup). def persona_info_fields(now: int) -> "OrderedDict": return OrderedDict([ ("DSNM", (STRING, PERSONA_NAME)), # displayName "CAGE" ("DTCR", (STRING, "2016-09-01T00:00:00Z")), # dateCreated (string) ("LADT", (INT, now)), # lastAuthenticated (best-guess int) ("NSNM", (STRING, PERSONA_NAMESPACE)), # nameSpaceName "cem_ea_id" ("PID", (INT, PERSONA_ID)), # personaId 33068179 ("STAS", (INT, 2)), # PersonaStatus ACTIVE = 2 ("STRC", (INT, 0)), # statusReasonCode ]) def get_persona_response_fields(now: int) -> "OrderedDict": """Blaze::Authentication::GetPersonaResponse @0x14487d1c0 -- 2 members: PINF (PersonaInfo) + UID (int).""" return OrderedDict([ ("PINF", (STRUCT, persona_info_fields(now))), ("UID", (INT, USER_ID)), ]) def list_personas_response_fields(now: int) -> "OrderedDict": """Blaze::Authentication::ListPersonasResponse @0x14487d210 -- 1 member: PINF (list).""" return OrderedDict([ ("PINF", (LIST, (STRUCT, [persona_info_fields(now)]))), ]) # ============================ UserSessions notifications (component 0x7802) # # Authentication (0x0001) publishes NO notifications at all -- its +0x28 slot is # getRestResourceInfo, not getNotificationName. The login-success notification # lives on UserSessions, whose getNotificationName (0x146de19a0) is clean, # unmutated code with a 12-entry jump table at 0x141b03f70: # 1 UserSessionExtendedDataUpdate 2 UserAdded 3 UserRemoved # 5 UserUpdated 8 UserAuthenticated 9 UserUnauthenticated # 12 ServerDraining # CGID (connectionGroupObjectId) is an ObjectId triple. heat2's OBJID encoding # is UNVERIFIED on the wire and a wrong encoding desynchronises the whole TDF # parse, whereas an ABSENT member simply keeps its client-side default. So we # omit it. Flip this once OBJID is confirmed against a real capture. EMIT_OBJID_FIELDS = False def user_session_login_info_fields(sess: Session, now: int) -> "OrderedDict": """Blaze::UserSessionLoginInfo @0x14486f920 -- 16 members. This is a SUPERSET of UserLoginInfo with the persona fields flattened in rather than nested. KEY must be byte-identical to LoginResponse.SESS.KEY.""" f = OrderedDict([ ("1CON", (INT, 0)), # isFirstConsoleLogin ("ALOC", (INT, sess.account_locale)), # accountLocale (echo client's) ("BUID", (INT, USER_ID)), # blazeUserId ("DSNM", (STRING, PERSONA_NAME)), # displayName ("FRST", (INT, 0)), # isFirstLogin ("KEY", (STRING, sess.session_key)), # sessionKey <- SAME string ("LAST", (INT, now)), # lastAuthenticated ("LLOG", (INT, now)), # lastLoginDateTime ("MAIL", (STRING, EMAIL)), # email ("NASP", (STRING, PERSONA_NAMESPACE)), # must match PreAuthResponse ("PID", (INT, PERSONA_ID)), # personaId ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform ("UID", (INT, USER_ID)), # userId ("USTP", (INT, USER_SESSION_TYPE)), # userSessionType ("XREF", (INT, EXT_ID)), # extId ]) if EMIT_OBJID_FIELDS: from heat2 import OBJID f["CGID"] = (OBJID, (COMP_USERSESSIONS, 1, USER_ID)) return f def network_qos_data_fields() -> "OrderedDict": """Blaze::Util::NetworkQosData @0x14486e680 -- 5 members. NATT = NatType; 0 = OPEN, which is what we want offline.""" return OrderedDict([ ("BWHR", (INT, 0)), # bandwidthHostedRate ("DBPS", (INT, 100000)), # downstream bits/s ("NAHR", (INT, 0)), # natHostedRate ("NATT", (INT, 0)), # NatType -> OPEN ("UBPS", (INT, 100000)), # upstream bits/s ]) def user_session_extended_data_fields() -> "OrderedDict": """Blaze::UserSessionExtendedData @0x144870390 -- 12 members. TWO FIFA-17-SPECIFIC DELTAS vs the MEC emulators: FIFA HAS `PSLM` (latencyList) which they lack, and FIFA has `BPS` as a TOP-LEVEL string member whereas they bury it inside the ADDR union. Follow FIFA's layout. ADDR (NetworkAddress union), CVAR (variable) and ULST (list) are omitted: heat2's UNION/OBJID encodings are unverified and a bad one breaks the whole parse, while an absent member just keeps its default.""" return OrderedDict([ ("BPS", (STRING, "openfut")), # bestPingSiteAlias ("CTY", (STRING, "US")), # country ("DMAP", (MAP, (INT, INT, []))), # dataMap map ("HWFG", (INT, 0)), # hardwareFlags bitfield ("ISP", (STRING, "OpenFUT")), # iSP ("PSLM", (LIST, (INT, [0]))), # latencyList <- FIFA-only ("QDAT", (STRUCT, network_qos_data_fields())), ("TZ", (STRING, "")), # timeZone ("UATT", (INT, 0)), # userInfoAttribute ]) def user_session_extended_data_update_fields() -> "OrderedDict": """Blaze::UserSessionExtendedDataUpdate @0x1448703e0 -- 3 members.""" return OrderedDict([ ("DATA", (STRUCT, user_session_extended_data_fields())), ("SUBS", (INT, 1)), # subscribed ("USID", (INT, USER_ID)), # userId ]) def user_identification_fields(sess: "Session") -> "OrderedDict": """Blaze::UserIdentification @0x14486ebc0 -- 9 members. ALOC echoes the session's captured locale so a client that ever sends a non-enUS PreAuth LANG cannot see two different locales for one session (UserAuthenticated already uses sess.account_locale).""" return OrderedDict([ ("AID", (INT, USER_ID)), # accountId ("ALOC", (INT, sess.account_locale)), # accountLocale (echo client's) ("EXBB", (BLOB, b"")), # externalBlob ("EXID", (INT, EXT_ID)), # externalId ("ID", (INT, USER_ID)), # blazeId ("NAME", (STRING, PERSONA_NAME)), # name ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace ("ORIG", (INT, PERSONA_ID)), # originPersonaId ("PIDI", (INT, PERSONA_ID)), # pidId ]) def user_data_fields(sess: "Session") -> "OrderedDict": """Blaze::UserData @0x1448706b0 -- 3 members. Payload of UserAdded (2). FLGS is a UserDataFlags bitfield; bit0 = online/authenticated.""" return OrderedDict([ ("EDAT", (STRUCT, user_session_extended_data_fields())), ("FLGS", (INT, 3)), ("USER", (STRUCT, user_identification_fields(sess))), ]) def build_login_notifications(sess: Session, now: int) -> list: """The push sequence the client waits on after a successful login.""" return [ ("UserAuthenticated", notification( COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, encode_tdf(user_session_login_info_fields(sess, now)))), ("UserSessionExtendedDataUpdate", notification( COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, encode_tdf(user_session_extended_data_update_fields()))), ("UserAdded", notification( COMP_USERSESSIONS, NOTIFY_USER_ADDED, encode_tdf(user_data_fields(sess)))), ] # ================================================ post-login RPC bodies def post_auth_response_fields(sess: Session) -> "OrderedDict": """Blaze::Util::PostAuthResponse @0x144875810 -- TELE, TICK, UROP. Telemetry/ticker are pointed at a dead local port on purpose: we want the client to have a well-formed config and then fail to connect quietly rather than resolve a real EA hostname.""" tele = OrderedDict([ # GetTelemetryServerResponse (15) ("ADRS", (STRING, "127.0.0.1")), ("ANON", (INT, 0)), ("DISA", (STRING, "")), ("EDCT", (INT, 0)), ("FILT", (STRING, "")), ("LOC", (INT, sess.account_locale)), ("MINR", (INT, 0)), ("NOOK", (STRING, "")), ("PORT", (INT, 9988)), ("SDLY", (INT, 15000)), ("SESS", (STRING, sess.session_key)), ("SKEY", (STRING, "")), ("SPCT", (INT, 75)), ("STIM", (STRING, "")), ("SVNM", (STRING, "telemetry-openfut")), ]) tick = OrderedDict([ # GetTickerServerResponse (3) ("ADRS", (STRING, "127.0.0.1")), ("PORT", (INT, 8999)), ("SKEY", (STRING, "")), ]) urop = OrderedDict([ # UserOptions (2) ("TMOP", (INT, 0)), # TelemetryOpt -> out/disabled ("UID", (INT, USER_ID)), ]) return OrderedDict([ ("TELE", (STRUCT, tele)), ("TICK", (STRUCT, tick)), ("UROP", (STRUCT, urop)), ]) def entitlement_fields(now: int, group=None, tag=None, eid=1) -> "OrderedDict": """Blaze::Authentication::Entitlement @0x14487d490 -- 16 members. FUT's client filter onListEntitlements (0x146f27440) keeps a record only if GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC", TAG non-empty, STAT==1. PRID/GNAM/TAG must contain no '|' and no '/' (re-serialised as PRID|GNAM|TAG|UCNT/).""" group = group or ENTITLEMENT_GROUP tag = tag or ENTITLEMENT_TAG return OrderedDict([ ("DEVI", (STRING, "")), # deviceUri ("GDAY", (STRING, "2016-09-01T00:00:00Z")), # grantDate ("GNAM", (STRING, group)), # groupName (MUST contain the needle) ("ID", (INT, eid)), # id ("ISCO", (INT, 0)), # isConsumable ("PID", (INT, PERSONA_ID)), # personaId ("PJID", (STRING, CONTENT_ID)), # projectId (EA offer id) ("PRCA", (INT, 2)), # productCatalog ("PRID", (STRING, CONTENT_ID)), # productId ("STAT", (INT, 1)), # EntitlementStatus -> ACTIVE (MUST be 1) ("STRC", (INT, 0)), # statusReasonCode ("TAG", (STRING, tag)), # entitlementTag (MUST be non-empty) ("TDAY", (STRING, "")), # terminationDate (never) ("TYPE", (INT, 1)), # EntitlementType ("UCNT", (INT, 0)), # useCount ("VER", (INT, 1)), # version ]) def entitlements_response_fields() -> "OrderedDict": """Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST. Emit BOTH groups FUT accepts so byte[entMgr+0x88] "loaded" flag flips.""" now = int(time.time()) return OrderedDict([ ("NLST", (LIST, (STRUCT, [ entitlement_fields(now, "FIFA17PCBoxContent", "ONLINE_ACCESS", 1), entitlement_fields(now, "FIFA16PC", "ONLINE_ACCESS", 2), ]))), ]) def get_auth_token_response_fields(sess: Session) -> "OrderedDict": """Blaze::Authentication::GetAuthTokenResponse @0x14487d080 -- 1 member.""" tok = sess.auth_code or ("OPENFUT-" + sess.session_key[:16]) return OrderedDict([("AUTH", (STRING, tok))]) def user_settings_response_fields() -> "OrderedDict": """TODO(verify): Util::userSettingsLoad's response descriptor was not reflected. Both independent clean-room emulators use a single `DATA` string, and an unknown-tag payload is ignored rather than fatal, so an empty DATA is the safe minimum -- the client falls back to defaults.""" return OrderedDict([("DATA", (STRING, ""))]) def get_lists_response_fields() -> "OrderedDict": """TODO(verify): AssociationLists::getLists (25/6) response is 3P-only (GetListsResponse{LMAP: list}). FIFA's list names are NOT verified -- do not invent them. An EMPTY list is well-formed and means 'this user has no association lists', which is true offline.""" return OrderedDict([("LMAP", (LIST, (STRUCT, [])))]) # ================================================================== dispatch def extract_service_name(fields) -> str: """PreAuthRequest.CDAT.SVCN -- echo it back as INST.""" try: cdat = fields.get("CDAT") if cdat and cdat[0] == STRUCT: svcn = cdat[1].get("SVCN") if svcn and svcn[0] == STRING and svcn[1]: return svcn[1] except Exception: pass return "fifa-2017-pc" def find_nested_int(fields, tag: str): """Depth-first search for an INT member `tag` anywhere in a decoded TDF.""" if not isinstance(fields, dict): return None for k, (t, v) in fields.items(): if k == tag and t == INT: return v if t == STRUCT: r = find_nested_int(v, tag) if r is not None: return r return None def get_str(fields, tag: str, default: str = "") -> str: try: tv = fields.get(tag) if tv and tv[0] == STRING: return tv[1] except Exception: pass return default def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list: """-> list of frames to send back, in order (may be empty).""" comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"] # Transport-level PING frame (msgType 4) -> PING_REPLY (5), empty body. if mtype == PING: log(" -> transport PING, answering PING_REPLY (empty)") return [reply_to(hdr, b"", msg_type=PING_REPLY)] if mtype not in (MESSAGE,): log(" -> msgType %s is not a request; not answering" % MSGTYPE_NAME.get(mtype, mtype)) return [] # ---------------------------------------------------------------- Util if comp == COMP_UTIL: if cmd == CMD_PREAUTH: sess.service_name = (extract_service_name(fields) if fields is not None else "fifa-2017-pc") loc = None if fields is not None: loc = find_nested_int(fields, "LANG") if loc is None: loc = find_nested_int(fields, "LOC") if loc: sess.account_locale = loc log(" -- client locale 0x%08x captured for ALOC" % loc) resp = preauth_response_fields(service_name=sess.service_name) payload = encode_tdf(resp) log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" % (sess.service_name, len(payload), heat2.dump(resp))) return [reply_to(hdr, payload)] if cmd == CMD_PING: resp = ping_response_fields() log(" -> PingResponse STIM=%d" % resp["STIM"][1]) return [reply_to(hdr, encode_tdf(resp))] if cmd == CMD_FETCHCLIENTCONFIG: cfid = get_str(fields or {}, "CFID", "") resp = fetch_config_response_fields(cfid) n = len(resp["CONF"][1][2]) log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s" % (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)")) for k, v in resp["CONF"][1][2]: log(" %-32s = %s" % (k, v)) return [reply_to(hdr, encode_tdf(resp))] if cmd == CMD_POSTAUTH: resp = post_auth_response_fields(sess) log(" -> PostAuthResponse (TELE/TICK/UROP)") return [reply_to(hdr, encode_tdf(resp))] if cmd == CMD_SETCLIENTSTATE: log(" -> setClientState: empty REPLY (no response TDF)") return [reply_to(hdr, b"")] if cmd == CMD_SETCLIENTMETRICS: log(" -> setClientMetrics: empty REPLY (no response TDF)") return [reply_to(hdr, b"")] if cmd == CMD_USERSETTINGSLOAD: log(" -> UserSettingsResponse (empty DATA; TODO verify descriptor)") return [reply_to(hdr, encode_tdf(user_settings_response_fields()))] if cmd == CMD_USERSETTINGSSAVE: log(" -> userSettingsSave: empty REPLY (accepted, discarded)") return [reply_to(hdr, b"")] if cmd == 0x15: # fetchQosConfig -> proven-good QosConfigInfo body log(" -> QosConfigInfo (fetchQosConfig)") return [reply_to(hdr, encode_tdf(qos_config()))] # ------------------------------------------------------ Authentication if comp == COMP_AUTH: if cmd == CMD_LOGIN: sess.auth_code = get_str(fields or {}, "AUTH", "") sess.logged_in = True sess.login_time = int(time.time()) log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus " "validation -- forged offline session)" % sess.auth_code) resp = login_response_fields(sess) payload = encode_tdf(resp) log(" -> LoginResponse (%d bytes):\n%s" % (len(payload), heat2.dump(resp))) notifs = build_login_notifications(sess, sess.login_time) out = [] if NOTIFY_BEFORE_LOGIN_REPLY: for name, fr in notifs: log(" ~> NOTIFY 0x7802/0x%04x %s" % (parse_fire2_header(fr)["command"], name)) out.append(fr) out.append(reply_to(hdr, payload)) else: out.append(reply_to(hdr, payload)) for name, fr in notifs: log(" ~> NOTIFY 0x7802/0x%04x %s" % (parse_fire2_header(fr)["command"], name)) out.append(fr) return out if cmd in (CMD_TRUSTEDLOGIN, CMD_EXPRESSLOGIN): # Same forged session; the request fields differ but we ignore them. sess.logged_in = True sess.login_time = int(time.time()) log(" == Authentication::%s -> same forged LoginResponse" % AUTH_CMDS.get(cmd, cmd)) out = [reply_to(hdr, encode_tdf(login_response_fields(sess)))] out += [fr for _, fr in build_login_notifications(sess, sess.login_time)] return out if cmd == CMD_LOGOUT: # An empty REPLY was already correct on the wire (logout has NO # request and NO response TDF -- no LogoutRequest/LogoutResponse # exists in the client's type index). But receiving this at all # means the client decided it had no credential BEFORE Blaze auth. # NOTE: logout here is NORMAL, not a failure. The OSDK state table # (0x147159154-0x147159680) orders Connect 400 -> Logout 500 -> # VersionCheck 700 -> PCLogin 800, so LoginStateLogout is the routine # "drop any stale session" step BEFORE PCLogin/GetAuthCode. Only # treat it as a problem if we NEVER subsequently see login (1/0x0A). if sess.logged_in: log(" -- Authentication::logout (1/0x46) after a login " "(session teardown; expected)") else: log(" -- Authentication::logout (1/0x46), no login yet -- " "normal LoginStateLogout (state 500). If GetAuthCode / login " "(1/0x0A) never follow, layer 1 (LSX :4216) or blazeSdkClientId " "is the blocker; see lsx_responder_v2.py + blazesdk_config().") return [reply_to(hdr, b"")] if cmd in (CMD_LISTUSERENTITLEMENTS2, 0x20, 0x30, 0x27): # 0x1D listUserEntitlements2 / 0x20 listEntitlements / # 0x30 listPersonaEntitlements2 / 0x27 grantEntitlement2 -- all return # the ONLINE_ACCESS entitlement so the client sees it however it asks. log(" -> Entitlements{NLST:[%s / offer %s / ACTIVE]} (cmd 0x%02x)" % (ENTITLEMENT_TAG, CONTENT_ID, cmd)) return [reply_to(hdr, encode_tdf(entitlements_response_fields()))] if cmd == CMD_GETAUTHTOKEN: resp = get_auth_token_response_fields(sess) log(" -> GetAuthTokenResponse AUTH=%r" % resp["AUTH"][1]) return [reply_to(hdr, encode_tdf(resp))] if cmd == CMD_GETACCOUNT: # BLOCKER FIX: the RPC behind the "Unable to retrieve account # information" popup. Forged AccountInfo, identity-consistent. resp = account_info_fields(sess, int(time.time())) payload = encode_tdf(resp) log(" == Authentication::getAccount (1/0x1E) -> AccountInfo " "UID=%d MAIL=%r ASRC=%r (%d bytes)" % (USER_ID, EMAIL, PERSONA_NAMESPACE, len(payload))) return [reply_to(hdr, payload)] if cmd == CMD_GETPERSONA: resp = get_persona_response_fields(int(time.time())) log(" -> GetPersonaResponse (1/0x5A) persona %d/%r" % (PERSONA_ID, PERSONA_NAME)) return [reply_to(hdr, encode_tdf(resp))] if cmd == CMD_LISTPERSONAS: resp = list_personas_response_fields(int(time.time())) log(" -> ListPersonasResponse (1/0x64) [%s]" % PERSONA_NAME) return [reply_to(hdr, encode_tdf(resp))] # ------------------------------------------------------- UserSessions if comp == COMP_USERSESSIONS and cmd == CMD_UPDATENETWORKINFO: log(" -> updateNetworkInfo: empty REPLY, then re-push " "UserSessionExtendedDataUpdate") return [ reply_to(hdr, b""), notification(COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, encode_tdf(user_session_extended_data_update_fields())), ] # --------------------------------------------------- AssociationLists if comp == COMP_ASSOCLISTS and cmd == CMD_GETLISTS: log(" -> GetListsResponse{LMAP: []} (TODO verify FIFA's list names)") return [reply_to(hdr, encode_tdf(get_lists_response_fields()))] # ---------------------------------------------------------------- TODO # Still unimplemented, in the order they are expected to show up: # Util::fetchQosConfig (9/0x15) -> QosConfigInfo (see qos_config) # Util::localizeStrings (9/4) -> echo the requested ids # Messaging::fetchMessages (15/2) -> empty list # Stats / GameReporting / GameManager -> FUT-mode specific, later # Authentication::getTermsOfServiceContent (1/0xF6) and # getPrivacyPolicyContent (1/0x2F) -> only reached if NTOS != 0 # Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED # (three clean-room sources disagree: header[14:16] vs metadata ERRC vs # payload CNTX/ERRC) -- do not emit one until it is verified on the wire. # ---------------------------------------------------------------------- # ----------------------------------------------------------- CensusData # subscribeToCensusDataUpdates (0x000A/0x0005): the client re-arms a resend # timer with delay = (CNP+NTMT)/1000; an EMPTY reply -> delay 0 -> ~30/s storm # that hangs the FUT loading screen. Return non-zero TimeValues so it settles # to one re-subscribe per 120s. (COMPONENT_000A_PLAN.md, wire-verified.) if comp == COMP_CENSUSDATA and cmd == CMD_SUBSCRIBETOCENSUSDATAUPDATES: rsub = find_nested_int(fields, "RSUB") if fields is not None else None log(" -> subscribeToCensusDataUpdates(RSUB=%s): Response{CNP=30s,NTMT=90s," "RTMT=300s} -> client re-subscribes in (CNP+NTMT)/1000 = 120000 ms" % rsub) return [reply_to(hdr, encode_tdf( subscribe_census_data_updates_response_fields()))] if REPLY_EMPTY_TO_UNKNOWN: log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not " "hang (all fields fall back to client-side defaults)" % rpc_name(comp, cmd, mtype)) return [reply_to(hdr, b"")] log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd, mtype)) return [] # ============================================================= blaze server def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool: """Fill `buf` to at least n bytes. False on clean EOF / short close.""" while len(buf) < n: try: chunk = sock.recv(65536) except socket.timeout: return False if not chunk: return False buf += chunk return True _frame_counter = [0] def blaze_handle(raw: socket.socket, addr) -> None: log("*** BLAZE CONNECT from %s ***" % (addr,)) sess = Session() log(" session key minted: %s" % sess.session_key) buf = bytearray() raw.settimeout(300) try: while True: if not recv_exactly(raw, FIRE2_HDR, buf): break hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR])) total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"] if hdr["payload_len"] > 4 * 1024 * 1024: log("BLAZE %s: absurd payload_len %d, dropping connection\n%s" % (addr, hdr["payload_len"], hexdump(bytes(buf[:64])))) break if not recv_exactly(raw, total, buf): log("BLAZE %s: EOF mid-frame (want %d, have %d)" % (addr, total, len(buf))) break frame = bytes(buf[:total]) del buf[:total] metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]] payload = frame[FIRE2_HDR + hdr["metadata_len"]:] _frame_counter[0] += 1 n = _frame_counter[0] log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x " "meta=%dB payload=%dB" % (n, rpc_name(hdr["component"], hdr["command"], hdr["msg_type"]), MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), hdr["msg_num"], hdr["user_index"], hdr["options"], hdr["metadata_len"], hdr["payload_len"])) log("RX #%d HEX:\n%s" % (n, hexdump(frame))) if metadata: log("RX #%d METADATA:\n%s" % (n, hexdump(metadata))) if DUMP_FRAMES: try: os.makedirs(RXDIR, exist_ok=True) fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin" % (n, hdr["component"], hdr["command"])) with open(fn, "wb") as fh: fh.write(frame) log("RX #%d saved -> %s" % (n, fn)) except Exception as e: log("RX #%d save failed: %s" % (n, e)) fields = None if payload: try: fields = decode_tdf(payload) log("RX #%d TDF:\n%s" % (n, heat2.dump(fields))) except Exception as e: log("RX #%d TDF DECODE FAILED: %s" % (n, e)) else: log("RX #%d TDF: (empty payload)" % n) try: outs = dispatch(hdr, fields, payload, sess) except Exception as e: log("RX #%d DISPATCH ERROR: %r" % (n, e)) outs = [] for k, out in enumerate(outs): raw.sendall(out) ohdr = parse_fire2_header(out) log("TX #%d.%d %s msgType=%s msgNum=%d %dB total (%d payload)" % (n, k, rpc_name(ohdr["component"], ohdr["command"], ohdr["msg_type"]), MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), ohdr["msg_num"], len(out), ohdr["payload_len"])) log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024))) except ConnectionResetError: log("BLAZE %s: connection reset by client" % (addr,)) except Exception as e: log("BLAZE %s ERR: %r" % (addr, e)) finally: try: raw.close() except Exception: pass log("BLAZE %s: closed" % (addr,)) # ======================================================= redirector (TLS) def build_redirect_response() -> bytes: # ServerInstanceInfo.address is a ServerAddress union -> Heat2 XML union is #
... member="0" = ipAddress variant # {hostname, ip(uint32 decimal), port(uint16)}. body = ( '\n' '\n' '\t
\n' '\t\t\n' f'\t\t\t{BLAZE_IP_STR}\n' f'\t\t\t{BLAZE_IP_U32}\n' f'\t\t\t{BLAZE_PORT}\n' '\t\t\n' '\t
\n' '\t0\n' '\t\n' '\t0\n' '
\n' ) b = body.encode() hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n" f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode() return hdr + b def make_tls_context(): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(CERT, KEY) ctx.minimum_version = ssl.TLSVersion.TLSv1 ctx.set_ciphers("ALL:@SECLEVEL=0") return ctx _ctx = [None] def redir_handle(raw: socket.socket, addr) -> None: try: tls = _ctx[0].wrap_socket(raw, server_side=True) except ssl.SSLError as e: log("REDIR REJECTED %s: %s" % (addr, e)) raw.close() return log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0])) try: tls.settimeout(8) req = b"" while b"\r\n\r\n" not in req: c = tls.recv(4096) if not c: break req += c if b"content-length:" in req.lower(): head, _, rest = req.partition(b"\r\n\r\n") cl = int([l.split(b":")[1] for l in head.split(b"\r\n") if l.lower().startswith(b"content-length")][0]) while len(rest) < cl: c = tls.recv(4096) if not c: break rest += c req = head + b"\r\n\r\n" + rest line0 = req.split(b"\r\n", 1)[0].decode(errors="replace") log("REDIR REQ %s: %s" % (addr, line0)) resp = build_redirect_response() tls.sendall(resp) log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d" % (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT)) time.sleep(0.3) tls.close() except Exception as e: log("REDIR ERR %s: %s" % (addr, e)) # ================================================ Nucleus OAuth stub (plain) # # LoginStateMachineImpl (string cluster 0x14389fd50-0x14389fef8) builds # "/connect/token", POSTs "grant_type=client_credentials" # with a "NEXUS_S2S " authorization header, and scrapes '"access_token" : "' # out of the reply body. We advertise nucleusConnect = this listener, so the # client can never reach accounts.ea.com. Note the exact spacing in the JSON: # the client searches for the literal '"access_token" : "'. def nucleus_handle(raw: socket.socket, addr) -> None: try: raw.settimeout(10) req = b"" while b"\r\n\r\n" not in req and len(req) < 65536: c = raw.recv(4096) if not c: break req += c head, _, rest = req.partition(b"\r\n\r\n") line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else "" log("NUCLEUS REQ %s: %s" % (addr, line0)) if head: log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace")) if rest: log("NUCLEUS BODY: %r" % rest[:512]) token = "OPENFUT_" + "".join( random.choice(string.ascii_letters + string.digits) for _ in range(40)) # Spacing matters: the client greps for the literal '"access_token" : "'. body = ('{\n "access_token" : "%s",\n "token_type" : "Bearer",\n' ' "expires_in" : 14400,\n "id_token" : "%s",\n' ' "refresh_token" : "%s"\n}\n' % (token, token, token)).encode() out = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" b"Cache-Control: no-store\r\nContent-Length: " + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) raw.sendall(out) log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token)) except Exception as e: log("NUCLEUS ERR %s: %s" % (addr, e)) finally: try: raw.close() except Exception: pass # ================================================================== serve def serve(port: int, handler, name: str) -> None: s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((HOST, port)) s.listen(16) log("%s listening on %s:%d" % (name, HOST, port)) while True: c, a = s.accept() threading.Thread(target=handler, args=(c, a), daemon=True).start() # ================================================================== selftest def _check_roundtrip(label: str, fields) -> bytes: payload = encode_tdf(fields) back = decode_tdf(payload) again = encode_tdf(back) assert again == payload, "%s: re-encode differs" % label assert list(back.keys()) == sorted(fields.keys(), key=heat2.tag_key), \ "%s: tag order %r" % (label, list(back.keys())) return payload def _selftest() -> None: print("=" * 72) print("blaze_responder_v3 selftest") print("=" * 72) sess = Session() sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0" sess.account_locale = 0x656E5553 now = 1469000000 # ---- 1. preAuth still round-trips (regression guard vs v2) pre = preauth_response_fields() p = _check_roundtrip("PreAuthResponse", pre) fr = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, p) assert fr[13] == 0x20, fr[13] assert list(decode_tdf(p).keys()) == [ "ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST", "MAID", "MINR", "NASP", "PILD", "PLAT", "QOSS", "RSRC", "SVER"] print("[ok] PreAuthResponse %4d payload bytes" % len(p)) # ---- 2. fetchClientConfig per CFID for cfid in ["BlazeSDK", "OSDK_CORE", "OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_WEBOFFER", "OSDK_ABUSE_REPORTING", "OSDK_XMS_ABUSE_REPORTING", "IdentityParams", "TOTALLY_UNKNOWN"]: f = fetch_config_response_fields(cfid) pb = _check_roundtrip("FetchConfigResponse/" + cfid, f) back = decode_tdf(pb) assert list(back.keys()) == ["CONF"], back.keys() kt, vt, items = back["CONF"][1] assert (kt, vt) == (STRING, STRING) assert items == client_config_for(cfid), cfid print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes" % (cfid, len(items), len(pb))) assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []" assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \ "unknown CFID must still carry a CONF field (empty map, not empty frame)" # ---- 3. LoginResponse lr = login_response_fields(sess) lp = _check_roundtrip("LoginResponse", lr) back = decode_tdf(lp) assert list(back.keys()) == ["ANON", "NTOS", "SESS", "SPAM", "UNDR"], back.keys() assert "CNTX" not in back and "ERRC" not in back and "SKEY" not in back s = back["SESS"][1] assert list(s.keys()) == ["1CON", "BUID", "FRST", "KEY", "LLOG", "MAIL", "PDTL", "UID"], list(s.keys()) assert s["KEY"][1] == sess.session_key assert s["BUID"][1] == USER_ID != 0 and s["UID"][1] == USER_ID d = s["PDTL"][1] assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \ list(d.keys()) assert d["PID"][1] == PERSONA_ID == 33068179 assert d["DSNM"][1] == PERSONA_NAME == "CAGE" assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0 lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp) lh = parse_fire2_header(lfr) assert (lh["component"], lh["command"], lh["msg_type"]) == (1, 0x0A, REPLY) assert lfr[13] == 0x20 print("[ok] LoginResponse %4d payload bytes, hdr %s" % (len(lp), lfr[:16].hex(" "))) # ---- 4. UserAuthenticated notification ua = user_session_login_info_fields(sess, now) up = _check_roundtrip("UserSessionLoginInfo", ua) nb = decode_tdf(up) assert list(nb.keys()) == ["1CON", "ALOC", "BUID", "DSNM", "FRST", "KEY", "LAST", "LLOG", "MAIL", "NASP", "PID", "PLAT", "UID", "USTP", "XREF"], list(nb.keys()) assert nb["KEY"][1] == sess.session_key, "notif KEY must match SESS.KEY" assert nb["PID"][1] == PERSONA_ID and nb["DSNM"][1] == PERSONA_NAME assert nb["NASP"][1] == PERSONA_NAMESPACE nfr = notification(COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, up) nh = parse_fire2_header(nfr) assert (nh["component"], nh["command"]) == (0x7802, 0x0008), nh assert nh["msg_type"] == NOTIFICATION and nh["msg_num"] == 0 assert nfr[13] == 0x40, nfr[13] print("[ok] UserAuthenticated notif %4d payload bytes, hdr %s" % (len(up), nfr[:16].hex(" "))) # ---- 5. the other two notifications + post-login RPCs for label, f in [ ("UserSessionExtendedDataUpdate", user_session_extended_data_update_fields()), ("UserAdded (UserData)", user_data_fields(sess)), ("PostAuthResponse", post_auth_response_fields(sess)), ("Entitlements", entitlements_response_fields()), ("GetAuthTokenResponse", get_auth_token_response_fields(sess)), ("UserSettingsResponse", user_settings_response_fields()), ("GetListsResponse", get_lists_response_fields()), ("PingResponse", ping_response_fields()), ]: b = _check_roundtrip(label, f) print("[ok] %-30s %4d payload bytes" % (label, len(b))) assert list(ping_response_fields().keys()) == ["STIM"], \ "PingResponse must carry ONLY STIM (TIME is MEC's, not FIFA 17's)" # ---- 5b. BLOCKER-FIX responses: AccountInfo / getPersona / listPersonas ai = account_info_fields(sess, now) aip = _check_roundtrip("AccountInfo", ai) aib = decode_tdf(aip) assert list(aib.keys()) == ["AMU", "ASRC", "CO", "DOB", "DTCR", "GOPT", "LATH", "LN", "MAIL", "PML", "RC", "STAS", "STAT", "TPOT", "UDU", "UID"], list(aib.keys()) assert len(aib) == 16 # 8 strings / 8 ints exactly, as reversed from the descriptor strs = [k for k, (t, v) in aib.items() if t == STRING] ints = [k for k, (t, v) in aib.items() if t == INT] assert strs == ["ASRC", "CO", "DOB", "DTCR", "LATH", "LN", "MAIL", "PML"], strs assert ints == ["AMU", "GOPT", "RC", "STAS", "STAT", "TPOT", "UDU", "UID"], ints # identity consistency vs LoginResponse.SESS assert aib["UID"][1] == USER_ID and aib["MAIL"][1] == EMAIL assert aib["ASRC"][1] == PERSONA_NAMESPACE == "cem_ea_id" assert aib["STAS"][1] == 1 and aib["STAT"][1] == 2 # ACTIVE / VERIFIED afr = fire2(COMP_AUTH, CMD_GETACCOUNT, 9, REPLY, aip) assert parse_fire2_header(afr)["command"] == 0x1E print("[ok] AccountInfo (getAccount 1/0x1E) %4d payload bytes" % len(aip)) gp = get_persona_response_fields(now) gpp = _check_roundtrip("GetPersonaResponse", gp) gpb = decode_tdf(gpp) assert list(gpb.keys()) == ["PINF", "UID"], list(gpb.keys()) pinf = gpb["PINF"][1] assert list(pinf.keys()) == ["DSNM", "DTCR", "LADT", "NSNM", "PID", "STAS", "STRC"], list(pinf.keys()) assert pinf["PID"][1] == PERSONA_ID and pinf["DSNM"][1] == PERSONA_NAME assert gpb["UID"][1] == USER_ID print("[ok] GetPersonaResponse (1/0x5A) %4d payload bytes" % len(gpp)) lpz = list_personas_response_fields(now) lpp = _check_roundtrip("ListPersonasResponse", lpz) lpb = decode_tdf(lpp) assert list(lpb.keys()) == ["PINF"], list(lpb.keys()) et, items = lpb["PINF"][1] assert et == STRUCT and len(items) == 1 assert items[0]["PID"][1] == PERSONA_ID print("[ok] ListPersonasResponse (1/0x64) %4d payload bytes" % len(lpp)) # ---- 5c. dispatch actually returns them (no fall-through to empty reply) class _H(dict): pass for command, want_kind in ((CMD_GETACCOUNT, "AccountInfo"), (CMD_GETPERSONA, "GetPersona"), (CMD_LISTPERSONAS, "ListPersonas")): h = {"component": COMP_AUTH, "command": command, "msg_type": MESSAGE, "msg_num": 1, "user_index": 0, "options": 0} outs = dispatch(h, OrderedDict(), b"", sess) assert len(outs) == 1, (command, outs) oh = parse_fire2_header(outs[0]) assert oh["payload_len"] > 0, "%s produced an EMPTY reply" % want_kind print("[ok] dispatch 1/%#04x -> %s (%d payload bytes)" % (command, want_kind, oh["payload_len"])) # ---- 6. the full login burst, framed frames = [fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)] + \ [fr for _, fr in build_login_notifications(sess, now)] blob = b"".join(frames) seen, i = [], 0 while i < len(blob): h = parse_fire2_header(blob[i:i + 16]) tot = 16 + h["metadata_len"] + h["payload_len"] seen.append((h["component"], h["command"], h["msg_type"])) decode_tdf(blob[i + 16 + h["metadata_len"]:i + tot]) i += tot assert seen == [(0x0001, 0x000A, REPLY), (0x7802, 0x0008, NOTIFICATION), (0x7802, 0x0001, NOTIFICATION), (0x7802, 0x0002, NOTIFICATION)], seen print("[ok] login burst re-framed: %d frames / %d bytes" % (len(frames), len(blob))) print() print("---- LoginResponse -------------------------------------------------") print(heat2.dump(lr)) print() print("---- NOTIFY 0x7802/0x0008 UserAuthenticated ------------------------") print(heat2.dump(ua)) print() print("ALL SELFTESTS PASSED") # ================================================================== main if __name__ == "__main__": if "--selftest" in sys.argv: _selftest() raise SystemExit(0) log("=== RESPONDER v3 START (redir %d / blaze %d%s) ===" % (REDIR_PORT, BLAZE_PORT, (" / nucleus %d" % NUCLEUS_PORT) if NUCLEUS_STUB_ENABLED else "")) log(" persona %d / %r namespace %r entitlement %s (offer %s)" % (PERSONA_ID, PERSONA_NAME, PERSONA_NAMESPACE, ENTITLEMENT_TAG, CONTENT_ID)) log(" REMINDER: layer 1 first -- start lsx_responder.py BEFORE FIFA 17, " "or the client sends logout (1/0x46) instead of login (1/0x0A).") _ctx[0] = make_tls_context() threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"), daemon=True).start() if NUCLEUS_STUB_ENABLED: threading.Thread(target=serve, args=(NUCLEUS_PORT, nucleus_handle, "NUCLEUS"), daemon=True).start() serve(REDIR_PORT, redir_handle, "REDIR")