Files
OpenFUT/fifa17-recon/tools/origin_loginstate.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

291 lines
15 KiB
Markdown

# FIFA 17 OriginSDK — what makes the game consider the user LOGGED IN (LSX events)
Clean-room RE. Sources: `/mnt/games/FIFA 17/FIFA17.exe` (fully symboled; `.text` is
runtime-decrypted, so all code analysis was done on live `/proc/PID/mem` dumps taken from the
running game — `live_code.bin` @0x140001000, `live_high.bin` @0x144ed3000, both in this
scratchpad), plus our own LSX captures. No leaked material used.
Helper tools written for this pass (scratchpad): `dumplive.py`, `disasm_helper.py`, `fndis.py`
(disassemble + auto-annotate rip-relative string refs), `fnstart.py`, `callers.py`, `xref2.py`,
`bulkxref.py`, `symmap.py` (turns the binary's own `Class::Method` log strings into a symbol map).
---
## VERDICT
**The prime hypothesis is CORRECT. The OriginSDK has a distinct "user is logged in" state that is
delivered ONLY as a server-PUSHED LSX `<Login>` event. Our request-only responder never sends it,
so FIFA's Origin manager has had `m_isLoggedIn = false` for the entire session — independently of
`GetInternetConnectedState connected="1"`.**
Add this **pushed** message to `lsx_responder.py` (encrypted like any other post-handshake
message: `hex(AES128-ECB(session_key, pkcs7(xml)))` + `\0`):
```xml
<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>
```
`sender="LOGIN_EVENT"` is **mandatory and exact** (proof below). `IsLoggedIn` is parsed as a
bool by literal comparison against `"false"` — anything that is not the exact string `false`
means true, so `"true"` or `"1"` both work; `"false"` / missing attribute means logged out.
Strongly recommended companion (FIFA also subscribes to this one, and it is likewise
push-only — never requestable):
```xml
<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>
```
Optional, keeps the profile consistent:
```xml
<LSX><Event sender="PROFILE_EVENT"><ProfileEvent Changed="0" UserId="33068179"/></Event></LSX>
```
**When to send:** any time after the client's first post-handshake request (the event handler
array is built inside `Origin::OriginSDK::Initialize`, which completes before the client issues
`GetConfig`). Practical recipe: push `<Login>` right after we answer the first `GetProfile`
(id=3), and push it again after answering `GetInternetConnectedState` (id=16/18) and after
`GetGameInfo UPTODATE`. Re-sending is harmless — the handler is idempotent (it just sets a
flag and broadcasts).
---
## Evidence chain
### 1. The SDK's LSX event surface (28 push-only messages)
`Origin::OriginSDK::RegisterEventCallback` (`0x14710e710`) indexes a fixed array of
pre-constructed handlers at `sdk + 0x278 + 8*eventEnum` (29 slots — `lea ebp,[rdx+0x1d]` in the
unregister-all path).
Every handler's matcher has the identical shape (e.g. `Origin::EventHandler<lsx::LoginT,unsigned
int>::HandleMessage` @`0x14710c7d0`, matcher @`0x147102800`):
```
root element must be "LSX"
enter element <Event> (name comes from handler, always "Event")
optional attr id="..." (HasAttribute check — may be omitted)
REQUIRED attr sender="..." (must be PRESENT and strcmp-equal to handler->sender)
enter element <Login> / <OnlineStatusEvent> / ... (hardcoded per handler)
then parse that element's attributes
```
So the wire form for every event is:
```xml
<LSX><Event sender="<SERVICE>"><ElementName attr="..."/></Event></LSX>
```
Full set of inner element names the SDK listens for (extracted from the 28 matchers in
`0x147101000..0x14710a000`):
```
AchievementSets BlockListUpdated BroadcastEvent ChatMessageEvent
ChunkStatus CoreContentUpdated CurrentUserPresenceEvent
GameMessageEvent GetPresenceResponse GroupEnterEvent GroupEvent
GroupInviteEvent GroupLeaveEvent IGOEvent IGOUnavailable
Login MinimizeRequest MultiplayerInvite MultiplayerInvitePending
OnlineStatusEvent PresenceVisibilityEvent ProfileEvent PurchaseEvent
QueryEntitlementsResponse QueryFriendsResponse RestoreRequest UserInvitedEvent
VoipStatusEvent
```
(plus the separate `LSXEvent<lsx::ChallengeT>` = the `<Challenge>` handshake event we already
send.)
### 2. `sender` value = the SDK's service-name enum. Login ⇒ `LOGIN_EVENT`
`0x14710df7f` is the SDK's "create all event handlers" routine. For each handler it does:
```
edx = <service enum>; rax = GetServiceName(sdk, edx) ; 0x1470e4870 -> [sdk+0x3b0] + 0x20*edx
call <MakeHandlerN>(sdk, rax /*sender string*/, r8 = sdk+0x278 /*array*/)
```
`GetServiceName`'s runtime table is initialised from the static string-pointer table at
**`0x144341420`**:
```
0=SDK 1=PROFILE 2=PRESENCE 3=FRIENDS 4=COMMERCE 5=RECENTPLAYER 6=IGO 7=MISC 8=LOGIN
9=UTILITY 10=XMPP 11=CHAT 12=IGO_EVENT 13=EALS_EVENTS 14=LOGIN_EVENT 15=INVITE_EVENT
16=PROFILE_EVENT 17=PRESENCE_EVENT 18=FRIENDS_EVENT 19=COMMERCE_EVENT 20=CHAT_EVENT
21=DOWNLOAD_EVENT 22=PERMISSION 23=RESOURCES 24=BLOCKED_USERS 25=BLOCKED_USER_EVENT
26=GET_USERID 27=ONLINE_STATUS_EVENT 28=ACHIEVEMENT 29=ACHIEVEMENT_EVENT 30=BROADCAST_EVENT
31=PROGRESSIVE_INSTALLATION 32=PROGRESSIVE_INSTALLATION_EVENT 33=CONTENT
```
The handler's `sender` string lands at `handler+0x48`, which is `this+0x10` for the
`IEventHandler` sub-object (secondary vtable at object+0x38) — exactly what
`HandleMessage` reads (`mov rbx,[rcx+0x10]`) and strcmp's the `sender` attribute against.
Resolved handler table (service enum → array slot → OriginEventT enum → element):
| OriginEventT | array slot | service enum | `sender` string | element |
|---|---|---|---|---|
| 0 | +0x00 | 0x0c | `IGO_EVENT` | `IGOEvent` |
| 1 | +0x08 | 0x0f | `INVITE_EVENT` | `MultiplayerInvite` |
| **2** | **+0x10** | **0x0e** | **`LOGIN_EVENT`** | **`Login`** |
| 3 | +0x18 | 0x10 | `PROFILE_EVENT` | `ProfileEvent` |
| 4 | +0x20 | 0x11 | `PRESENCE_EVENT` | `GetPresenceResponse` |
| 5 | +0x28 | 0x12 | `FRIENDS_EVENT` | `QueryFriendsResponse` |
| 6 | +0x30 | 0x13 | `COMMERCE_EVENT` | `PurchaseEvent` |
| 7 | +0x38 | 0x15 | `DOWNLOAD_EVENT` | `CoreContentUpdated` |
| 8 | +0x40 | 0x19 | `BLOCKED_USER_EVENT` | `BlockListUpdated` |
| **9** | **+0x48** | **0x1b** | **`ONLINE_STATUS_EVENT`** | **`OnlineStatusEvent`** |
| 10 | +0x50 | 0x1d | `ACHIEVEMENT_EVENT` | `AchievementSets` |
| 11 | +0x58 | 0x0f | `INVITE_EVENT` | `MultiplayerInvitePending` |
| 12 | +0x60 | 0x14 | `CHAT_EVENT` | `ChatMessageEvent` |
| 13 | +0x68 | 0x11 | `PRESENCE_EVENT` | `CurrentUserPresenceEvent` |
| 14 | +0x70 | 0x1e | `BROADCAST_EVENT` | `BroadcastEvent` |
| 15 | +0x78 | 0x11 | `PRESENCE_EVENT` | `PresenceVisibilityEvent` |
| 16 | +0x80 | 0x13 | `COMMERCE_EVENT` | `QueryEntitlementsResponse` |
| 17 | +0x88 | 0x20 | `PROGRESSIVE_INSTALLATION_EVENT` | `ChunkStatus` |
| 18 | +0x90 | 0x0c | `IGO_EVENT` | `IGOUnavailable` |
(13 independent service-enum/element pairings all agree with the table — the mapping is not a guess.)
### 3. `<Login>` carries exactly one attribute: `IsLoggedIn`, parsed as a bool
Deserializer `0x147138640` (reached via `0x14712fd80` from the `Login` matcher) reads exactly one
attribute name, `"IsLoggedIn"` (string @`0x14394e0f0`), then converts with `0x14713ffa0`:
```
0x14713ffc8: lea rdx, "false"
0x14713ffd2: call strcmp
0x14713ffda: setne al ; value != "false" => true
0x14713ffdd: mov [rdi], al ; stored as a byte
```
Same helper is used for `<OnlineStatusEvent isOnline="...">` (`0x147139e6e`) and
`<PresenceVisibilityEvent Visible="...">`. `<ProfileEvent>` carries `Changed` + `UserId`.
### 4. FIFA subscribes to the Login event, and it is the ONLY event that mutates
### FIFA's Origin login state
FIFA registers 9 Origin event callbacks in a loop at `0x146f33e90`+ (call site `0x146f34068`
`OriginRegisterEventCallback` @`0x1470db310`), from a constant array:
```
xmm0 @0x143565970 = {0,1,2,3}
xmm1 @0x143902060 = {4,5,6,7}
plus 9
=> enums {0,1,2,3,4,5,6,7,9} (all with the same callback 0x146f20c20)
```
Cross-referenced against the table above, FIFA subscribes to exactly:
IGOEvent, MultiplayerInvite, **Login**, ProfileEvent, GetPresenceResponse, QueryFriendsResponse,
PurchaseEvent, CoreContentUpdated, **OnlineStatusEvent**.
`0x146f20c20` forwards to the FIFA Origin-manager dispatcher `0x146f1e060`
(manager singleton via `0x146f28790`, global `[0x1448acf50]`). Its `case 2` is the only case that
writes state:
```
0x146f1e099: cmp edx, 2 ; eventEnum == 2 (Login)
0x146f1e09e: cmp DWORD PTR [r9], 1 ; converted IsLoggedIn == 1 ?
0x146f1e0a2: lea rbx,[rcx+0x80] ; listener list for event 2
0x146f1e0ab: mov BYTE PTR [rcx+0x13], 1 ; <== OriginMgr.m_isLoggedIn = TRUE
0x146f1e0af: mov DWORD PTR [rcx+0x14], 0 ; <== clear login error/reason
(else)
0x146f1e0b8: mov BYTE PTR [rcx+0x13], 0 ; m_isLoggedIn = FALSE
0x146f1e116: broadcast to listener list
```
All other cases (0,1,3,4,5,6,7,9) only broadcast to their listener list.
`OriginMgr+0x13` is written from exactly two places in the whole image: this Login-event case,
and `0x146f20c80` (the async login/`CheckOnline` completion callback, which also clears +0x18).
**There is no requestable LSX verb that sets it** — it is unreachable without a pushed `<Login>`.
### 5. Why this matters: GetAuthCode is enqueue-driven and has never been enqueued
* FIFA-level auth-code issuer: `FifaOnline::FirstPartyAuthTokenRetriever::DoTick` @`0x146f199b9`
`OriginRequestAuthCodeSync` @`0x1470db3c0``Origin::OriginSDK::RequestAuthCodeSync`
@`0x1470e67f0` → LSX `<GetAuthCode>`.
* `DoTick` walks a **2-slot request array** at `retriever+0x8` and does nothing when both slots
are null (`0x146f199e0: mov rsi,[rbx]; test rsi,rsi; je <exit>`).
* Enqueue path: `RequestAuthCode` @`0x146f5b8ab`, reached via thunk `0x146f57bf0`
(`mgr = *[0x1448a3b20]` gated by byte `[0x1448a3ac3]`; retriever = `mgr + 0x4e98`).
* **Live read of the stuck game (PID 3362053) confirmed:**
`[0x1448a3ac3] = 1`, `mgr = 0x43dc3e70`, retriever = `0x43dc8d08`, **both slots = 0x0**
no auth-code request has ever been created. This is why `/tmp/openfut_authcode.txt` stays
empty and `GetAuthCode` never appears in the LSX log. The SDK itself has no login gate inside
`RequestAuthCodeSync`; the gate is entirely FIFA-side, upstream of the enqueue.
### 6. Corroborating: `LoginStatePCLogin` has an explicit "not logged in to Origin" abort
FIFA's Blaze login state machine (`LoginStateMachineImpl`, `LoginStateBase` subclasses; per-state
`GetName` thunks in `0x14719b360..0x14719b4a7`; states: ShowMaintenance, Isp, LoadIspAccountInfo,
Connect, LoadConfig, Logout, VersionCheck, Login, **PCLogin**, VerifyAccount, UpgradeAccount,
LoginComplete, Unsuspend, CheckUser, RecheckUser, WebOffer).
`LoginStatePCLogin` vtable @`0x14395c188`; its driver is `0x1471b58e0` (a 0x19-case sub-state
machine on `this+0x260`). Its very first sub-state does:
```
0x1471b59a5: rcx = *[0x144b86bf0]; call [vt+0x60] ; get the Origin/Ebisu session object
0x1471b59b5: test rax,rax
0x1471b59b8: je 0x1471b5b42 ; NULL -> failure branch
...
0x1471b5b64: lea rax, "TXT_NOT_LOGIN_TO_EBISU" ; loc key stored at state+0x80
0x1471b5b72: mov DWORD PTR [r14+0x260], 1 ; -> error sub-state
```
("Ebisu" is EA's internal codename for Origin; the sibling key
`TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE` is the "title version outdated" gate we already beat via
`GetGameInfo UPTODATE`. Both live at `0x1439633e8` / `0x14395bfc0`-ish in the same loc-key block.)
Also proven live: FIFA's separate "Origin is online" byte `[0x1448a3ac0]` was **1** during the
stuck session, so the *internet/online* gate (fed by `GetInternetConnectedState`, callback
`0x146f1e6ae`, broadcasts `FE::FIFA::OriginOnlineEvent`) is already satisfied. **Online ≠ logged
in.** They are two different flags with two different feeds; we only ever fed the first one.
---
## Wire recipe for `lsx_responder.py`
Same framing as the existing `<Challenge>` event we already push successfully
(NUL-terminated; after the handshake everything is `hex(AES128-ECB(session_key, pkcs7(xml)))`):
```python
def push_event(conn, key, xml):
conn.sendall(lsx_encrypt(xml, key))
LOGIN_EVENT = '<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>'
ONLINE_EVENT = '<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>'
PROFILE_EVT = '<LSX><Event sender="PROFILE_EVENT"><ProfileEvent Changed="0" UserId="33068179"/></Event></LSX>'
```
Trigger points (after sending our normal `<Response>`):
1. after answering the first `GetProfile` → push `LOGIN_EVENT`, then `ONLINE_EVENT`
2. after answering `GetInternetConnectedState` → push `LOGIN_EVENT` again
3. after answering `GetGameInfo UPTODATE` → push `LOGIN_EVENT` + `ONLINE_EVENT` again
Notes / gotchas:
* `sender` must be **exactly** `LOGIN_EVENT` / `ONLINE_STATUS_EVENT`. A wrong or missing `sender`
makes `HandleMessage` return false and the message is silently dropped (no error, no crash) —
which is exactly the failure mode to watch for.
* An `id` attribute on `<Event>` is optional.
* Sending an event before `OriginSDK::Initialize` has built the handler array is a silent no-op,
so never send before the client's first request.
* If the game still doesn't call `GetAuthCode` after this, the next thing to instrument is
`OriginMgr+0x13` (byte) and `OriginMgr+0x14` (dword) via `/proc/PID/mem`
(`OriginMgr = *[0x1448acf50]`): +0x13 flipping 0→1 proves the event landed and moves the
investigation downstream to `LoginStatePCLogin` / the Blaze `Authentication::logout` loop.
## Open / not proven
* I could not statically locate the consumer that *reads* `OriginMgr+0x13` (no matching
`[reg+0x13]` byte-read in the FIFA online region) — it is presumably an inlined accessor or a
reaction to the event-2 broadcast. So "flag false ⇒ GetAuthCode never enqueued" is a strong
inference from (a) the flag being login-specific, (b) `LoginStatePCLogin`'s
`TXT_NOT_LOGIN_TO_EBISU` abort and (c) the live-confirmed empty auth-code slots — but the exact
read site is unconfirmed. Instrumenting +0x13 live is the cheap way to close this.
* The one identified event-2 listener list subscriber is the EAStore/DLC subsystem
(registrar `0x14735077f`, listener `0x147350e30`), not the login flow — consistent with the
login gate reading the flag rather than listening.
* The Blaze side still ends in `Authentication::logout (1/0x46)` → disconnect →3s ping-reconnect
loop (`/tmp/blaze_responder.log`). If the Login event does not change that, the second
candidate is the FIFA `LoginStateMachine` transition out of `LoginStateLogout`, which is a
separate (Blaze-side) investigation.