#!/usr/bin/env python3 """FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication. 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 (a NORMAL pre-login step, see below) 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 = "FIFA17PC" 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 # ---- 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_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", } 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) 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.saw_logout = 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 = [ ("associationListSkipInitialSet", "1"), # 0x143b6eb88 AssocListAPI ("autoReconnectEnabled", "1"), # 0x1438a0a68 ConnMgr ("connIdleTimeout", "90000000"), # 0x1438a0a58 ConnMgr ("defaultRequestTimeout", "30000000"), # 0x1438a0a40 ConnMgr ("enableQosBandwidthTest", "false"), # 0x1438a0a08 clears bit1 ("enableQosFirewallTest", "false"), # 0x1438a09f0 clears bit0 ("maxReconnectAttempts", "5"), # 0x1438a0a80 ConnMgr ("pingPeriod", "20000000"), # 0x1438a0a30 ConnMgr ("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"), ("OSDK_PRESENCE_POLL", "60"), ("OSDK_ANTIGRIEFING_MAX_COUNT", "0"), ("OSDK_ARENA_ENABLED", "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"), ] # OSDK_NUCLEUS is Nucleus *tuning* only -- no URL keys were found in it. The # real Nucleus endpoints are BlazeSDK-level (nucleusConnect, above). OSDK_NUCLEUS = [ ("OSDK_NUCLEUS_ENABLED", "1"), ("OSDK_NUCLEUS_POLL", "60"), ("OSDK_NUCLEUS_RETRY_COUNT", "3"), ("OSDK_NUCLEUS_TIMEOUT", "30"), ] # Keep the online storefront and abuse-report web views switched OFF: with no # EA web backend reachable, an enabled one is a hang waiting to happen. OSDK_WEBOFFER = [ ("OSDK_WEBOFFER_ENABLED", "0"), ("OSDK_WEBOFFER_URL", ""), ] OSDK_ABUSE_REPORTING = [ ("OSDK_ABUSE_REPORTING_ENABLED", "0"), ("OSDK_ABUSE_NUM_TYPES", "0"), ] OSDK_TICKER = [ ("OSDK_TICKER_ENABLED", "0"), ] # 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 "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, "IdentityParams": IDENTITY_PARAMS, } 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).""" if cfid == "BlazeSDK": return blazesdk_config() return sorted(CLIENT_CONFIGS.get(cfid) or []) 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 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 ]) # ============================ 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() -> "OrderedDict": """Blaze::UserIdentification @0x14486ebc0 -- 9 members.""" return OrderedDict([ ("AID", (INT, USER_ID)), # accountId ("ALOC", (INT, ACCOUNT_LOCALE_FALLBACK)), # accountLocale ("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() -> "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())), ]) 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()))), ] # ================================================ 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) -> "OrderedDict": """Blaze::Authentication::Entitlement @0x14487d490 -- 16 members. The retail exe requires TAG='ONLINE_ACCESS' tied to offer 1027460, STAT active, PID 33068179. Failure modes: AUTH_ERR_NO_SUCH_ENTITLEMENT (63), AUTH_ERR_ENTITLEMENT_TAG_REQUIRED (74).""" day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) return OrderedDict([ ("DEVI", (STRING, "")), # deviceUri ("GDAY", (STRING, "2016-09-01T00:00:00Z")), # grantDate ("GNAM", (STRING, ENTITLEMENT_GROUP)), # groupName ("ID", (INT, 1)), # 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 (1, verified) ("STRC", (INT, 0)), # statusReasonCode ("TAG", (STRING, ENTITLEMENT_TAG)), # entitlementTag ("TDAY", (STRING, "")), # terminationDate (never) ("TYPE", (INT, 1)), # EntitlementType -> ONLINE_ACCESS (1, verified) ("UCNT", (INT, 0)), # useCount ("VER", (INT, 1)), # version ]) del day # (kept for readability of the date format above) def entitlements_response_fields() -> "OrderedDict": """Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST.""" now = int(time.time()) return OrderedDict([ ("NLST", (LIST, (STRUCT, [entitlement_fields(now)]))), ]) 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). # # CORRECTION (2026-07-30): logout here is NOT proof of failure. # The OSDK login state ids recovered from the registration block # at 0x147159154-0x147159680 are: # CheckUser 100, Isp 200, RecheckUser 300, # LoadIspAccountInfo 310, Connect 400, *Logout 500*, # VersionCheck 700, PCLogin 800, LoginComplete 1000, # VerifyAccount 1300, UpgradeAccount 1350 # LoginStateLogout sits BETWEEN Connect and VersionCheck: it is the # normal "drop any stale Blaze session before authenticating" # step. Seeing 1/0x46 immediately after the six fetchClientConfig # replies is therefore expected. What matters is whether # login (1/0x0A) EVER arrives afterwards. sess.saw_logout = True if sess.logged_in: log(" -- Authentication::logout (1/0x46) after a successful " "login: normal teardown.") else: log(" -- Authentication::logout (1/0x46) with no prior login. " "This is LoginStateLogout (id 500), a normal pre-login " "step. If no login (1/0x0A) follows within ~60s, LAYER 1 " "(Origin/LSX on 127.0.0.1:4216) never flipped " "OriginMgr.m_isLoggedIn -- i.e. the pushed " " " "frame is missing. Run lsx_responder_v2.py.") 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))] # ------------------------------------------------------- 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. # ---------------------------------------------------------------------- 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()), ("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)" # ---- 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")