Files
OpenFUT/fifa17-recon/tools/origin_nucleus.md
T
funman300 6ddd5e9d47 fifa17-recon: offline FUT squad-shell working + full card-system RE
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
2026-08-01 20:24:30 -07:00

295 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).