Files
OpenFUT/fifa17-recon/tools/lsx_responder.py
T
funman300 5d5198f5d1 fifa17-recon: match rewards, POW online layer, account backend, quick sell
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.

WORKING END TO END (live-verified this session):
  * match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
    (0x180121b60). Play a match, get coins, W/D/L updates.
  * packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
  * quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
    were destroyed for 0 coins. Now credits discardValue.
  * POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
    a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
    FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
    ROSTERUPDATE_URL. FUT_POW=1.
  * account backend -- fut_account.py replaces 7 hardcoded copies of the persona
    across 5 files; club/persona/online-profile editable via CLI.

CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
  * FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
    purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
    take externalPriceId(0x11a), not amount/currency.
  * FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
    unique among FUT deserializers) and parses only itemData -> dreamSquads.
  * class -> deserializer resolution: the name literal is preceded by a 4-BYTE
    HEADER and the factory LEA points at the header, so look up name_addr - 4.
    Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
    Draft schemas.
  * live-only endpoints the request table never lists: ut/%s/squad/list,
    ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
    table is a floor, not a ceiling -- the log is the only ground truth.
  * 163 RS4 call names exist; we served 17. All now served.

FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).

UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).

Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).

Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 09:42:59 -07:00

246 lines
11 KiB
Python

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