6ddd5e9d47
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
295 lines
14 KiB
Markdown
295 lines
14 KiB
Markdown
# 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:
|
||
|
||
```json
|
||
,{ "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:
|
||
|
||
1. The offline verdict is a **hard-coded string literal**, not a computed decision. There
|
||
is no "check" to patch — only a canned answer.
|
||
2. **The emu can never answer `GetAuthCode`, `QueryEntitlements`, or a *second*
|
||
`GetInternetConnectedState`.** It has no `AuthCode` / `QueryEntitlementsResponse`
|
||
template at all. Everything after step 18 is `ErrorSuccess`. That is exactly why the
|
||
re-probe at `id=22` above got `ErrorSuccess` instead 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`.** On `bind()` failure it branches to `0x6ffffc932245`:
|
||
`freeaddrinfo → closesocket → WSACleanup → return 1`. It exits **cleanly** — no crash,
|
||
no retry.
|
||
* It accepts **exactly one** connection then closes the listener. Confirmed live: `ss`
|
||
shows the ESTAB pair `127.0.0.1:4216 <-> 127.0.0.1:51162` but **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:**
|
||
|
||
1. server → client:
|
||
`<LSX><Event sender="EALS"><Challenge key="<32 hex>" build="release" version="10,4,13,6637"/></Event></LSX>`
|
||
2. client → server: message carrying `response="…"` and `key="…"`
|
||
3. server → client:
|
||
`<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="H"/></Response></LSX>`
|
||
where `H = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED))`, 64 hex chars,
|
||
and `K_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>`
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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`).
|
||
|
||
```xml
|
||
<!-- 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:
|
||
|
||
1. `origin.nav` probes `GetInternetConnectedState` → needs `connected="1"` →
|
||
emits `OriginIsOnlineTrue` → `startFutBlazeLogin`.
|
||
2. The client calls LSX `GetAuthCode` (with a `ClientId`) → our `<AuthCode Code="…"/>`.
|
||
This is `lsx::GetAuthCodeT → lsx::AuthCodeT`, handler
|
||
`…GetAuthCodeT,struct lsx::AuthCodeT…::HandleMessage` @ `0x1439385cf`.
|
||
3. That code is then presented to **Blaze component `0x0001` (Authentication)** — the
|
||
nucleus path (`nucleusConnect` / `nucleusConnectTrusted` / `nucleus_id`,
|
||
`ExpressLoginRequest`, `GetAuthTokenResponse`, `GetUserAccessTokenResponse`). This is
|
||
the empty-payload `0x0001/0x0046` call we already saw stall in the session log.
|
||
4. 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
|
||
|
||
1. Start `lsx_responder.py` (binds 4216).
|
||
2. Start the Blaze stack (`blaze_responder_v2.py`).
|
||
3. Launch FIFA 17. Watch the LSX log for `GetInternetConnectedState` → `connected="1"`,
|
||
then for `GetAuthCode` and which attribute the client reads back.
|
||
4. Expect the nav flow to advance `OriginIsOnlineTrue → startFutBlazeLogin`, putting the
|
||
stall back on Blaze `0x0001` — 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).
|