# 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
resp <-- THE GATE
resp
```
## 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,` + `mov r9d,` 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:
``
2. client → server: message carrying `response="…"` and `key="…"`
3. server → client:
``
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):
``
```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 ``), 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
```
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 `` 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 ``.
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 `` 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).