Milestone: FIFA 17 Ultimate Team boots end-to-end on our offline backend
past every EA gate into the hub and a live Squads editor (correct 4-4-2,
5-star squad, no freezes).
Key findings this session:
- userMassInfo MUST stay {} (any content desyncs the massinfo parser
0x180174630 -> tokenizer busy-loop freeze). Deliver the squad via
GET /squad/0 (fetched on Squads-tab entry) instead.
- Player cards render generic because the card view-model (0x1800d7920)
reads identity/rating/face from a resolved record at item+0x10, filled
by a lookup (0x18011cca0) in the FUT item-definition std::map at
CardsDb+0x160c0 -- which is EMPTY offline -> default blank record.
- Version advertising (itemDbVersion/checkServerDbVersion) is proven inert
(JSON fields routed to the skip handler). Owned items don't auto-trigger
a definition fetch. In-place map overwrite is dead (map stays empty).
- Definition-serving endpoints (item/resource, defid, item?idList) built +
ready; the fetch trigger lives in the packed FIFA17.exe.
New: docs/CARD_SYSTEM.md (findings + ordered next-steps plan for real
player cards: patch-POC, dbdata extractor, drive FIFA17.exe fetch, or
live-memory store injection). Plus tools: fut_seed.py (squad ladder +
definition serving), fifadrive.sh, vgamepad.py, and the login-RE toolset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
14 KiB
Origin / LSX online-state layer — reversed, and the forced-online fix
Date: 2026-07-30 · Live target: FIFA17.exe PID 19517 (ptrace_scope=0) ·
Emu module base: 0x6ffffc930000 (/mnt/games/FIFA 17/stp-origin_emu.dll)
Clean-room provenance: everything below comes from (a) static/dynamic analysis of
binaries we own — FIFA17.exe, stp-origin_emu.dll as unpacked in our own process —
and (b) the live LSX byte traffic our own client produced. No 2021 EA/FIFA leak material
was used or consulted.
1. Verdict: which layer produces the error
Layer (1), the Origin/LSX online-state layer — and it fires before Blaze auth matters.
The game's own navigation script (recovered from live memory @ 0x41bc5e2e) gates FUT on
the Origin online verdict:
,{ "name":"launchFUTFlow", "type":"external", "file":"/online/origin.nav"
, "outputs": { "OriginIsOnlineTrue":"startFutBlazeLogin", "quit":"mainMenu" } }
,{ "name":"futBlazeLogin", "type":"external", "file":"/online/onlineLoginFlow.nav"
, "inputs": { "startFutBlazeLogin":"startLoginWithoutMultiplayerCheck" }
, "outputs": { "loginSuccess":"CheckFUTRosters", "loginFail":"mainMenu" } }
origin.nav must emit OriginIsOnlineTrue before futBlazeLogin (our Blaze work) is ever
entered. Today it cannot, because the Origin emu answers the online probe with
connected="0". The message string "...log in to Origin in Online Mode." lives at
0x7b8fab9.
Captured live LSX traffic (decrypted plaintext buffers still resident in memory):
req <Request recipient="" id="22"><GetInternetConnectedState version="3"/></Request>
resp <Response id="17" sender=""><InternetConnectedState connected="0"/></Response> <-- THE GATE
resp <Response id="19..22" sender=""><ErrorSuccess Code="0" Description=""/></Response>
2. What the Steampunks emu actually is (the decisive structural finding)
stp-origin_emu.dll is UPX-packed on disk (hence garbled strings); the unpacked image
lives at 0x6ffffc931000-0x6ffffc93d000 (rwxp, already writable).
It is not an LSX server. It never parses a request. It is a blind fixed-script
replayer: it sends 18 hard-coded responses with hard-coded ids 1..18, in a fixed order,
whatever the game asks — then loops forever emitting ErrorSuccess.
Reconstructed script (lea r8,<template> + mov r9d,<id> pairs), confirmed against the
live capture (ids 15→18 match exactly):
| id | response template |
|---|---|
| 1 | ChallengeAccepted response="%s" |
| 2 | GetConfigResponse Config="false" |
| 3 | GetProfileResponse ... PersonaId=%llu ... Persona=%s |
| 4 | GetSettingResponse Setting="false" |
| 5 | GetGameInfoResponse GameInfo="false" |
| 6 | GetGameInfoResponse GameInfo="ar_SA,…,zh_TW" |
| 7 | GetSettingResponse Setting="production" |
| 8 | GetSettingResponse Setting="false" |
| 9 | IsProgressiveInstallationAvailableResponse Available="false" |
| 10 | GetProfileResponse … |
| 11 | GetGameInfoResponse GameInfo="ar_SA,…" |
| 12 | GetSettingResponse Setting="%s" |
| 13 | GetGameInfoResponse GameInfo="false" |
| 14 | ErrorSuccess Code="0" |
| 15 | GetSettingResponse Setting="production" |
| 16 | GetGameInfoResponse GameInfo="false" |
| 17 | InternetConnectedState connected="0" ← the offline verdict |
| 18 | GetProfileResponse … (PersonaId 33068179 / CAGE) |
| 19+ | ErrorSuccess Code="0" forever (loop @ 0x6ffffc932dd3, esi++) |
Two consequences:
- The offline verdict is a hard-coded string literal, not a computed decision. There is no "check" to patch — only a canned answer.
- The emu can never answer
GetAuthCode,QueryEntitlements, or a secondGetInternetConnectedState. It has noAuthCode/QueryEntitlementsResponsetemplate at all. Everything after step 18 isErrorSuccess. That is exactly why the re-probe atid=22above gotErrorSuccessinstead of a connected-state answer.
3. Socket setup — why we can preempt it
sub_0x6ffffc932130 (the DllInit server thread):
WSAStartup(0x202)
getaddrinfo("127.0.0.1", "4216", {AI_PASSIVE, AF_INET, SOCK_STREAM, IPPROTO_TCP})
socket() -> bind() -> listen(0x7fffffff) -> accept()
closesocket(listen_fd) <-- @0x6ffffc9322bb, immediately after accept()
- No
SO_REUSEADDR. Onbind()failure it branches to0x6ffffc932245:freeaddrinfo → closesocket → WSACleanup → return 1. It exits cleanly — no crash, no retry. - It accepts exactly one connection then closes the listener. Confirmed live:
ssshows the ESTAB pair127.0.0.1:4216 <-> 127.0.0.1:51162but no LISTEN on 4216.
So: bind 127.0.0.1:4216 before launching the game and the stub politely stands down, and the game's OriginSDK connects to us. No DNAT, no hosts trick, no DLL patching needed. (Wine's WS2_32 maps to real Linux sockets, so a normal Linux listener wins the port.)
4. LSX wire protocol (fully reversed)
Transport: TCP 127.0.0.1:4216, each message a NUL-terminated byte string
(send(len = strlen+1)).
Handshake — plaintext:
- server → client:
<LSX><Event sender="EALS"><Challenge key="<32 hex>" build="release" version="10,4,13,6637"/></Event></LSX> - client → server: message carrying
response="…"andkey="…" - server → client:
<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="H"/></Response></LSX>whereH = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED)), 64 hex chars, andK_FIXED = 000102030405060708090a0b0c0d0e0f(emu.rdata@0x6ffffc935038, read live).
Session key derivation (sub_0x6ffffc931f10 tail, 0x9320bf–0x932101), using
MSVCR120 srand/rand (IAT 0x6ffffc9340a0 / 0x6ffffc934100, resolved by export name):
srand(7); r0 = rand() # r0 == 61 (verified numerically)
bx = (uint16)((H[0] << 8) + H[1]) # first two ASCII chars of H
srand(bx + r0)
key[i] = (uint8)rand() for i in 0..15
All later messages: hex_lower( AES128_ECB( pkcs7_pad16( xml ) ) ) + NUL
(encoder sub_0x931dc0, decoder sub_0x931ce0, byte-wise AES with S-box @ 0x934330
and inverse S-box @ 0x934430, hex format "%02x" @ 0x9345d0).
Reference implementation, round-trip verified:
/tmp/claude-1000/-home-alex-Documents-OpenFUT/b89d9ca6-265d-4444-969c-6923501c168a/scratchpad/lsx_responder.py
ChallengeAccepted response = 00b9c8afef744cbc1dd1b1e8aca6a2ed5fb0f43c5e287f833ea2750983772e0f
derived session key = 4a216b49ea0b8c8a7b9864c3d0dd07c9
roundtrip OK = True
5. The fix
5a. Minimal /proc/mem patch — one byte (stopgap only)
| VA | 0x6ffffc9353f4 |
| before | 30 ('0') |
| after | 31 ('1') |
Context (0x6ffffc9353b0, verified live):
<LSX><Response id="%d" sender=""><InternetConnectedState connected="0"/></Response></LSX>
python3 .../scratchpad/memtool.py patch 6ffffc9353f4 31
Page is already rwxp; the format string is re-read on every use.
Limits — read these before relying on it. (i) It must be applied before the emu
reaches script step 17, i.e. right after launch; pressing Q to re-connect will not
help, because the emu is a linear script and is permanently parked in the ErrorSuccess
loop for the rest of this run. (ii) Even applied in time it only fixes the one scripted
occurrence — the game's later re-probe still gets ErrorSuccess. (iii) It does not
give us GetAuthCode or QueryEntitlements, so it gets us past gate 1 straight into
gate 2. Use it only as a cheap one-shot experiment to confirm OriginIsOnlineTrue fires.
5b. The real fix — replace the emu with our own LSX responder (recommended)
Because the offline verdict is a canned string in a script that also cannot answer the auth-code or entitlement questions, patching cannot get us to a logged-in state. Serve LSX ourselves:
python3 /tmp/.../scratchpad/lsx_responder.py # bind 4216 FIRST
# then launch FIFA 17 — stub's bind() fails, it returns 1, we own the socket
This is request-driven (parses <Request id=… ><Verb …/>), so it survives re-probes,
arbitrary ordering, and the reconnect loop.
6. Exact LSX responses the forced-online path must emit
Element and attribute names below were read out of FIFA17.exe's own LSX name tables
(element table @ 0x143937900 / 0x14394dc00, attribute pool @ 0x14394de00).
<!-- THE GATE -->
<LSX><Response id="N" sender=""><InternetConnectedState connected="1"/></Response></LSX>
<!-- auth code handed to Blaze; element name confirmed as <AuthCode> -->
<LSX><Response id="N" sender="EbisuSDK"><AuthCode Code="<blob>" Return="<blob>"/></Response></LSX>
<!-- identity: MUST match stp-origin_emu.ini [Globals] and our Blaze side -->
<LSX><Response id="N" sender="EbisuSDK"><GetProfileResponse IsSubscriber="true"
PersonaId="33068179" AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US"
UserId="33068179" Persona="CAGE" IsUnderAge="false" CommerceCurrency="USD"/></Response></LSX>
<!-- online entitlement -->
<LSX><Response id="N" sender="EbisuSDK"><QueryEntitlementsResponse>
<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" ResourceId="1027460"
OfferId="1027460" GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>
</QueryEntitlementsResponse></Response></LSX>
Recovered attribute pool relevant here: connected (InternetConnectedState),
ClientId (GetAuthCode request), PersonaId · Persona · AvatarId · Country · IsUnderAge · IsSubscriber · GeoCountry · CommerceCountry · CommerceCurrency (GetProfileResponse),
ItemId · EntitlementId · ResourceId · GrantDate · OfferId · bIsOwned · Uses (Entitlement).
One open item: the value attribute of <AuthCode> is not 100 % pinned. The pool
position between ClientId (GetAuthCode) and connected (InternetConnectedState) is
empty, which means it is suffix-shared — Code (the tail of the pooled string
"AuthCode") is the strong candidate, with Return the alternative. The responder emits
both attributes; a name-keyed XML attribute reader takes the one it knows and ignores
the other, so this resolves itself on the next run. Confirm from the log which one the
client consumes.
7. Auth-code → Blaze handoff (keeping a1/a3 consistent)
Flow, from the OriginSDK symbols in FIFA17.exe
(…\External\EA\OriginSDK\src\impl\…) and the Blaze::Authentication symbol set:
origin.navprobesGetInternetConnectedState→ needsconnected="1"→ emitsOriginIsOnlineTrue→startFutBlazeLogin.- The client calls LSX
GetAuthCode(with aClientId) → our<AuthCode Code="…"/>. This islsx::GetAuthCodeT → lsx::AuthCodeT, handler…GetAuthCodeT,struct lsx::AuthCodeT…::HandleMessage@0x1439385cf. - That code is then presented to Blaze component
0x0001(Authentication) — the nucleus path (nucleusConnect/nucleusConnectTrusted/nucleus_id,ExpressLoginRequest,GetAuthTokenResponse,GetUserAccessTokenResponse). This is the empty-payload0x0001/0x0046call we already saw stall in the session log. - Blaze then validates entitlements (
AUTH_ERR_NO_SUCH_ENTITLEMENT,AUTH_ERR_ENTITLEMENT_TAG_REQUIRED) and persona (AUTH_ERR_INVALID_PERSONA,AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA,AUTH_ERR_PERSONA_NOT_FOUND).
Consistency contract for the Blaze side (a1/a3):
| field | value | source of truth |
|---|---|---|
| persona / nucleus id | 33068179 |
stp-origin_emu.ini + LSX GetProfileResponse |
| persona name | CAGE |
same |
| entitlement tag | ONLINE_ACCESS |
FIFA17.exe 0x1438991e8 (retail exe) |
| content / offer id | 1027460 |
EA offer id for FIFA 17 |
| country / currency | US / USD |
LSX GetProfileResponse |
| auth code | whatever our LSX <AuthCode Code=…> returned |
must be echoed/accepted verbatim by Blaze Authentication |
Since we author both ends, the auth code can be any opaque token — but the Blaze
Authentication reply must return the same 33068179 / CAGE, or the client trips the
persona-mismatch errors above. Recommend a shared constants module so the LSX responder
and blaze_responder_v2.py cannot drift.
8. Suggested next run
- Start
lsx_responder.py(binds 4216). - Start the Blaze stack (
blaze_responder_v2.py). - Launch FIFA 17. Watch the LSX log for
GetInternetConnectedState→connected="1", then forGetAuthCodeand which attribute the client reads back. - Expect the nav flow to advance
OriginIsOnlineTrue → startFutBlazeLogin, putting the stall back on Blaze0x0001— which is then a1/a3's territory, now with a real auth code and a consistent persona.
Appendix — addresses
| what | VA |
|---|---|
| emu image base | 0x6ffffc930000 |
| emu unpacked code (rwxp) | 0x6ffffc931000-0x6ffffc93d000 |
InternetConnectedState connected="0" template |
0x6ffffc9353b0 |
patch byte ('0'→'1') |
0x6ffffc9353f4 |
fixed AES key 000102…0f |
0x6ffffc935038 |
| encoder (pad+AES+hex) | 0x6ffffc931dc0 |
| decoder (hex+AES+unpad) | 0x6ffffc931ce0 |
| challenge parse + key derive | 0x6ffffc931f10 |
| server thread (socket setup) | 0x6ffffc932130 |
closesocket(listener) after accept |
0x6ffffc9322bb |
| bind-failure exit path | 0x6ffffc932245 |
ErrorSuccess forever-loop |
0x6ffffc932dd3 |
| AES S-box / inv S-box | 0x6ffffc934330 / 0x6ffffc934430 |
nav flow OriginIsOnlineTrue |
0x41bc5e2e |
| error string "log in to Origin in Online Mode." | 0x7b8fab9 |
| LSX response element table | 0x143937900 |
| LSX request element table | 0x14394dc00 |
| LSX attribute name pool | 0x14394de00 |
ONLINE_ACCESS / TRIAL_ONLINE_ACCESS |
0x1438991e8 |
Tools written this pass (all in …/scratchpad/):
origin/lsxdump.py (harvest all LSX messages from live memory),
origin/emu_live.bin + origin/emu_text.asm (unpacked emu image + disassembly),
lsx_responder.py (clean-room LSX server).