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

14 KiB
Raw Blame History

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:

  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, 0x9320bf0x932101), 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.

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:

  1. origin.nav probes GetInternetConnectedState → needs connected="1" → emits OriginIsOnlineTruestartFutBlazeLogin.
  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 GetInternetConnectedStateconnected="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).