Files
OpenFUT/fifa17-recon/tools/lsx_responder_v2.py
T
funman300 83539e33ec fifa17-recon: take running-backend versions of 8 runtime files (direction fix)
The earlier reconcile committed the local working-tree versions of these
files, which are OLDER than the deployed backend. The running container (C)
is byte-identical to docker/fifa17-python/tools (B) and is a strict superset:
it adds profile_path_for/select_account/ensure_security_question (fut_store),
safe_header_for_log/safe_request_path/security_question_route (utas_server),
account_sync_route/_match_call/match_ready_body, plus POW balance fields and
match lifecycle support, with zero unique local functions lost.

Reconciled tree is now a strict superset of B with every shared file
byte-identical; verified via md5 map (0 missing, 0 differing).
2026-08-10 17:12:27 -07:00

622 lines
30 KiB
Python
Executable File

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