From 6ddd5e9d4721fcc11905e0be51d19f8581b0ad53 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 1 Aug 2026 20:24:30 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o --- fifa17-recon/docs/CARD_SYSTEM.md | 114 ++ fifa17-recon/tools/acct_error_trace.md | 340 ++++ fifa17-recon/tools/acct_retrieval.md | 544 +++++++ fifa17-recon/tools/auth_refs.md | 401 +++++ fifa17-recon/tools/auth_schema_reflection.md | 527 ++++++ fifa17-recon/tools/auth_statemachine.md | 274 ++++ fifa17-recon/tools/auth_watch.py | 72 + fifa17-recon/tools/blaze_responder.py | 113 ++ fifa17-recon/tools/blaze_responder_v2.py | 575 +++++++ fifa17-recon/tools/blaze_responder_v3.py | 1387 ++++++++++++++++ .../tools/blaze_responder_v3_patched.py | 1408 +++++++++++++++++ fifa17-recon/tools/blaze_responder_v3b.py | 3 + fifa17-recon/tools/capture_lsx.py | 141 ++ fifa17-recon/tools/decode_fire2.py | 67 + fifa17-recon/tools/dump_login_code.py | 96 ++ fifa17-recon/tools/fifadrive.sh | 69 + fifa17-recon/tools/force_login_flag.py | 55 + fifa17-recon/tools/forge_node.py | 92 ++ fifa17-recon/tools/fut_flow.md | 349 ++++ fifa17-recon/tools/fut_seed.py | 159 ++ fifa17-recon/tools/login_dump/manifest.txt | 17 + fifa17-recon/tools/lsx_force_online.py | 341 ++++ fifa17-recon/tools/lsx_responder.py | 242 +++ fifa17-recon/tools/memtool.py | 45 + fifa17-recon/tools/origin_login_probe.py | 134 ++ fifa17-recon/tools/origin_loginstate.md | 290 ++++ fifa17-recon/tools/origin_nucleus.md | 294 ++++ fifa17-recon/tools/preauth_refs.md | 366 +++++ .../tools/preauth_schema_reflection.md | 265 ++++ fifa17-recon/tools/trace_login.gdb | 89 ++ fifa17-recon/tools/trace_login.sh | 15 + fifa17-recon/tools/utas_server.py | 97 +- fifa17-recon/tools/verify_preauth.py | 222 +++ fifa17-recon/tools/vgamepad.py | 155 ++ fifa17-recon/tools/watch_login.gdb | 57 + fifa17-recon/tools/watch_login.sh | 11 + 36 files changed, 9421 insertions(+), 5 deletions(-) create mode 100644 fifa17-recon/docs/CARD_SYSTEM.md create mode 100644 fifa17-recon/tools/acct_error_trace.md create mode 100644 fifa17-recon/tools/acct_retrieval.md create mode 100644 fifa17-recon/tools/auth_refs.md create mode 100644 fifa17-recon/tools/auth_schema_reflection.md create mode 100644 fifa17-recon/tools/auth_statemachine.md create mode 100644 fifa17-recon/tools/auth_watch.py create mode 100644 fifa17-recon/tools/blaze_responder.py create mode 100644 fifa17-recon/tools/blaze_responder_v2.py create mode 100644 fifa17-recon/tools/blaze_responder_v3.py create mode 100644 fifa17-recon/tools/blaze_responder_v3_patched.py create mode 100644 fifa17-recon/tools/capture_lsx.py create mode 100644 fifa17-recon/tools/decode_fire2.py create mode 100755 fifa17-recon/tools/dump_login_code.py create mode 100644 fifa17-recon/tools/fifadrive.sh create mode 100644 fifa17-recon/tools/force_login_flag.py create mode 100644 fifa17-recon/tools/forge_node.py create mode 100644 fifa17-recon/tools/fut_flow.md create mode 100644 fifa17-recon/tools/fut_seed.py create mode 100644 fifa17-recon/tools/login_dump/manifest.txt create mode 100644 fifa17-recon/tools/lsx_force_online.py create mode 100644 fifa17-recon/tools/lsx_responder.py create mode 100644 fifa17-recon/tools/memtool.py create mode 100644 fifa17-recon/tools/origin_login_probe.py create mode 100644 fifa17-recon/tools/origin_loginstate.md create mode 100644 fifa17-recon/tools/origin_nucleus.md create mode 100644 fifa17-recon/tools/preauth_refs.md create mode 100644 fifa17-recon/tools/preauth_schema_reflection.md create mode 100644 fifa17-recon/tools/trace_login.gdb create mode 100755 fifa17-recon/tools/trace_login.sh create mode 100644 fifa17-recon/tools/verify_preauth.py create mode 100644 fifa17-recon/tools/vgamepad.py create mode 100644 fifa17-recon/tools/watch_login.gdb create mode 100755 fifa17-recon/tools/watch_login.sh diff --git a/fifa17-recon/docs/CARD_SYSTEM.md b/fifa17-recon/docs/CARD_SYSTEM.md new file mode 100644 index 0000000..13f456d --- /dev/null +++ b/fifa17-recon/docs/CARD_SYSTEM.md @@ -0,0 +1,114 @@ +# FIFA 17 FUT — Card System RE + Next-Steps Plan + +Status as of 2026-08-01. All clean-room (our own binaries + running client only). + +## Where we are + +Offline FIFA 17 Ultimate Team runs end-to-end on our backend: +- Every EA online gate cracked (Origin/LSX, Blaze, UTAS/RS4) → **FUT hub**. +- **Squads editor renders**: correct 4-4-2 formation, 11 labelled slots, 5-star + squad rating, manager slot, benches — **no freezes**. +- The one missing piece: player cards render as the generic "FUT 17" back + (rating 0) — no real player *identity/face* resolves. + +## The squad-shell (what makes it work — don't regress these) + +- `userMassInfo` MUST return `{}`. Any content (userInfo AND/OR squad) desyncs the + massinfo parser (0x180174630) → infinite tokenizer spin (busy-loop freeze at + 0x1801c7f1a). `utas_server.py`: `FUT_MASSINFO=empty` (default). +- Deliver the squad via **`GET /squad/0`** (LoadActiveSquad, deser 0x18013d1f0), + which FIFA fetches on **Squads-tab entry** (re-fetches on tab-switch, not on + editor re-open). `FUT_SQUAD_STEP` selects the squad (s2v0 = 1 real item). +- `GET /user` is NEVER called at boot — userInfo can only reach FIFA via + userMassInfo (which we can't populate without the desync). So the hub's + coins/record can't be shown until the userInfo-in-massinfo desync is solved. + +## Why cards render generic (definitively reversed — 3 workflows) + +The card view-model (0x1800d7920) reads EVERY rendered field +(rating@+0xb4, position@+0x146, nation@+0x148, teamid@+0x94, 6 attrs@+0x98..0xac, +name@+0xdd) from a **resolved player-definition record at `item+0x10`** — NEVER +from our item JSON. That's why item-format/version/field changes had zero effect. + +`item+0x10` is filled by the resolve at 0x180141160-76: +`getter 0x18011a830 → CardsDb singleton [0x1802e6398] → call [vtable+0xa08]` +(= lookup **0x18011cca0**), output buffer `[rbp+0x160]`. The lookup searches a +std::map at `CardsDb_obj+0x160c0` keyed by resourceId. **Offline that map is +EMPTY**, so every lookup misses and a **default blank record** is emitted → +generic card. + +Dead ends (proven, do not retry): +- **Version advertising is inert.** `itemDbVersion` (atom 0x16c) and + `checkServerDbVersion` (atom 0x80) are JSON field names routed to the value-SKIP + handler 0x180135ff0 in every parser — parsed and discarded, never compared. + Roster-version bump and Blaze `itemDbVersion=999999999` both did nothing. +- **Serving owned items does NOT auto-trigger a definition fetch.** The + itemData-array parser (0x1801293d0→0x18013fe00) does no membership check and no + enqueue; the render-miss is terminal. Our squad already has an owned item + rendering generic with 0 `idList` fetches — empirical proof. +- **In-place map overwrite is dead** — the map at `+0x160c0` stays empty even with + all 11 cards rendering (probed live). The resolve emits a transient default to + the caller stack each frame; nothing persistent to edit. + +The definition FETCH (`ut/17/item?idList=`, `/item/resource`, `/defid`; URL builder +0x180129200) is issued by FUT-controller vtable method 0x180119010 / requestDefinitions +0x180036e20 — **both have zero call-sites inside CardsDLL**. The decision to fetch +lives in the **packed FIFA17.exe** (decrypted in live memory only). + +Our definition-serving endpoints (`item_def`/`defs_route` in `utas_server.py`, +routes `item/resource`/`defid`/`item?idList=`) are **built and ready** for if/when +the fetch is ever driven. + +## Next-steps plan (real player faces) — ordered by tractability + +### Option A — Drive the fetch from FIFA17.exe (most "correct", hardest to find) +FIFA17.exe is packed on disk but decrypted in live memory (Wine flat-maps at +0x140000000). Find the call-site of the idList issuer (CardsDLL vtable method +0x180119010, .rdata slot 0x18021cb78) inside the live FIFA17.exe image: set a +hardware breakpoint / rwatch on that slot's invocation, or scan decrypted .text +for the call. Identify what condition it gates on and satisfy it. If FIFA then +requests `item?idList=`, our endpoints already answer → cards resolve. This +is the clean win: no ongoing memory writes, works via normal data flow. + +### Option B — Populate the CardsDb store so lookups HIT (live-memory injection) +Pre-insert real records into the std::map at `CardsDb_obj+0x160c0` +(node: Left+0x0/Right+0x8/Parent+0x10/color+0x18/key(resourceId)+0x20/record+0x28). +Two sub-approaches: +- **B1 (call the game's own insert):** drive the map's find-or-insert + (0x180115c30, reached from lookup 0x18011cca0) with a resourceId + a record we + fill. Requires a small code-injection harness (set up registers + call) since + the insert isn't reachable from our side otherwise. +- **B2 (hand-build a node):** allocate a node in FIFA's heap, write + left/right/parent/color/key/record, splice into the tree + rebalance. Fiddliest; + RB-tree invariants must hold or later lookups corrupt. +Record fields to fill (from base `dbdata.dll`): rating@+0xb4, position@+0x146, +nation@+0x148, teamid@+0x94, 6 attrs@+0x98..0xac, name@+0xdd. + +### Option C — Patch the resolve miss-path (proof-of-concept, then key it) +Patch lookup 0x18011cca0's miss branch to write real fields into its output record +`[rbp+0x160]`. Quickest to see *a* real card, but naively makes ALL cards show one +player; must be keyed by resourceId to be useful. Good first experiment to confirm +the field offsets end-to-end before investing in A or B. + +### Prerequisite for all: a dbdata.dll extractor +Build a tool to read the base player DB (`/mnt/games/FIFA 17/dbdata.dll`, +single export `getTableData`; tables players/playernames/teams/nations/ +teamplayerlinks) → a `resourceId → {name,rating,pos,nation,team,attrs}` table +(resourceId = playerId | version<<24; Ronaldo playerId 20801). This feeds B and C +and validates A. No such tool exists yet. + +## Recommended order +1. **C** as a 30-minute proof: patch the miss-path to emit a fixed real record → + confirm a real card face appears (validates the whole record-offset model live). +2. Build the **dbdata extractor** (needed by everything). +3. Attempt **A** (find the FIFA17.exe fetch trigger) — the clean, durable win. +4. Fall back to **B1** (drive the game's insert) if A's trigger proves unreachable. + +## Reusable tooling +- `/proc/PID/mem` read/write pattern: `autopatch.py`, the poke/force tools + (ptrace_scope=0 armed by `root_arm.sh`). +- Live vtable/struct probing: the python snippets used this session (singleton + `[0x1802e6398]`, static↔live base map). +- `fifadrive.sh` (screen capture) + `vgamepad.py` (virtual pad) for headless + drive/observe — note keyboard XTEST does NOT reach FIFA; the gamepad is unverified + in-game. diff --git a/fifa17-recon/tools/acct_error_trace.md b/fifa17-recon/tools/acct_error_trace.md new file mode 100644 index 0000000..04aed25 --- /dev/null +++ b/fifa17-recon/tools/acct_error_trace.md @@ -0,0 +1,340 @@ +# "Unable to retrieve account information. Please try again." — full trace (FIFA 17) + +Clean-room. Sources: live `/proc/PID/mem` of FIFA17.exe (PID 3362053, base `0x140000000`), +cached image dumps `scratchpad/live_code.bin` (`0x140001000..0x144c23000`) + +`live_high.bin` (`0x144ed3000..0x151359000`), and `scratchpad/navregion.bin` (heap nav JSON). +All VAs absolute, stable across runs. New tools: `fx.py` (offline image str/dis/xref/callers), +`memsearch2.py` (ASCII+UTF-16 live search), `ptrscan.py`, `livedis.py`, `imgstr.py`. + +--- + +## 0. Headline + +The popup is **not** a login *failure*. It is the OSDK **generic alert** raised on a +**60-second timeout** while the OSDK login state machine waits for a step to complete. +The loc id is **`OSDK_A_R30B`**. The two states that raise it are +**`LoginStateConnect`** (waiting for all six `Util::fetchClientConfig` results) and +**`LoginStateLoadIspAccountInfo`** (waiting for the account country/locale from the OSDK +`'cnnc'` service). Both sit **upstream of `LoginStatePCLogin`**, which is where +`GetAuthCode` + `Authentication::login` live — which is exactly why neither is ever seen. + +--- + +## 1. The string is not in the EXE + +Live search (ASCII **and** UTF-16LE) over every readable mapping: 14 hits, **all in +anonymous heap**, zero inside the image range `0x140000000..0x151359000`. +So it is a **localized string loaded from data**, resolved at runtime. + +Representative heap copies (ref-counted `[u16 refcnt=1][u16 len=0x39][u16 cap]` header, +MSVC debug heap `cd`/`fe` fill around them): + +``` +0x0000b7563b00 "Unable to retrieve account information. Please try again." +0x0000b79ad020 same +0x0000bc81ba70 same +0x00000db86f88 UTF-16LE render copy +0x000078f5380 entry in a 64-byte-slot string table (slots 0x78f5280,2c0,300,340,380,3c0,…) +``` + +## 2. Who owns the string — the popup record + +Pointer-scan for holders of `0x78f5380` found an FE parameter record at `0x41abd7c0`, +a run of `{begin,end,cap,alloc}` string triples: + +| field | value | +|---|---| +| `0x41abd798` | `"OnlineLoginViewModel"` (20 ch) | +| `0x41abd7c0` | `"ALERT_POPUP"` (11 ch) | +| `0x41abd7e0` | `"Unable to retrieve account information. Please try again."` (0x39 = 57 incl NUL) | + +=> the text is the **`ALERT_POPUP` data-provider value of `OnlineLoginViewModel`**. + +Confirming strings in the image: + +``` +0x143b4d6c0 OnlineLoginViewModel +0x143b4d890 UIFrameworkService::UIFDataProvider::PopupDp +0x143b4d8c0 LOGIN_POPUP +0x143b4d8e0 ALERT_POPUP +0x143b4d8f0 processLoginAlert +0x143b4d908 processBootLoginFailure +0x143b4d920 processLoginFailure +``` + +## 3. Which FE action sets it + +`OnlineLoginViewModel` action trampoline **`0x147dc62c0`** +(`this=rcx, actionId=edx, params=r8`): + +``` +1471dc62dc: lea eax,[rdx-0x27a4] ; actionId - 10148 +1471dc62ea: cmp eax,0x6 ; ja ->ret ; only ids 0x27a4..0x27aa +1471dc6310: lea rdx,"StrParam" ; call [params->vt+0x28] ; HasParam("StrParam") +1471dc6329: lea rdx,"StrParam" ; call [params->vt+0x20] ; GetString("StrParam", &buf) +1471dc634d: call 0x147e03080 ; handler(this, id, buf) +``` + +Handler **`0x147e03080`** — one function, 7-way jump table +(`add ebx,[0x146b1d808]` where the dword = `-10148`; table at `0x1429f9148`): + +| case | target | does | +|---|---|---| +| 0 | `0x147e03134` | sets `LOGIN_POPUP` | +| 1 | `0x147e03579` | sets `LOGIN_POPUP` | +| **2** | **`0x147e03682`** | **sets `ALERT_POPUP` (`0x147e03690`) + button `OSDK_OK` + callback `processLoginAlert`** | +| 3 | `0x147e03ac6` | `processBootLoginFailure` | +| 4 | `0x147e03b3e` | `ALERT_POPUP` + `OSDK_OK` + `processLoginFailure` | +| 5 | `0x147e04015` | popup hide | +| 6 | `0x147e03fe4` | popup show | + +Action-id ↔ name registration block (`0x147de8330..0x147de8544`, `mov edx,` + +`lea rdx,` + `call [vt+0x20]`): + +| id | name | case | +|---|---|---| +| `0x27a4` | `onlineLoginToEaPopup` | 0 | +| `0x27a5` | `onlineBootLoginToEaPopup` | 1 | +| **`0x27a6`** | **`evt_onlineAlertPopup`** | **2** | +| `0x27a7` | `evt_onlineBootLoginFailurePopup` | 3 | +| `0x27a8` | `evt_onlineLoginFailurePopup` | 4 | +| `0x27a9` | `onlineLoginPopupHide` | 5 | +| `0x27aa` | `onlineLoginPopupShow` | 6 | + +**=> the popup is `evt_onlineAlertPopup` (0x27a6), message carried in param `StrParam`.** +It is explicitly *not* `evt_onlineLoginFailurePopup` / `evt_onlineBootLoginFailurePopup`. + +## 4. The FE flow (`/online/onlineLoginFlow.nav`) + +Recovered verbatim from `navregion.bin` (offset ~31456301); saved to +`scratchpad/onlineLoginFlow_raw.txt`: + +```json +{ "name":"onlineLoginFlow" +, "onEnter":[["loadViewModel", ["OnlineLoginViewModel"]]] +, "states":[{ "transitions":[ + { "event":"evt_onlineAlertPopup", "targets":["onlineAlertLoginPopup"] } + ,{ "event":"evt_onlineBootLoginFailurePopup", "targets":["onlineFailureLoginPopup"] } + ,{ "event":"evt_onlineLoginFailurePopup", "targets":["onlineFailureLoginPopup"] } + ,{ "event":"evt_online_disconnected", "targets":["processLoginFailure"] } ...] + ,"states":[ + { "name":"startLoginWithoutMultiplayerCheck" + ,"onEnter":[["sendScreenEvent", ["OnlineLogin", "0"]]] } + ,{ "name":"onlineAlertLoginPopup" + ,"onEnter":[["sendAction", ["onlineLoginPopupShow"]]] + ,"onExit" :[["sendAction", ["onlineLoginPopupHide", "ALERT_POPUP"]]] + ,"states":[{ "name":"onlineAlertLoginIdle" + ,"transitions":[{"event":"processLoginAlert","targets":["processLoginAlert"]}]} + ,{ "name":"processLoginAlert" + ,"onEnter":[["sendScreenEvent", ["ProcessLoginAlert"]]] }]} ...]}]} +``` + +Entry per `nav/root.nav`: `launchFUTFlow` (`/online/origin.nav`) →`OriginIsOnlineTrue`→ +`futBlazeLogin` (`/online/onlineLoginFlow.nav`, entry `startLoginWithoutMultiplayerCheck`) +→ `loginSuccess` → `CheckFUTRosters` → `futFlow`. + +**We are inside `futBlazeLogin`.** `origin.nav` already emitted `OriginIsOnlineTrue` +(our LSX work) — the wall is one layer deeper. + +## 5. The C++ that raises it — OSDK login state machine + +EA **Online SDK (OSDK) 8.01.03.00-fifa.01**; source paths embedded, e.g. +`E:/p4/fifafb/rl/empatch/TnT/Code/fifa/gamemodes/extern/OSDK/8.01.03.00-fifa.01/source/common/modulemanager.cpp`. + +States are classes whose vtable slot 1 is a `GetName()` thunk (`lea rax,; ret`) +in `0x14719b3xx`; vtable slot 4 (`+0x20`) is the per-tick handler; slot 8 (`+0x40`) is +"advance to next state". + +| state | vtable | handler (vt+0x20) | +|---|---|---| +| `LoginStateIsp` | `0x14395bc78` | `0x1471b45f0` | +| `LoginStateConnect` | `0x14395be20` | `0x1471b4a40` | +| `LoginStateLoadIspAccountInfo` | `0x14395bd18` | `0x1471b4b40` | +| `LoginStatePCLogin` | `0x14395c180` | `0x1471b58e0` | + +Shared alert builder **`0x147190e80`** — `OSDK_RaiseError(this, Notification* out, int code, const char* locId)`: + +``` +147190e9b: mov byte [rdx],1 ; notif type = error +147190e9e: mov byte [rdx+2],1 +147190ea2: mov [rdx+8],r8d ; numeric code +147190ebd: lea rcx,[rdi+0xc] ; call 0x145e27ff0 ; strcpy(notif+0xc, locId) +147190ee0: mov byte [rdi+0x20b],0 +147190f16: lea rdx,"error" ; lea rcx,"osdk" ; call 0x146ec02d0 ; telemetry osdk/error/%d +``` + +It has **16 call sites**, all inside the login-state cluster `0x1471b4xxx..0x1471b7xxx` +— i.e. it is *the* OSDK "raise login alert with loc id" entry point. The notification +carries the **loc-id string**, which the OSDK→FE bridge resolves to localized text and +delivers as `StrParam` on `evt_onlineAlertPopup`. + +Loc ids raised by neighbouring states (for contrast): + +``` +0x14395cdd8 OSDK_SIGNIN_REQUIRED (LoginStateIsp, default branch) +0x14395ce48 OSDK_ONLINE_OUTDATED_PATCH (LoginStateIsp, NetConn 4CC '-upp') +0x14395ce68 OSDK_ONLINE_DISABLED_PARENTAL (LoginStateIsp, NetConn 4CC '-adu') +0x14395cdc8 OSDK_ERROR +0x14395c6b8 OSDK_A_R30B <<< our popup +``` + +### 5a. `LoginStateConnect` — handler `0x1471b4a40` (STRONGEST CANDIDATE) + +Sub-state in `[this+0x20]`, deadline in `[this+0x24]`. + +``` +; ---- sub-state 0 : kick off ---- +1471b4b0d: mov rcx,[rbx+0x28] +1471b4b11: call 0x1471a8970 ; issue all client-config loads +1471b4b16: lea eax,[rdi+0xea60] ; rdi = now(ms); 0xea60 = 60000 = 60 s +1471b4b1c: mov [rbx+0x20],1 ; -> polling +1471b4b23: mov [rbx+0x24],eax ; deadline = now + 60 s + +; ---- sub-state 1 : poll ---- +1471b4ab4: mov rax,[rbx+0x28] ; xor edx,edx +1471b4aba: mov r8d,[rax+0x60] ; entry count +1471b4abe: test r8d,r8d ; je -> sub-state 3 ; nothing pending => done +1471b4ac3: mov r9d,[rax+0x64] ; stride +1471b4ac7: mov r10,[rax+0x58] ; array base +1471b4acb: (loop) mov rcx,[base + idx*stride] +1471b4ad5: cmp byte [rcx+0x60],0 +1471b4ad9: je 0x1471b4af4 ; <-- this entry NOT ready +1471b4ae2: mov [rbx+0x20],3 ; all ready -> success +1471b4af4: sub edi,[rbx+0x24] ; now - deadline +1471b4af7: test edi,edi ; jle -> ret ; still inside the 60 s window: keep waiting +1471b4afb: mov [rbx+0x20],2 ; TIMEOUT + +; ---- sub-state 2 : alert ---- +1471b4a86: lea r9,"OSDK_A_R30B" ; xor r8d,r8d ; mov rdx,r10 ; mov rcx,rbx +1471b4a9b: call 0x147190e80 +``` + +`0x1471a8970` iterates the same array and for each entry calls `0x1471a89f0`, which: + +``` +1471a8a20: mov byte [rcx+0x60],0 ; clear the "ready" flag the poll loop reads +1471a8a2c: lea r8,"LoadCfg:%s" ; 0x143962390 +``` + +**`LoadCfg:%s` is `Util::fetchClientConfig`.** The config-name list sits immediately +after it, and matches the six CFIDs we observe on the wire byte-for-byte: + +``` +0x143962be8 OSDK_CORE +0x143962bf8 OSDK_CLIENT +0x143962c08 OSDK_NUCLEUS +0x143962c18 OSDK_WEBOFFER +0x143962c28 OSDK_ABUSE_REPORTING +0x143962c40 OSDK_TICKER +``` + +The entries are `ResourceLoader` objects (`0x143962960..0x143962b60`, states +`LOADED` / `LOADING` / `NOT_FOUND`, `ResourceLoader::ResourceFailure()`). +`[entry+0x60]` is the "loaded OK" flag. **An empty / failed config reply leaves it 0.** + +### 5b. `LoginStateLoadIspAccountInfo` — handler `0x1471b4b40` (SECOND CANDIDATE) + +Same 4-sub-state shape: + +``` +; sub-state 0 : GetService('cnnc')->[+0x38]()->[+0xb0]() ; -> sub-state 3 +1471b4e9d: mov rcx,[0x144b86bf8] ; mov edx,0x636e6e63 ('cnnc') +1471b4eac: call [rax+0x60] ; mgr->GetService('cnnc') +1471b4eb5: call [rdx+0x38] +1471b4ebe: call [rdx+0xb0] + +; sub-state 1 : poll the same accessor +1471b4e6d: call [rdx+0xb0] -> eax +1471b4e73: test eax,eax ; je -> timeout check ; 0 = not resolved +1471b4e77: cmp eax,0x5a5a ; jne -> sub-state 3 ; 'ZZ' = placeholder +1471b4e7e: sub ebx,[rdi+0x24] ; jle -> ret ; inside deadline: keep waiting +1471b4e85: mov [rdi+0x20],2 ; TIMEOUT + +; sub-state 2 : alert +1471b4e1f: lea r9,"OSDK_A_R30B" ; call 0x147190e80 + +; sub-state 3 : SUCCESS — parse the account locale +1471b4b84: mov rsi,[0x144b86bf8] ; mov rdx,[rsi+0xb0] ; char* country/locale + ... packs 4 chars, lowercases pair 1 (|0x20), uppercases pair 2 (&0xdf) +1471b4df0: call [rax+0x188] ; mov [rax+0x52c],ebx ; store packed "enUS"-style u32 +1471b4e1c: jmp [this->vt+0x40] ; advance to next login state +``` + +`0x5a5a` is literally **`"ZZ"`** — the unknown-country placeholder. So this state waits +for a **real 2-letter account country code** from the OSDK `'cnnc'` (connection / +Nucleus adaptor) service and alerts `OSDK_A_R30B` if it never arrives. + +Operation names registered in the same module manager, right beside `LoadCfg:%s`: + +``` +0x1439623c0 FetchAccountInfo <<< semantic match for the popup text +0x1439623d8 UpdateAccountInfo +0x1439623f0 LookupOriginPersona +0x143962408 FetchOriginPersona +0x143962420 CreateOriginPersona +0x143962598 "Operation timed out. Name = [%s], Handle = [%u]" +``` + +--- + +## 6. Why this matches the observed behaviour exactly + +| observation | explanation | +|---|---| +| `preAuth` → `ping` → **6× `fetchClientConfig`** → then popup | `LoginStateConnect` sub-state 0 issues exactly these 6 `LoadCfg:` requests | +| popup appears after a wait, not instantly | hard-coded **60 000 ms** deadline (`lea eax,[rdi+0xea60]`) | +| we answer all 6 `fetchClientConfig` with **empty** replies | `ResourceLoader` never reaches `LOADED`, `[entry+0x60]` stays 0 → poll never satisfied → timeout | +| popup is the **alert**, not the login-failure popup | `evt_onlineAlertPopup` (0x27a6), not 0x27a7/0x27a8 — the flow never reported a login failure, it timed out earlier | +| **`GetAuthCode` never requested** | `Origin::OriginSDK::RequestAuthCodeSync` / `LSXRequest` is driven from `LoginStatePCLogin`, which is **downstream** and never entered | +| **`Authentication::login` (1/0x0A) never sent** | same reason — the state machine aborts before it | +| client sends `Authentication` 1/0x46 then reconnect-loops | the OSDK teardown path after the alert (`LoginStateLogout`) | +| nucleusConnect stub on :42131 gets zero requests | the client gets its Nucleus config **from `fetchClientConfig OSDK_NUCLEUS`**, which we return empty — so it never learns a Nucleus URL to dial | + +The last row is the key causal loop: **`OSDK_NUCLEUS` is where the Nucleus/account +endpoints come from.** Returning it empty both (a) fails the `LoginStateConnect` ready +check and (b) guarantees the account-info fetch can never resolve, which is why *both* +`OSDK_A_R30B` sites are armed. + +--- + +## 7. What was NOT proven + +1. **`OSDK_A_R30B` → that exact English text** is inferred, not read. The loc DB lives in + compressed Frostbite superbundles (`Data/Win32/loc/en.toc` + `.sb`, chunked); the + plain text is not greppable on disk and the game exited before I could read the live + loc table. Confidence is high (it is the only alert id raised by the two + "waiting for account data" states, and every other login alert id has a clearly + different meaning), but it is one unverified link. +2. **Which of the two sites fired** (`LoginStateConnect` vs `LoginStateLoadIspAccountInfo`). + `LoginStateConnect` is the stronger candidate because the observed Blaze traffic stops + exactly at its wait condition. + +### Cheap runtime confirmations (next live run) + +* Read `[state+0x20]` / `[state+0x24]` while the spinner is up — sub-state 2 = timeout hit. +* Breakpoint / patch-log `0x147190e80` and dump `r9` (the loc-id string) — this names the + alert with zero ambiguity and works for every login alert. +* Grep `/tmp/lsx.log`-style logging for the OSDK telemetry event `osdk` / `error` / `%d` + emitted at `0x147190f24`. + +--- + +## 8. Actionable conclusion + +**Layer implicated: Blaze `Util::fetchClientConfig` (9/0x01) — not LSX, not Nucleus HTTP.** + +The Origin/LSX layer is already satisfied (`origin.nav` emitted `OriginIsOnlineTrue`). +The wall is the **first Blaze step after preAuth**: the client demands non-empty client +configs for all six CFIDs and blocks for 60 s, then alerts. + +Fix order: +1. Return **real, non-empty** `fetchClientConfig` maps for `OSDK_CORE`, `OSDK_CLIENT`, + **`OSDK_NUCLEUS`**, `OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_TICKER` + (note: **`OSDK_TICKER`**, per the in-binary list at `0x143962c40` — earlier notes + recorded `OSDK_XMS_ABUSE_REPORTING` as the 6th; verify against the live capture). + `OSDK_NUCLEUS` must carry auth/account URLs pointed at our stub. +2. That should let `LoginStateConnect` reach sub-state 3 and advance to + `LoginStateLoadIspAccountInfo`, which then needs the account **country code** to come + back as a real 2-letter code (not `0`, not `"ZZ"`) from the `'cnnc'` service. +3. Only then does `LoginStatePCLogin` run — and that is where **LSX `GetAuthCode`** will + finally be requested, followed by Blaze `Authentication::login` (1/0x0A). diff --git a/fifa17-recon/tools/acct_retrieval.md b/fifa17-recon/tools/acct_retrieval.md new file mode 100644 index 0000000..4631e37 --- /dev/null +++ b/fifa17-recon/tools/acct_retrieval.md @@ -0,0 +1,544 @@ +# FIFA 17 — "account information" retrieval path, end to end + +**Date:** 2026-07-30 · **Target:** live `FIFA17.exe` (base `0x140000000`, Wine flat PE map), +plus the saved live-string dump `modstrings.txt` after the process exited mid-analysis. + +**Clean-room provenance:** every fact below comes from (a) static/dynamic analysis of binaries we +own (`FIFA17.exe` as mapped in our own process, `stp-origin_emu.dll`), (b) our own client's observed +LSX/Blaze wire traffic (`/tmp/lsx.log`, `/tmp/blaze_responder.log`), and (c) the client's own +runtime reflection/metadata. **No 2021 EA/FIFA leak material was used or consulted.** + +Builds on `auth_schema_reflection.md`, `auth_statemachine.md`, `origin_nucleus.md`. + +--- + +## 0. Verdict (short) + +**Account information is carried on the Blaze channel, but it is *gated* by the LSX/Origin channel. +The Nucleus/account HTTP channel is not part of the PC login path at all.** + +* **LSX/Origin (`127.0.0.1:4216`)** supplies the *identity* and the *credential*: + `GetProfile` → the OriginSDK's default **user id / persona id**; `QueryEntitlements` → + `ONLINE_ACCESS`; **`GetAuthCode` → the one-time Origin auth code**. +* **Blaze (Fire2, `:42130`)** supplies the *account record*: `Authentication::login (1/0x0A)` takes + the auth code in `LoginRequest.AUTH` and returns `LoginResponse.SESS` (session key, blazeUserId, + userId, email, persona details) — this **is** "account information" for the login flow. The + explicit account RPCs (`getAccount 1/0x1E → AccountInfo`, `getPersona 1/0x5A`, + `listPersonas 1/0x64`, `listUserEntitlements2 1/0x1D`) all live *behind* that login. +* **Nucleus/account HTTP (`:42131`)** is unused **by design of this code path**, for two independent + reasons proven below (§4). It is not a missing piece; it is a dead end for PC/Origin login. + +**The exact missing piece:** `LoginRequest.AUTH` (`authCode`, a plain string) can only be filled by +LSX `GetAuthCode`, and the client never issues it. No auth code → no `Authentication::login` → no +`LoginResponse` → nothing that can answer "account information" → popup. Everything downstream +(`getAccount`, `listPersonas`, FUT `user/accountinfo`) is unreachable. + +--- + +## 1. Channel 1 — LSX / Origin on `127.0.0.1:4216` + +### 1.1 Complete OriginSDK LSX surface (from the client's own template instantiations) + +`Origin::LSXRequest<>` / `LSXEnumeration<>` instantiations present in the image: + +| Request | Response | Notes | +|---|---|---| +| `GetConfigT` | `GetConfigResponseT` | | +| `GetProfileT` | `GetProfileResponseT` | **sets the default user/persona — see §1.2** | +| `GetSettingT` | `GetSettingResponseT` | | +| `GetGameInfoT` | `GetGameInfoResponseT` | `UPTODATE` gate | +| `GetInternetConnectedStateT` | `InternetConnectedStateT` | the online gate | +| **`GetAuthCodeT`** | **`AuthCodeT`** | handler `0x1439385b0`, callback `0x143938660` | +| `QueryEntitlementsT` | `QueryEntitlementsResponseT` → `OriginItemT` | enumeration, `0x1439484e0` | +| `GetUserProfileByEmailorEAIDT` | `…ResponseT` → `OriginFriendT` | | +| `IsProgressiveInstallationAvailableT`, `AreChunksInstalledT`, `QueryChunkStatusT`, `SetDownloaderUtilizationT`, `QueryOffersT`, `GetWalletBalanceT`, `CheckoutT`, `ConsumeEntitlementT`, `GetPresenceT`, `SetPresenceT`, `QueryFriendsT`, `GetBlockListT`, `SendInviteT`, `ShowIGOT`, `GrantAchievementT`, `ExtendTrialT`, `SelectStoreT` | | not on the login path | + +### 1.2 `GetProfile` is what defines "who is logged in" — **proven** + +`OriginGetDefaultUser()` @ `0x1470da6d0` and `OriginGetDefaultPersona()` @ `0x1470da680` are bare +field reads: + +``` +1470da6d0 call 0x1470e2840 ; SDK-ready predicate +1470da6f4 call 0x1470e3560 ; get impl singleton +1470da6f9 mov rax,[rax+0x3a0] ; <<< default USER id +1470da680 ... mov rax,[rax+0x3a8] ; <<< default PERSONA id +``` + +Both fields are zeroed in the SDK constructor (`0x1470deb2f` / `0x1470deb36`, `rsi = 0`) and are +written in exactly **one** place — `Origin::OriginSDK::Initialize` @ ~`0x1470e5a30`: + +``` +1470e5ac7 call 0x147118d80 ; sync GetProfile(index=0), 15000 ms timeout (0x3a98) +1470e5acc test eax,eax +1470e5ace jne ... ; on failure, leave 0 +1470e5ad0 mov rax,[rsp+0x70] +1470e5ad5 mov [rdi+0x3a0],rax ; <<< userId from GetProfileResponse +1470e5adc mov rax,[rsp+0x78] +1470e5ae1 mov [rdi+0x3a8],rax ; <<< personaId from GetProfileResponse +``` + +`0x147118d80` builds the request through `0x147117fe0` with `index = 0` and waits via `0x1471186f0`. +The service name used is **`"EbisuSDK"`** (`0x143937d58`), which is why our responder must answer +`GetProfile` with `sender="EbisuSDK"` — it already does. + +> **Consequence:** our `GetProfileResponse` with `UserId="33068179" PersonaId="33068179"` *is* the +> mechanism that populates the SDK's identity. That part is working; the client asked `GetProfile` +> three times (ids 3, 10, 17) and we answered correctly each time. + +### 1.3 The auth-code request path — fully mapped + +`FifaOnline::FirstPartyAuthTokenRetriever::DoTick` @ **`0x146f199c0`**: + +``` + rbx = this+8 ; rbp = 2 ; two request slots: this+0x08, this+0x10 +loop: + rsi = [rbx]; if (!rsi) goto next ; nothing pending -> nothing happens, silently + [rsp+0x60] = 0 ; out: auth-code buffer + [rsp+0x58] = 0 ; out: auth-code length + rax = call 0x1470da6d0 ; OriginGetDefaultUser() -> sdk[+0x3a0] + r9 = &len ; r8 = &buf ; rdx = rsi+0x18 ; rdx = ClientId string from the request object + rcx = rax ; user handle + eax = call 0x1470db3c0 ; Origin::OriginSDK::RequestAuthCodeSync + if (eax != 0) -> 0x146f19aab ; log "[%s] Origin Error(%d)" (0x1438f5e18) + if (buf == 0 || len == 0) -> 0x146f19a7e ; log "[%s] Invalid authcode" (0x1438f5e00) + ; success: store the code at rsi+0xd8, set rsi+0xe8 = 1 +``` + +`Origin::OriginSDK::RequestAuthCodeSync` @ **`0x1470db3c0`**: + +``` +1470db3da lea rdx,[0x143936158] ; trace "OriginRequestAuthCodeSync entered" +1470db3f2 call 0x1470dbf30 ; trace +1470db3f7 call 0x1470e2840 ; <<< SDK-ready predicate +1470db3fe je 0x1470db424 ; NOT ready -> error 0xa0010000, NO LSX TRAFFIC AT ALL +1470db400 call 0x1470e3560 ; impl +1470db41d call 0x1470e67f0 ; impl->RequestAuthCodeSync(user, clientId, &buf, &len, 0) +``` + +**Signature (recovered):** `OriginRequestAuthCodeSync(OriginUserT user, const char* clientId, +char** outBuf, size_t* outLen, ...)`. `clientId` comes from the request object at `+0x18` and is +what lands in the LSX `` attribute (attribute name `ClientId` +@ `0x14394e098`). + +**Note the wire-silent failure path:** if `0x1470e2840` returns false, `RequestAuthCodeSync` logs and +returns `0xa0010000` **without ever touching the socket**. That is a failure mode that looks exactly +like our symptom (nothing in the LSX log). It is, however, unlikely here, because the same predicate +guards `OriginGetDefaultUser`, `OriginCheckOnline`, `OriginGetProfile` etc., all of which demonstrably +worked. Ranked below in §5. + +### 1.4 Pushed LSX **Events** — the SDK does support them, including `` + +Our responder is request-driven only and never pushes. The client's OriginSDK *does* register +event handlers. Complete inventory of `Origin::EventHandler::HandleMessage`: + +| Element (name table `0x14393cfd8`–`0x14393d208`) | Payload type | +|---|---| +| **`Login`** @ `0x14393d0ac` | `unsigned int` | +| `OnlineStatusEvent` @ `0x14393d120` | `bool` | +| `ProfileEvent` @ `0x14393d0b8` | `OriginProfileChangeT` | +| `CurrentUserPresenceEvent`, `PresenceVisibilityEvent`, `BroadcastEvent`, `IGOEvent`, `IGOUnavailable`, `MinimizeRequest`, `RestoreRequest`, `MultiplayerInvite`, `MultiplayerInvitePending`, `UserInvitedEvent`, `PurchaseEvent`, `ChatMessageEvent`, `GameMessageEvent`, `CoreContentUpdated`, `BlockListUpdated`, `AchievementSets`, `ChunkStatus`, `GroupEvent`, `GroupEnterEvent`, `GroupLeaveEvent`, `GroupInviteEvent`, `VoipStatusEvent` | various | + +`Origin::LSXEvent<>` (the *handshake* event path) is instantiated for `ChallengeT` **only** — that +is the `` we already send. Everything in the table above +goes through the `EventHandler`/`EventEnumerator` dispatch, whose element-name match is at +`0x1471028ee` (it first compares the `sender` attribute — `sender` @ `0x143938028` — then the element +name). + +**Relevant attribute names in the pool** (`0x14394de00`+) that belong to this family: +`SessionInformation` `0x14394e0d8`, `IsLoggedIn` `0x14394e0f0`, `Changed` `0x14394e100`, +`userid` `0x14394e108`, `isOnline` `0x14394e180`, `initial` `0x14394e0c8`, `from` `0x14394e0d0`. + +So hypothesis (a) from the brief is **structurally possible** — a `` event exists and the +client can consume it. It is *not* proven to be required (see §5). + +--- + +## 2. Channel 2 — Blaze (Fire2 on `:42130`) — **this is where account info actually lives** + +Schemas already reflected out in `auth_schema_reflection.md`; the load-bearing parts: + +``` +Authentication::LoginRequest (0x14487ca10, 3) AUTH authCode:string EXTB externalBlob:blob + EXTI externalId:uint64 +Authentication::LoginResponse (0x14487d170, 5) ANON NTOS SESS SPAM UNDR + SESS = UserLoginInfo (0x14487cb00, 8) KEY_ sessionKey BUID blazeUserId UID_ userId + MAIL email PDTL personaDetails LLOG FRST 1CON +Authentication::AccountInfo (0x14487c810, 16) <- reply of getAccount (1/0x1E), request is EMPTY + AMU anonymousUser:bool ASRC authenticationSource:string CO country:string DOB dOB:string + DTCR dateCreated:string GOPT globalOptin:int8 LATH lastAuth:string + LN language:string MAIL email:string PML parentalEmail:string RC reasonCode:enum + STAS status:enum STAT emailStatus:enum TPOT thirdPartyOptin:int8 + UDU underageUser:bool UID userId:int64 +Authentication::Entitlements (0x14487d4e0, 1) NLST list <- listUserEntitlements2 (1/0x1D) +Authentication::Entitlement (0x14487d490, 16) TAG entitlementTag PRID productId STAT status + PID personaId GDAY grantDate UCNT useCount … +``` + +Command ids (recovered by calling the client's own `getCommandName`, cross-checked against the +static REST binding at `0x143896a80` → `trustedLogin = 0x0B`): +`login 0x0A · trustedLogin 0x0B · listUserEntitlements2 0x1D · getAccount 0x1E · getAuthToken 0x24 · +listPersonaEntitlements2 0x30 · expressLogin 0x3C · logout 0x46 · getPersona 0x5A · listPersonas 0x64 · +getOriginPersona 0x104`. + +**Observed wire behaviour (14:46 session):** +`Util::preAuth (9/0x07)` ✓ → `Util::ping` ✓ → 6× `Util::fetchClientConfig` (`OSDK_CORE`, +`OSDK_CLIENT`, `OSDK_NUCLEUS`, `OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_XMS_ABUSE_REPORTING`) ✓ +→ **`Authentication::logout (1/0x46)`, empty payload** → disconnect → 3-second transport-ping +reconnect loop forever. + +`logout` has no request and no response TDF (confirmed by absence of `LogoutRequest`/`LogoutResponse` +anywhere in the client's type index). It is the OSDK `LoginStateLogout` state — i.e. **the login state +machine aborted between `LoadConfig` and `Login`.** + +### 2.1 The OSDK login state machine (recovered state list) + +`LoginStateMachineImpl` states, from the client's `GetStateName` thunks at `0x14719b360`+: + +``` +LoginStateShowMaintenance LoginStateIsp LoginStateLoadIspAccountInfo +LoginStateConnect LoginStateLoadConfig LoginStateVersionCheck +LoginStateLogin LoginStatePCLogin LoginStateVerifyAccount +LoginStateUpgradeAccount LoginStateLoginComplete LoginStateUnsuspend +LoginStateCheckUser LoginStateRecheckUser LoginStateWebOffer +LoginStateLogout +``` + +Mapped to observed traffic: `Connect` = `preAuth`; `LoadConfig` = the 6 `fetchClientConfig` calls; +then it should proceed `VersionCheck → PCLogin → Login → VerifyAccount → LoadIspAccountInfo → +LoginComplete`. It went to `Logout` instead. + +Associated OSDK events (`0x143984000`+): `EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE` / +`…_SUCCESS`, `EVENT_LOGIN_FAILURE`, `EVENT_LOGIN_ABORTED`, `EVENT_LOGIN_QUEUED`, +`EVENT_LOGIN_TOS_NOT_ACCEPTED`, `EVENT_LOGIN_TOLLBOOTH`, … +Associated operation names (`0x1439623c0`+): **`FetchAccountInfo`**, `UpdateAccountInfo`, +`LookupOriginPersona`, `FetchOriginPersona`, `CreateOriginPersona`. + +`LoginStateVersionCheck` reads three keys out of the Blaze client config — +`SV_ENABLE_SERVER_VERSIONING` `0x14395d148`, `SV_CLIENT_CHANGELIST` `0x14395d168`, +`SV_SERVER_VERSION` `0x14395d180` — and on mismatch emits +`"Client/server version mismatch! Client is at version (%08d). Server is at version (%08d).%s"` +(`0x14395d1d0`). **We currently return none of these keys.** Absent ⇒ almost certainly treated as +disabled, but see §6 for the cheap belt-and-braces fix. + +--- + +## 3. Channel 3 — the FUT web API (UTAS) — real, but strictly post-login + +This is a *fourth* surface the brief did not list, and it is the one that literally serves a thing +called "account info". It is **not** the current blocker (it is unreachable without a Blaze session), +but it *will* be the next wall, so it is documented here. + +``` +0x1438dbe88 FUT_RS4_BASE_URL <- config key; NO hardcoded host anywhere in the image +0x1438dbe58 ut/game/%s/ <- path prefix, %s = sku ("fifa17", 0x1438dac20) +0x1438db6c0 user/accountinfo <- THE FUT account-info endpoint +0x1438db810 users/club?sku= <- POST body {"idList":[%I64d,…]} +0x1438db8f8 utStats?sku= +0x1438dbe48 FutServerCall / FutServerCall::RequestBuffer +0x1438dc378 FutGetUserAccountInfoServerCallConfig +0x1438dc3a0 FutGetUtStatsServerCallConfig +0x1438dc3c0 FutGetUsersClubInfoServerCallConfig +``` + +Request headers for `user/accountinfo` (contiguous at `0x1438db6e0`–`0x1438db731`): + +``` +Accept: application/json +Content-Type: application/json +Accept-Encoding: gzip +Easw-Session-Data-Nucleus-Id: %lld <- the nucleus/user id, i.e. 33068179 +``` + +Response JSON keys the client parses (contiguous at `0x1438db760`–`0x1438db8b0`): +`userAccountInfo` · `personas` · `userClubList` · `clubName` · `established` · `assetId` · +`returningUser` · `userPersonaInfos` · `divisionOnline`. + +A separate OSDK "SportsWorld"/EASFC HTTP module uses its own header set +(`0x14396f8b0`+): `EASW-Version: 2.0.5.0`, `EASW-Token:`, `EASW-Session:`, +`EASW-Nucleus-Persona:`, `EASW-Userid:`, `EASW-Request-Signature:`, `EASW-Content-Signature:`. + +Because `FUT_RS4_BASE_URL` has no hardcoded default, **we can point the entire FUT web API at our own +HTTP server purely by serving that config key** — no DNS/DNAT needed. Worth banking now. + +--- + +## 4. Why the `nucleusConnect` stub on `127.0.0.1:42131` is unused — answered + +Two independent reasons, both proven. + +### 4.1 `nucleusConnect` / `nucleusConnectTrusted` are **server-supplied config keys**, and we never send them + +`0x14389fef8 = "nucleusConnect"` has exactly one reader, `0x147237862`: + +``` +147237830 sub rsp,0x28 +147237834 call 0x1471995b0 ; get service locator +14723783f call [rdx+0x188] ; -> Blaze connection/config object +147237845 mov rcx,[rax+0x750] +147237855 test rcx,rcx ; je -> return 0 +147237862 lea rdx,[0x14389fef8] ; "nucleusConnect" +147237869 call [rax+0x48] ; getConfigString(key, &out) +14723786c mov rax,[rsp+0x38] ; return the string +``` + +`[vtbl+0x48]` is the Blaze config-string getter. The value therefore comes from the **server** — +the `CONF` map in our `PreAuthResponse`, or a `fetchClientConfig` section. Our `PreAuthResponse` +`CONF` map contains only timing values (`pingPeriod` etc.) and our six `fetchClientConfig` replies +are fabricated key sets that contain no `nucleusConnect*`. **The client therefore gets an empty +string and never dials anything.** Zero requests on `:42131` is the expected, correct outcome of +what we are currently serving. + +### 4.2 The Nucleus path that *does* exist is S2S/client-cert-only, and belongs to `trustedLogin` + +`nucleusConnectTrusted` (`0x14389fdf8`) is read at `0x146e1658b` and immediately feeds a URL builder: + +``` +146e1658b lea rdx,[0x14389fdf8] ; "nucleusConnectTrusted" +146e16595 call [rax+0x48] ; getConfigString +14e1659d lea r8,[0x14389fe10] ; "%s/connect/token" +146e165ae call 0x146dc0950 ; snprintf(buf, 0x400, "%s/connect/token", url) +146e165db movups xmm0,[0x14389fe28] ; "grant_type=client_credentials" +``` + +Surrounding literals, contiguous, all in the BlazeSDK `LoginStateMachine` block: + +``` +0x14389fd90 Content-Type: application/x-www-form-urlencoded +0x14389fdc1 enable-client-cert-auth: true +0x14389fde0 LoginStateMachine +0x14389fdf8 nucleusConnectTrusted +0x14389fe10 %s/connect/token +0x14389fe28 grant_type=client_credentials +0x14389fe50 NEXUS_S2S +0x14389fe60 "access_token" : " +0x14389fef8 nucleusConnect +``` + +So the shape, if it were ever used, is: + +``` +POST {nucleusConnectTrusted}/connect/token +Content-Type: application/x-www-form-urlencoded +enable-client-cert-auth: true +Authorization: NEXUS_S2S + +grant_type=client_credentials +→ 200 {"access_token" : "…"} +``` + +…and the token then goes into `TrustedLoginRequest {ID_ id, ITYP idType, TOKN accessToken}` = +**`Authentication::trustedLogin (1/0x0B)`**, whose REST binding at `0x143896a80` confirms the shape +(`GET`, headers `Authorization: accessToken`, `X-Forwarded-UserType: idType`, +`X-Forwarded-UserId: id`). + +**This is the console/dedicated-server trusted path. It requires a client certificate +(`enable-client-cert-auth: true`) and is not what the PC/Origin build uses.** The PC build uses +`login (1/0x0A)` with `AUTH = `. + +### 4.3 The hardcoded EA account hosts are for the web UI, not for login + +``` +0x143b8b528 https://accounts.int.ea.com/ 0x143b8b588 https://accounts.ea.com/ +0x143b8b548 https://gateway.int.ea.com/ 0x143b8b5a8 https://gateway.ea.com/ +0x143b8b568 https://signin.int.ea.com/ 0x143b8b5c0 https://signin.ea.com/ +0x143b8b700 Nucleus::gNucleusLocale 0x143b8b718 Nucleus::gNucleusBaseUrl +0x143b8b738 Nucleus::gNucleusBaseProxyUrl 0x143b8b758 Nucleus::gNucleusBasePortalUrl +0x143b8b778 Nucleus::gNucleusClientSideRedirectUri +``` + +These back the EAWebKit account-management pages, reached through the OSDK WebOffer config keys +`NUCLEUS_CREATE_URL` / `NUCLEUS_ADDED_URL` / `NUCLEUS_INCOMPLETE_URL` / `NUCLEUS_CREATE_INFO_URL` / +`NUCLEUS_DUPACCT_INFO_URL` / `NUCLEUS_DEACTIVATED_INFO_URL` (`0x14395eb60`–`0x14395ec18`, sitting in +the middle of the `WEB_OFFER_URL` / `NEWS_URL` / `FAQ_URL` / `TOSA_URL` block) — i.e. the +`OSDK_NUCLEUS` `fetchClientConfig` section is **a set of account-web-page URLs**, not login plumbing. +That the game does not dial `accounts.ea.com` is therefore *correct behaviour*, not a symptom. + +> **Bottom line on channel 3:** do not build a Nucleus HTTP server. If you want the `:42131` stub to +> ever receive traffic you would have to (a) advertise `nucleusConnectTrusted` in the Blaze config +> and (b) satisfy `enable-client-cert-auth` over TLS — and that would put you on the `trustedLogin` +> path, which is *not* the path this build's login state machine takes. Serve the auth code instead. + +--- + +## 5. The dependency chain, and where it actually breaks + +``` + LSX GetInternetConnectedState connected="1" ✅ done + -> g_originOnline @0x1443337f8 (writer 0x146f1e6b0) + FE::FIFA::OriginOnlineEvent + -> origin.nav emits OriginIsOnlineTrue -> startFutBlazeLogin + LSX GetGameInfo UPTODATE="true" ✅ done + LSX GetConfig / GetProfile ✅ done -> sdk[+0x3a0]=UserId, [+0x3a8]=PersonaId + -------------------------------------------------------------------------------- + OSDK LoginStateConnect -> Blaze Util::preAuth ✅ answered + OSDK LoginStateLoadConfig-> Blaze Util::fetchClientConfig × 6 ✅ answered (fabricated data) + OSDK LoginStateVersionCheck -> SV_* keys from config ⚠️ keys absent + OSDK LoginStatePCLogin -> FirstPartyAuthTokenRetriever::DoTick + -> OriginGetDefaultUser() + -> LSX GetAuthCode(ClientId) ❌ NEVER SENT <<< BREAK + -> Blaze Authentication::login AUTH= ❌ never sent + OSDK LoginStateVerifyAccount / LoadIspAccountInfo + -> Blaze getAccount 1/0x1E -> AccountInfo ❌ unreachable + -> QueryEntitlements / listUserEntitlements2 ❌ unreachable + OSDK LoginStateLoginComplete ❌ unreachable + -------------------------------------------------------------------------------- + FUT: CheckFUTRosters -> {FUT_RS4_BASE_URL}ut/game/fifa17/user/accountinfo ❌ unreachable +``` + +Observed instead: `LoadConfig` → **`LoginStateLogout`** → `Authentication::logout (1/0x46)` → +disconnect → reconnect ping loop. In the most recent run the client did not even re-reach `preAuth`; +it sat in a bare PING/close loop every 3 s, i.e. the connection manager gave up. + +### Ranked hypotheses for "no `GetAuthCode`", with the discriminating test for each + +| # | Hypothesis | Evidence for | Evidence against | Discriminating probe | +|---|---|---|---|---| +| **H1** | The login state machine aborts *before* `LoginStatePCLogin` — i.e. nothing is ever enqueued into `DoTick`'s two slots, so `RequestAuthCodeSync` is never called. | `logout` arrives immediately after the last `fetchClientConfig`, with **no** intervening RPC and no LSX traffic. `QueryEntitlements` is *also* never issued — consistent with "the whole PC-login step never ran", not with "the auth-code call failed". | — | Breakpoint `0x146f199c0` (`DoTick`) and `0x1470db3c0`. If `DoTick` runs but both slots are null → H1 confirmed. **Do this first.** | +| **H2** | Our fabricated `fetchClientConfig` payloads are wrong/insufficient, so `LoadConfig` "succeeds" but a required key is missing and the next state fails. | All six replies contain keys we invented. `SV_ENABLE_SERVER_VERSIONING` / `SV_CLIENT_CHANGELIST` / `SV_SERVER_VERSION` are read by `LoginStateVersionCheck` and we send none. `OSDK_NUCLEUS` should be six `NUCLEUS_*_URL` keys; we send four invented `OSDK_NUCLEUS_*` keys. | Missing keys usually degrade to `""`/0 in OSDK. | Serve the corrected config (§6.1) and re-run; watch whether the client advances past `LoadConfig`. Cheap, no RE needed. | +| **H3** | The OriginSDK needs a *pushed* `` / `` event before it considers a user authenticated. | `Origin::EventHandler` exists (`0x14393f900`), element `Login` @ `0x14393d0ac`; attributes `IsLoggedIn` / `SessionInformation` / `userid` exist in the pool. Our responder never pushes anything. | The Steampunks emu never pushed events either, yet the game still got as far as `preAuth`. `OriginGetDefaultUser` is fed by `GetProfile`, *not* by the Login event (§1.2) — so the SDK's notion of "who" does not depend on it. | Push `` (encrypted, right after `ChallengeAccepted`) and see whether behaviour changes. Cheap; try alongside H2. | +| **H4** | `RequestAuthCodeSync` early-outs at the SDK-ready predicate `0x1470e2840` and returns `0xa0010000` without wire traffic. | It is the only *silent* failure path inside the auth-code call. | The same predicate guards `OriginGetDefaultUser` / `OriginGetProfile` / `OriginCheckOnline`, all of which demonstrably worked in this session. | Breakpoint `0x1470db3f7`; check `al` after `0x1470e2840`. Only worth doing if H1's `DoTick` probe shows the call *is* being made. | + +H1 is by far the most likely. H2 is the cheapest thing that could plausibly cause H1. + +--- + +## 6. What we must serve + +### 6.1 Blaze `Util::fetchClientConfig` — replace the invented keys + +Serve authentic key *names* (values can be conservative). Recovered key names, by section: + +* **`OSDK_CORE`** — include the version-check trio explicitly so nothing is ambiguous: + `SV_ENABLE_SERVER_VERSIONING = 0`, `SV_CLIENT_CHANGELIST = 0`, `SV_SERVER_VERSION = 0`. + Keep `OSDK_PRESENCE_DELAY = 5`, `OSDK_PRESENCE_POLL = 60` (real key names, `0x143962848`/`0x143962860`). +* **`OSDK_NUCLEUS`** — the *real* keys are web-page URLs, not the `OSDK_NUCLEUS_*` we invented: + `NUCLEUS_CREATE_URL`, `NUCLEUS_ADDED_URL`, `NUCLEUS_INCOMPLETE_URL`, `NUCLEUS_CREATE_INFO_URL`, + `NUCLEUS_DUPACCT_INFO_URL`, `NUCLEUS_DEACTIVATED_INFO_URL` (`0x14395eb60`–`0x14395ec18`). + Empty strings are fine and honest; invented `OSDK_NUCLEUS_ENABLED=1` is not. +* **`OSDK_WEBOFFER`** — real keys: `WEB_OFFER_URL`, `NEWS_URL`, `NEWS_TIME_STAMP_URL`, `FAQ_URL`, + `TOSA_URL`, `TOSAC_URL`, `MENU_ESPN_URL`, `MENU_WEBGM0_URL`…`MENU_WEBGM2_URL`. +* **`OSDK_ABUSE_REPORTING` / `OSDK_XMS_ABUSE_REPORTING`** — `OSDK_ABUSE_REPORTING_ENABLED = 0`, + `OSDK_ABUSE_NUM_TYPES = 0` (already correct). +* **`OSDK_CLIENT`** — the `OSDK_CLUBS_*` limits we already send are real key names. +* Consider adding **`FUT_RS4_BASE_URL = http://127.0.0.1:/`** — see §6.4. + +### 6.2 LSX `GetAuthCode` — be ready the instant it is asked + +```xml + + + +``` + +Emit **both** `Code` and `Return` until the log shows which one the client consumes (the attribute +pool position between `ClientId` and `connected` is suffix-shared, so `Code` is the strong +candidate). The code is opaque — we author both ends — but it **must be echoed verbatim into +`LoginRequest.AUTH`** and accepted there. Log the incoming `ClientId` value; that tells us which EA +client id FIFA 17 presents and is worth recording. + +### 6.3 LSX `QueryEntitlements` — pre-stage the answer + +```xml + + + +``` + +### 6.4 Blaze `Authentication` — the account-info replies to have ready + +| Cmd | Reply | Must contain | +|---|---|---| +| `login 0x0A` | `LoginResponse` | `ANON=0 NTOS=0 UNDR=0 SPAM=1`; `SESS.KEY_` non-empty; `SESS.BUID = SESS.UID_ = 33068179`; `SESS.PDTL.PID_ = 33068179`; `SESS.PDTL.DSNM = "CAGE"`; `SESS.PDTL.STAS = 0`; `SESS.PDTL.PLAT` = the `PLAT` from `PreAuthResponse` | +| `getAccount 0x1E` | `AccountInfo` (empty request) | `UID = 33068179`, `CO = "US"`, `LN = "en_US"`, `MAIL` = any well-formed address, `STAS` = active, `STAT` = verified, `UDU = false`, `AMU = false`, `ASRC = "cem_ea_id"` (matches the `NASP` we already return in `PreAuthResponse`), `DTCR` / `LATH` ISO-8601 | +| `listPersonas 0x64` | `ListPersonasResponse` | one persona: id `33068179`, name `CAGE`, status active | +| `getPersona 0x5A` | `GetPersonaResponse` | same single persona | +| `listUserEntitlements2 0x1D` | `Entitlements` | one `Entitlement`: `TAG = "ONLINE_ACCESS"`, `PRID` tied to offer `1027460`, `PID = 33068179`, `STAT` active | +| `logout 0x46` | empty REPLY | already correct — but treat its arrival as the failure signal it is | + +Also serve `UserSessions` notification `UserAuthenticated (0x08)` after login (notification ids +already decoded: `0x141b03f70`). + +### 6.5 FUT web (`{FUT_RS4_BASE_URL}ut/game/fifa17/user/accountinfo`) — the next wall, pre-built + +``` +GET {FUT_RS4_BASE_URL}ut/game/fifa17/user/accountinfo + Accept: application/json + Content-Type: application/json + Accept-Encoding: gzip + Easw-Session-Data-Nucleus-Id: 33068179 + +200 {"userAccountInfo": { + "personas": [{ + "personaId": 33068179, "personaName": "CAGE", + "returningUser": 0, + "userClubList": [{"clubName":"…","established":,"assetId":}], + "userPersonaInfos": [], "divisionOnline": 0 + }] + }} +``` + +Because `FUT_RS4_BASE_URL` has no hardcoded default, serving it as a config key is enough to point +the whole FUT web API at us — plain HTTP on `127.0.0.1` is fine, no TLS or DNAT needed. + +--- + +## 7. Immediate next actions (ordered) + +1. **Probe H1.** Relaunch, breakpoint `0x146f199c0` (`FirstPartyAuthTokenRetriever::DoTick`) and + `0x1470db3c0` (`RequestAuthCodeSync`). Whether `DoTick` runs at all, and whether its two slots are + null, decides H1 vs H4 in one shot. +2. **Ship the corrected `fetchClientConfig` (§6.1)** — real key names, explicit `SV_*` trio. Cheapest + possible fix for H2 and it removes a class of ambiguity permanently. +3. **Add `GetAuthCode` + `QueryEntitlements` responses (§6.2/6.3)** to `lsx_responder.py` so the + moment the client asks, it is answered — and log the `ClientId` it presents. +4. **Try the pushed `` event (§H3)** — one extra encrypted frame after `ChallengeAccepted`. + Low cost, and it is the only untested structural difference between us and a real Origin client. +5. Keep the Nucleus HTTP stub on `:42131` parked. It is on the `trustedLogin`/client-cert path and is + not what this build uses. + +--- + +## Appendix — addresses + +| What | VA | +|---|---| +| `FifaOnline::FirstPartyAuthTokenRetriever::DoTick` | `0x146f199c0` | +| `Origin::OriginSDK::RequestAuthCodeSync` | `0x1470db3c0` | +| … its SDK-ready predicate (wire-silent bail) | `call 0x1470e2840` @ `0x1470db3f7` | +| … its impl call | `call 0x1470e67f0` @ `0x1470db41d` | +| `OriginGetDefaultUser()` → `sdk[+0x3a0]` | `0x1470da6d0` | +| `OriginGetDefaultPersona()` → `sdk[+0x3a8]` | `0x1470da680` | +| `Origin::OriginSDK::Initialize` — writes `+0x3a0`/`+0x3a8` from `GetProfile` | `0x1470e5ad5` / `0x1470e5ae1` | +| sync `GetProfile(index=0)` helper (timeout `0x3a98` = 15 s) | `0x147118d80` | +| OriginSDK ctor zeroing `+0x3a0`/`+0x3a8` | `0x1470deb2f` / `0x1470deb36` | +| `"[%s] Invalid authcode"` / `"[%s] Origin Error(%d)"` | `0x1438f5e00` / `0x1438f5e18` | +| `"OriginRequestAuthCodeSync entered"` | `0x143936158` | +| `nucleusConnect` (config key) / its only reader | `0x14389fef8` / `0x147237862` | +| `nucleusConnectTrusted` / its reader | `0x14389fdf8` / `0x146e1658b` | +| `%s/connect/token` · `grant_type=client_credentials` · `enable-client-cert-auth: true` · `NEXUS_S2S ` | `0x14389fe10` · `0x14389fe28` · `0x14389fdc1` · `0x14389fe50` | +| `trustedLogin` REST binding (`GET`, `Authorization`/`X-Forwarded-*`) | `0x143896a80` | +| LSX **event** element-name table (incl. `Login` @ `0x14393d0ac`) | `0x14393cfd8`–`0x14393d208` | +| `Origin::EventHandler::HandleMessage` | `0x14393f900` | +| `Origin::EventHandler::HandleMessage` | `0x143940050` | +| LSX event dispatch / element-name compare | `0x1471028ee` | +| LSX request element table / attribute pool | `0x14394dc00` / `0x14394de00` | +| `"EbisuSDK"` / `"EALS"` service names | `0x143937d58` / `0x143937c60` | +| OSDK login-state name thunks | `0x14719b360`+ | +| `SV_ENABLE_SERVER_VERSIONING` / `SV_CLIENT_CHANGELIST` / `SV_SERVER_VERSION` | `0x14395d148` / `0x14395d168` / `0x14395d180` | +| version-mismatch message | `0x14395d1d0` | +| OSDK config section names (`OSDK_CORE`…`OSDK_TICKER`) | `0x143962be8`–`0x143962c40` | +| OSDK operations `FetchAccountInfo` / `UpdateAccountInfo` | `0x1439623c0` / `0x1439623d8` | +| `EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE` / `_SUCCESS` | `0x1439840b8` / `0x1439840e0` | +| `NUCLEUS_*_URL` config keys (OSDK_NUCLEUS section) | `0x14395eb60`–`0x14395ec18` | +| FUT `FUT_RS4_BASE_URL` / `ut/game/%s/` / `user/accountinfo` | `0x1438dbe88` / `0x1438dbe58` / `0x1438db6c0` | +| FUT `Easw-Session-Data-Nucleus-Id: %lld` | `0x1438db731` | +| FUT response JSON keys (`userAccountInfo`…`divisionOnline`) | `0x1438db760`–`0x1438db8b0` | +| EASW/SportsWorld header block | `0x14396f8b0`–`0x14396fa50` | +| hardcoded `accounts/gateway/signin.ea.com` (web UI only) | `0x143b8b528`–`0x143b8b5c0` | +| `Nucleus::gNucleusBaseUrl` & friends | `0x143b8b700`–`0x143b8b778` | +| Blaze `AccountInfo` / `LoginRequest` / `LoginResponse` descriptors | `0x14487c810` / `0x14487ca10` / `0x14487d170` | + +Tools written this pass (all in `…/scratchpad/`): `acct_probe1.py` (CommandInfo neighbourhood dump), +`strdump.py` (string run at a VA), `wsearch.py` (ASCII+UTF-16 phrase search), `navdump.py` +(nav-flow JSON extractor). Reused: `memtool.py`, `strsearch.py`, `xref.py`, `dis.sh`, `cmdinfo.py`. diff --git a/fifa17-recon/tools/auth_refs.md b/fifa17-recon/tools/auth_refs.md new file mode 100644 index 0000000..9735711 --- /dev/null +++ b/fifa17-recon/tools/auth_refs.md @@ -0,0 +1,401 @@ +# FIFA 17 Blaze — Authentication (component 0x0001) login flow + +**Date:** 2026-07-30 · **Scope:** what the client needs so it believes it is logged in. + +--- + +## 0. Provenance legend + +| Flag | Meaning | +|---|---| +| **(A) binary** | Reflected/disassembled out of **our own `FIFA17.exe`** (live `/proc//mem`, PID 19517). Authoritative for FIFA 17. | +| **(A) wire** | Our own captured bytes (`fifa17-recon/captures/blaze/session/`). | +| **(A) clean-room 3P** | Third-party clean-room reimplementations cloned in the scratchpad. `grid-blaze` states explicitly it is "a clean-room implementation based entirely on network analysis"; `pamplona-future`/`catalyst-mitm` are the same Beat-Revival lineage (packet captures + `zeroKilo`/`jacobtread` public TDF work). Mirror's Edge Catalyst = **Blaze 15.1.1.0.5**, FIFA 17 = **Blaze 15.1.1.3.0** — same SDK generation. | +| **(B?) unverified 3P** | `refs/z7_*.txt` in the scratchpad — pre-existing reference captures of *unknown* origin, **not** produced by us. Treated as a hint only; every z7 claim below was independently re-derived from (A). Where z7 disagrees with our binary, **our binary wins** (see §6). | + +**No EA/FIFA leaked source was consulted.** Nothing below is derived from the 2021 leak. + +--- + +## 1. THE HEADLINE: the client is not stalling — it is **logging out** + +Observed frame #9 (`captures/blaze/session/auth_cmd0x46.bin`): + +``` +00 00 00 00 | 00 00 | 00 01 | 00 46 | 00 00 10 | 00 | 00 00 +payload=0 meta=0 comp=1 cmd=0x46 msgNum=16 MESSAGE +``` + +`component 0x0001 (Authentication), command 0x0046 = 70 decimal, EMPTY payload.` + +**Command 70 = `logout`.** Evidence: + +1. **(A) clean-room 3P** — `pamplona-future/src/blaze/components/authentication.ts:10-46` is a full BlazeSDK-15.1.1 `Authentication` command enum, and it lists `logout = 70`. That enum is independently corroborated on three other entries by our own data: + - `login = 10` ↔ z7 capture `comp=0x0001 cmd=0x000A [LoginRequest]` + - `getAuthToken = 36` ↔ z7 capture `comp=0x0001 cmd=0x0024 [GetAuthTokenResponse]` + - `listUserEntitlements2 = 29` ↔ `grid-blaze/src/main.rs:35` routes `1, 29 => list_entitlments` +2. **(A) binary** — FIFA 17's own Authentication RPC-name literal pool at `0x14389d6xx–0x14389d928` contains **exactly the same 33 RPC names** as the pamplona enum (`acceptLegalDocs … logout … upgradeAccount`), i.e. FIFA 17 and MEC ship the *same* Authentication component definition. `"logout"` is at `0x14389d8d8`. +3. `logout` is the only Authentication RPC in that set that takes **no request parameters**, which matches the observed 0-byte payload. (`getAuthToken` is also parameterless but is `0x24`, not `0x46`.) + +> Confidence: **high**, cross-validated three ways, but *not* 100% binary-pinned — FIFA 17's `getCommandName` for Authentication is not emitted as the `lea rax,[rip+str]; ret` stub pattern that yielded the Util table, and the name pool has **zero** code xrefs (searched every `lea/mov rip-rel` and every aligned+unaligned 8-byte pointer, module-wide). So the id↔name mapping itself is inherited from (A) clean-room 3P, not re-derived from FIFA 17. + +### What this changes + +The client **never attempted `login` (cmd 10)**. It went: + +`preAuth → ping → 6× fetchClientConfig (all answered EMPTY) → logout → give up` + +So it is *not* waiting on an Authentication response we failed to send. It decided, **before** issuing any login, that it had nothing to log in with, and tore the Blaze session down. That is consistent with the two-layer gate: it had **no Origin auth code** to put in `LoginRequest.AUTH`, and/or the OSDK layer failed to initialise from the empty configs. + +**(A) binary corroboration of layer 1:** `FIFA17.exe` contains the localisation key **`TXT_NOT_LOGIN_TO_EBISU`** at `0x1439633e8` (Ebisu = EA's internal codename for Origin), sitting immediately beside `TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE`, `OSDK_PRESENCE_OFFLINE`, `OSDK_OL_STATE_NONE`. The UI flow events `checkOriginConnected` / `OriginIsOnline` / `OriginIsOffline` live at `0x143b4cb58`. This is almost certainly the on-screen message, and it is produced by the **Origin/LSX layer**, not by Blaze. + +--- + +## 2. Authentication component — command ids + +Component id **0x0001**. Ids **(A) clean-room 3P** (pamplona enum); every name **(A) binary**-confirmed present in FIFA 17's RPC name pool unless noted. + +| Cmd | RPC | Cmd | RPC | +|---|---|---|---| +| 10 (0x0A) | **`login`** | 54 | `disableOptIn` | +| 11 (0x0B) | `trustedLogin` | 60 (0x3C) | `expressLogin` | +| 20 | `updateAccount` | **70 (0x46)** | **`logout`** ← *observed* | +| 21 | `upgradeAccount` | 90 (0x5A) | `getPersona` | +| 29 (0x1D) | **`listUserEntitlements2`** | 100 | `listPersonas` | +| 30 | `getAccount` | 101 | `expressCreateAccount` | +| 31 | `grantEntitlement` | 230 | `createWalUserSession` | +| 32 | `listEntitlements` | 241 | `acceptLegalDocs` | +| 34 | `getUseCount` | 242 | `getEmailOptInSettings` | +| 35 | `decrementUseCount` | 246 | `getTermsOfServiceContent` | +| 36 (0x24) | **`getAuthToken`** | 260 | `getOriginPersona` | +| 38 | `getPasswordRules` | 270 | `checkEmail` | +| 39 | `grantEntitlement2` | 280 | `getPersonaNameSuggestions` | +| 43 | `modifyEntitlement2` | 290 | `guestLogin` | +| 44 | `consumecode` | | | +| 45 | `passwordForgot` | | | +| 47 | `getPrivacyPolicyContent` | | | +| 48 | `listPersonaEntitlements2` | | | +| 51 | `checkAgeReq` | | | +| 52 | `getOptIn` | | | +| 53 | `enableOptIn` | | | + +FIFA 17 additionally ships types with no id in the MEC enum — `GetUserAccessTokenRequest/Response`, `GetUserXblTokenRequest/Response`, `StressLoginRequest`, `CheckLegalDocRequest/Response`, `GetSuggestionsRequest` — so FIFA's component is a **superset**. Ids for those are unknown. + +**Error-code encoding (A) binary:** Blaze error codes are `(index << 16) | componentId`. Confirmed by the `0x000N7802` constant block at `0x146de08xx` sitting alongside the `USER_ERR_*` name stubs (component `0x7802` = UserSessions). So an Authentication error is `0xNNNN0001`. + +--- + +## 3. `login` (1/10) request + response TDF — **(A) binary, authoritative** + +Reflected from FIFA 17's own TDF type descriptors. **Fields MUST be emitted in ascending packed-tag order**, which for A–Z tags is plain alphabetical with `' '` (pad) sorting first — the member tables below are already in that order. + +### Request — `Blaze::Authentication::LoginRequest` @ `0x14487ca10` (3 members) + +| Tag | Member | Type | +|---|---|---| +| `AUTH` | `authCode` | string ← **the Origin/Nucleus auth code** | +| `EXTB` | `externalBlob` | blob | +| `EXTI` | `externalId` | uint64 | + +> z7's `LoginRequest` also carried `ACHT{SHID,SKID}` — **FIFA 17 has no `ACHT` member**. z7 is a different title/version. Ignore it. + +### Response — `Blaze::Authentication::LoginResponse` @ `0x14487d170` (**5 members**) + +| Tag | Member | Type | +|---|---|---| +| `ANON` | `isAnonymous` | bool | +| `NTOS` | `needsLegalDoc` | bool | +| `SESS` | `userLoginInfo` | struct `UserLoginInfo` | +| `SPAM` | `isOfLegalContactAge` | bool | +| `UNDR` | `isUnderage` | bool | + +> **Important divergence from the MEC emulators.** `grid-blaze/src/models/authentication.rs:23-50` and `pamplona-future/.../authentication.ts:78-128` both emit `CNTX`, `ERRC` and a top-level `SKEY` in the login *payload*. **FIFA 17's `LoginResponse` has none of those.** `CNTX`/`ERRC` are the Blaze **error metadata** block (see `grid-blaze/src/packet.rs:62-71` `ErrorBody`, and its own `// TODO: move ErrorBody to metadata`; pamplona sets `metadataSize: 75` on the login reply). Emit **exactly the 5 members above** in the payload; leave metadata empty on success. The session key lives at `SESS.KEY`, not at top level. + +### `SESS` — `Blaze::Authentication::UserLoginInfo` @ `0x14487cb00` (8 members) + +| Tag | Member | Type | +|---|---|---| +| `1CON` | `isFirstConsoleLogin` | bool | +| `BUID` | `blazeUserId` | int64 | +| `FRST` | `isFirstLogin` | bool | +| `KEY` | `sessionKey` | string ← **the forged session key** | +| `LLOG` | `lastLoginDateTime` | int64 | +| `MAIL` | `email` | string | +| `PDTL` | `personaDetails` | struct `PersonaDetails` | +| `UID` | `userId` | int64 | + +(`1` = 0x31 → packed 0x11, which is **below** `A` = 0x21, so `1CON` correctly sorts first.) + +### `PDTL` — `Blaze::Authentication::PersonaDetails` @ `0x14487cab0` (6 members) + +| Tag | Member | Type | +|---|---|---| +| `DSNM` | `displayName` | string | +| `LAST` | `lastAuthenticated` | uint32 | +| `PID` | `personaId` | int64 | +| `PLAT` | `clientPlatform` | enum `ClientPlatformType` | +| `STAS` | `status` | enum `PersonaStatus::Code` | +| `XREF` | `extId` | uint64 | + +### `getAuthToken` (1/36) response — `GetAuthTokenResponse` @ `0x14487d080` + +Single member: `AUTH` `authToken` : string. (Matches z7 exactly.) + +### `listUserEntitlements2` (1/29) response — `Blaze::Authentication::Entitlements` @ `0x14487d4e0` + +Single member `NLST` : `list`. + +`Blaze::Authentication::Entitlement` @ `0x14487d490` (16 members, tag order): +`DEVI` deviceUri(str), `GDAY` grantDate(str), `GNAM` groupName(str), `ID` id(u64), `ISCO` isConsumable(bool), `PID` personaId(i64), `PJID` projectId(str), `PRCA` productCatalog(enum), `PRID` productId(str), `STAT` status(enum `EntitlementStatus::Code`), `STRC` statusReasonCode(enum), `TAG` entitlementTag(str), `TDAY` terminationDate(str), `TYPE` entitlementType(enum `EntitlementType::Code`), `UCNT` useCount(u32), `VER` version(u32). + +**(A) binary** enum literals: `EntitlementType::Code` = `ONLINE_ACCESS`, `TRIAL_ONLINE_ACCESS`, `SUBSCRIPTIONS`, `PARENTAL_APPROVAL` (@`0x1438991e8`). `EntitlementStatus::Code` includes `ACTIVE`/`USED`/`UNUSED`/`BANNED`/`DISABLED`. + +--- + +## 4. Forging a session with no Nucleus — the recipe + +**(A) clean-room 3P** for the *shape* of the forgery, **(A) binary** for every tag. + +### 4.1 What a fake session consists of + +`grid-blaze/src/routes/authentication.rs:17-133` is the canonical minimal pattern: + +1. Ignore whatever is in `LoginRequest.AUTH` (it never validates it against Nucleus — it swaps in Discord OAuth; an offline emulator just skips validation entirely). +2. Mint a `User { user_id, persona_id, username }` from local storage / config. +3. Store it on the session (`session.data.set_user`). +4. **Push the `UserAuthenticated` notification** (see §4.3). +5. Reply with `LoginResponse`. + +### 4.2 Session key + +Both 3P emulators use a **canned literal**; the client does not verify it: +- `grid-blaze` uses simply `"0"` for both `SKEY` and `SESS.KEY`. +- `pamplona-future` uses a realistic-looking `"0540000031e5dde8_wT9NlhYTUidv3EMiZo7kaRMYV0x3$x72YrtOC*QU1v"`. + +Real Blaze session keys look like `<16 hex>_<44 random base64-ish chars>`. Recommend generating that shape once per session and reusing the **same string** in `LoginResponse.SESS.KEY` **and** in the `UserAuthenticated` notification's `KEY` — they must match. + +### 4.3 The `UserAuthenticated` notification — **(A) binary, exact** + +Component **`0x7802` (30722, UserSessions)**, command **`0x0008`**, msgType = **NOTIFICATION (2)**, msgNum = 0. + +Payload type is **`Blaze::UserSessionLoginInfo`** @ `0x14486f920`, **16 members**: + +| Tag | Member | Type | +|---|---|---| +| `1CON` | `isFirstConsoleLogin` | bool | +| `ALOC` | `accountLocale` | uint32 | +| `BUID` | `blazeUserId` | int64 | +| `CGID` | `connectionGroupObjectId` | ObjectId (triple) | +| `DSNM` | `displayName` | string | +| `FRST` | `isFirstLogin` | bool | +| `KEY` | `sessionKey` | string | +| `LAST` | `lastAuthenticated` | uint32 | +| `LLOG` | `lastLoginDateTime` | int64 | +| `MAIL` | `email` | string | +| `NASP` | `personaNamespace` | string | +| `PID` | `personaId` | int64 | +| `PLAT` | `clientPlatform` | enum | +| `UID` | `userId` | int64 | +| `USTP` | `userSessionType` | enum `UserSessionType` | +| `XREF` | `extId` | uint64 | + +> This is a **hard confirmation** of the z7 reference: `z7_userauth_notif.txt` (`comp=0x7802 cmd=0x0008 [UserSessions::UserAuthenticated]`) carries exactly these 16 tags. It also **corrects both MEC emulators**, which call notification 30722/8 `updateHardwareFlags` — that is the name of *command* 8, not *notification* 8. The payload they build is right; the name is wrong. + +### 4.4 `UserSessionExtendedDataUpdate` — **(A) binary** + +`Blaze::UserSessionExtendedDataUpdate` @ `0x1448703e0`, 3 members: `DATA` (struct `UserSessionExtendedData`), `SUBS` (bool), `USID` (int64). +This is what pamplona/grid-blaze mislabel `validateSessionKey` and send as **notification 30722/1**. + +`Blaze::UserSessionExtendedData` @ `0x144870390` — **12 members** (tag order): +`ADDR` address(`NetworkAddress`, union), `BPS` bestPingSiteAlias(str), `CTY` country(str), `CVAR` clientData(variable), `DMAP` dataMap(`map`), `HWFG` hardwareFlags, `ISP` iSP(str), **`PSLM` latencyList(`list`)**, `QDAT` qosData, `TZ` timeZone(str), `UATT` userInfoAttribute(u64), `ULST` blazeObjectIdList(`list`). + +> Two FIFA-17-specific deltas vs the MEC emulators: FIFA has **`PSLM`** (they don't), and FIFA has **`BPS` as a top-level string member** (they wrap it inside the `ADDR` union as `BPS `). Follow the FIFA layout. + +`Blaze::Util::NetworkQosData` (`QDAT`) @ `0x14486e680`: `BWHR` u32, `DBPS` u32, `NAHR` u32, `NATT` enum `NatType`, `UBPS` u32. + +### 4.5 Persona identity must match the Origin emu + +From `stp-origin_emu.ini [Globals]`: `PersonaId=33068179`, `PersonaName=CAGE`, `Language=en_US`. +Use `BUID = PID = 33068179`, `DSNM = "CAGE"`, `NASP = "cem_ea_id"` (namespace confirmed by our own PreAuthResponse being accepted), `PLAT = 4` (pc), `USTP = 0`. +Mismatch trips `AUTH_ERR_INVALID_PERSONA` / `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` / `AUTH_ERR_PERSONA_NOT_FOUND` — all present in the binary at `0x146e0ed91`, `0x146e0eebf`, `0x146e0ecf8`. + +--- + +## 5. `Util::fetchClientConfig` (9/1) — what to return + +Request: `Blaze::Util::FetchClientConfigRequest` = `{ CFID: string }`. +Response: `Blaze::Util::FetchConfigResponse` @ `0x1448752e0` = **single member `CONF` : `map`**. (Note: **not** wrapped in an extra struct — the extra nesting only exists inside `PreAuthResponse`, where `CONF` is a `FetchConfigResponse` struct whose own single member is also called `CONF`. Easy to get wrong.) + +### 5.1 The `BlazeSDK` section (returned inside `PreAuthResponse.CONF.CONF`) + +Our capture shows the client asks for this **inside the preAuth request**: `FCCR { CFID = 'BlazeSDK' }`. **(A) binary** — these are the config keys FIFA 17 actually parses (string literals present in the exe; the "absent" ones are MEC-only and are silently ignored by FIFA 17): + +| Key | Present in FIFA17.exe | Owner (from adjacent literals) | +|---|---|---| +| `connIdleTimeout` | ✅ `0x1438a0a58` | `ConnectionManager` | +| `defaultRequestTimeout` | ✅ `0x1438a0a40` | `ConnectionManager` | +| `pingPeriod` | ✅ `0x1438a0a30` | `ConnectionManager` | +| `autoReconnectEnabled` | ✅ `0x1438a0a68` | `ConnectionManager` | +| `maxReconnectAttempts` | ✅ `0x1438a0a80` | `ConnectionManager` | +| `enableQosFirewallTest` | ✅ `0x1438a09f0` | `ConnectionManager`/`QosManager` | +| `enableQosBandwidthTest` | ✅ `0x1438a0a08` | `ConnectionManager`/`QosManager` | +| **`nucleusConnect`** | ✅ `0x14389fef8` | **`LoginStateMachineImpl`** | +| **`nucleusConnectTrusted`** | ✅ `0x14389fdf8` | **`LoginStateMachineImpl`** | +| `associationListSkipInitialSet` | ✅ `0x143b6eb88` | `AssociationListAPI` | +| `userManagerMaxCachedUsers` | ✅ | UserManager | +| `voipHeadsetUpdateRate` | ✅ | VoIP | +| `nucleusPortal` | ❌ absent | MEC-only | +| `nucleusProxy` | ❌ absent | MEC-only | +| `bytevaultHostname` / `bytevaultPort` / `bytevaultSecure` | ❌ absent | MEC-only | +| `xblTokenUrn`, `xboxOneStringValidationUri`, `xlspConnectionIdleTimeout` | ❌ absent | MEC/Xbox-only | + +**How `nucleusConnect` is used — (A) binary.** The `LoginManagerImpl` / `LoginStateMachineImpl` string cluster at `0x14389fd50–0x14389fef8` reads, in order: + +``` +LoginManagerImpl · LoginData · LoginStateMachineImpl · LoginStateMachine +nucleusConnectTrusted · "%s/connect/token" · "grant_type=client_credentials" +recvBuf · "NEXUS_S2S " · "\"access_token\" : \"" · headers +LoginStateBase::buffer +LoginStateAuthenticated::mTermsOfServiceBuffer +LoginStateAuthenticated::mPrivacypolicyBuffer +nucleusConnect +``` + +So the client builds **`/connect/token`**, POSTs `grant_type=client_credentials`, and scrapes `"access_token" : "` out of the JSON reply. **Point `nucleusConnect` / `nucleusConnectTrusted` at our own HTTPS listener and serve a canned OAuth token JSON** — that is the Blaze-side half of defeating auth. There is a `NEXUS_S2S` header value involved. `LoginStateAuthenticated` also buffers ToS + privacy-policy text, which is why `NTOS` (`needsLegalDoc`) in `LoginResponse` should be **0**. + +Recommended `BlazeSDK` map (keep it minimal — FIFA 17 ignores unknown keys): + +``` +associationListSkipInitialSet = 1 +autoReconnectEnabled = 0 +connIdleTimeout = 90000000 +defaultRequestTimeout = 30000000 +enableQosBandwidthTest = false +enableQosFirewallTest = false +maxReconnectAttempts = 0 +nucleusConnect = https://accounts.ea.com <- repoint to us +nucleusConnectTrusted = https://accounts2s.ea.com <- repoint to us +pingPeriod = 20000000 +userManagerMaxCachedUsers = 0 +``` + +### 5.2 The `OSDK_*` sections — **(A) binary** + +`OSDK_CORE`, `OSDK_CLIENT`, `OSDK_NUCLEUS`, `OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_TICKER` are literals at `0x143962be8..0x143962c40`, and they sit **inside the `ResourceLoader` / `NETRESOURCE` / `LoadResourceFromMultiUrl` / `netres` string cluster**. Adjacent source path: `.../extern/OSDK/8.01.03.00-fifa.01/source/common/presencedownloadmanagerabstract.cpp` → the game embeds **OSDK 8.01.03.00-fifa.01**. + +Meaning: these sections are **game-tuning key/value maps** consumed by FIFA's OSDK layer (a `ResourceLoader` with states `LOADING`/`LOADED`/`NOT_FOUND`), not Blaze plumbing. The key namespace is `OSDK_*`; confirmed examples of *real* config keys (as opposed to the many `OSDK_*` localisation ids): + +`OSDK_PRESENCE_DELAY`, `OSDK_PRESENCE_POLL`, `OSDK_ABUSE_NUM_TYPES`, `OSDK_ANTIGRIEFING_MAX_COUNT`, `OSDK_ARENA_ENABLED`, `OSDK_ARENA_CHALLENGE_SCHEDULE_URL`, `OSDK_ARENA_REGISTER_EMAIL_URL`, `OSDK_CLUBS_MAX_SEARCH_RESULT`, `OSDK_CLUBS_LOAD_MEMBER_PAGE_SIZE`, `OSDK_CLUBS_MAX_USERS_FOR_GAME`, `OSDK_CLUBS_LEADERBOARD_CLUB_MAX`, `OSDK_CLUBS_INCOME_SEARCH_MAX`. + +There is **no `nucleusConnect`-equivalent in `OSDK_NUCLEUS`** that we could find; the Nucleus URLs are BlazeSDK-level (§5.1). `OSDK_NUCLEUS` most likely holds Nucleus *tuning* (poll intervals, retry counts). + +> **Assessment:** returning an empty `CONF` map for the `OSDK_*` sections is probably **not** what killed us — these are tuning values that fall back to defaults. Answering them non-empty is cheap insurance, but the real blocker is layer 1 (Origin/LSX `GetAuthCode` / `OriginIsOnline`). Also note `OSDK_XMS_ABUSE_REPORTING` (which the client requested) is **not** in the literal block — so the section list is built dynamically. + +### 5.3 `IdentityParams` + +Not requested by FIFA 17 in our capture, but both MEC emulators and z7 answer it identically: +`display = console2/welcome`, `redirect_uri = http://127.0.0.1/success`. + +--- + +## 6. Post-login RPC order + +**(A) wire (ours)** for everything up to `logout`. Beyond that, **(A) clean-room 3P** + z7, since we have never got past it. + +Observed by us (FIFA 17): +``` +1 9/7 Util::preAuth (req carries FCCR{CFID='BlazeSDK'}, CINF{CLNT='FIFA17', BSDK='15.1.1.3.0'}) +2 9/2 Util::ping + -- reconnect -- +3 9/1 Util::fetchClientConfig CFID=OSDK_CORE +4 9/1 CFID=OSDK_CLIENT +5 9/1 CFID=OSDK_NUCLEUS +6 9/1 CFID=OSDK_WEBOFFER +7 9/1 CFID=OSDK_ABUSE_REPORTING +8 9/1 CFID=OSDK_XMS_ABUSE_REPORTING +9 1/70 Authentication::logout <-- gave up here +``` + +Expected happy path (compiled from `grid-blaze/src/main.rs:32-44`, `pamplona-future/.../util.ts:41-74`, and the z7 msgNum ordering): + +``` +9/7 Util::preAuth -> PreAuthResponse +9/1 Util::fetchClientConfig -> FetchConfigResponse (xN) +1/10 Authentication::login -> LoginResponse + << NOTIFY 30722/8 UserAuthenticated (UserSessionLoginInfo) +9/8 Util::postAuth -> PostAuthResponse + << NOTIFY 30722/1 UserSessionExtendedDataUpdate + << NOTIFY 30722/2 (UserAdded — DATA + USER) +1/29 Authentication::listUserEntitlements2 -> Entitlements{NLST} +9/28 Util::setClientState -> empty reply (req: MODE=1, STAT=0) +1/36 Authentication::getAuthToken -> {AUTH: ""} +9/10 Util::userSettingsLoad -> UserSettingsResponse +25/6 AssociationLists::getLists -> GetListsResponse{LMAP} +30722/20 UserSessions::updateNetworkInfo -> empty reply +9/22 Util::setClientMetrics -> empty reply +9/2 Util::ping (every pingPeriod) +``` + +Ordering caveat: in `grid-blaze` the `UserAuthenticated` notification is pushed **from inside the login handler, before the login reply is written** (`routes/authentication.rs:128-132`); pamplona writes the reply first, then the notification (`authentication.ts:54-57`). Both apparently work. `postAuth` pushes its two notifications around its reply (`util.ts:60-64`: extendedDataAttribute → reply → extendedData). + +### `Util::postAuth` (9/8) — **(A) binary** + +Request `Blaze::Util::PostAuthRequest` @ `0x1448757c0`: **`DSUI` dirtySockUserIndex(int32), `UDID` uniqueDeviceId(string)** — only 2 members. *(z7 shows a third `MAC` field; FIFA 17 does not have it. Another z7 mismatch.)* + +Response `Blaze::Util::PostAuthResponse` @ `0x144875810`: `TELE`, `TICK`, `UROP`. +- `TELE` = `GetTelemetryServerResponse` @ `0x144875470`, 15 members: `ADRS`(str) `ANON`(bool) `DISA`(str) `EDCT`(bool) `FILT`(str) `LOC`(u32) `MINR`(bool) `NOOK`(str) `PORT`(u32) `SDLY`(u32) `SESS`(str) `SKEY`(str) `SPCT`(u32) `STIM`(str) `SVNM`(str). +- `TICK` = `GetTickerServerResponse` @ `0x1448754c0`, 3 members: `ADRS`(str) `PORT`(u32) `SKEY`(str). +- `UROP` = `UserOptions` @ `0x144875770`, 2 members: `TMOP`(enum `TelemetryOpt`) `UID`(int64). + +### Bug in our current responder + +`Blaze::Util::PingResponse` @ `0x144875560` has **exactly one member: `STIM` (serverTime, uint32)**. `blaze_responder_v2.py` sends `STIM` **and** `TIME`. `TIME` is not a member of FIFA 17's `PingResponse` (it is MEC's). Harmless-ish, but drop it. + +--- + +## 7. Association lists (25/6) — for completeness + +**(A) clean-room 3P** only (`grid-blaze/src/models/association_lists.rs`). Response `GetListsResponse{ LMAP: list }`; each entry is `INFO{ BOID(ObjectId) FLGS(u8) LID{LNM(str) TYPE(u8)} LMS(u32) PNAM(str) PRID(u8) PRMS(u32) }`, `OFRC`, `TOCT`. MEC ships `friendList`(type 1), `followList`(type 5), `communicationBlockList`(type 4). FIFA's list names are **not** verified — do not assume. + +--- + +## 8. Recommended next actions + +1. **Fix layer 1 first.** The client logs out *before* trying to log in, and the on-screen string is `TXT_NOT_LOGIN_TO_EBISU`. Make the in-process LSX server on `127.0.0.1:4216` answer `OriginIsOnline` / `GetInternetConnectedState` as **online**, and `GetAuthCode` with any non-empty code. Without that there is nothing to put in `LoginRequest.AUTH` and Blaze work is unreachable. +2. Implement `Util::fetchClientConfig` returning a non-empty `CONF` map (`{CONF: map}`) for every `CFID`, even if only a couple of keys — removes it as a variable. +3. Put `nucleusConnect` / `nucleusConnectTrusted` in the **preAuth** `CONF` map pointing at our own listener, and serve `POST /connect/token` returning `{"access_token" : ""}`. +4. Implement `Authentication::login` (1/10) → the 5-member `LoginResponse` above, then push `UserAuthenticated` (30722/8, 16 members) with the **same** session-key string, using PersonaId 33068179 / "CAGE". +5. Implement `Util::postAuth` (9/8), `Authentication::listUserEntitlements2` (1/29) returning one `ONLINE_ACCESS` entitlement (`PJID`/offer id `1027460`, `TYPE`=ONLINE_ACCESS, `STAT`=ACTIVE), `Util::setClientState` (9/28) empty, `Authentication::getAuthToken` (1/36) → `{AUTH}`. +6. Keep handling `Authentication::logout` (1/70) with an empty reply — but treat receiving it as a **failure signal** in the responder log, not a normal step. + +--- + +## 9. File index + +**Cloned reference repos (scratchpad):** +- `/tmp/.../scratchpad/grid-blaze/src/routes/authentication.rs` — login handler + entitlements +- `/tmp/.../scratchpad/grid-blaze/src/models/authentication.rs` — AuthResponse/Entitlement serialisers +- `/tmp/.../scratchpad/grid-blaze/src/routes/util.rs`, `src/models/util.rs` — preAuth/postAuth/fetchClientConfig +- `/tmp/.../scratchpad/grid-blaze/src/models/user_sessions.rs` — the 4 session notifications +- `/tmp/.../scratchpad/grid-blaze/src/main.rs:32-44` — full route table +- `/tmp/.../scratchpad/grid-blaze/src/packet.rs` — Fire2 framing (matches our corrected layout) +- `/tmp/.../scratchpad/pamplona-future/src/blaze/components/authentication.ts:10-46` — **the command-id enum** +- `/tmp/.../scratchpad/pamplona-future/src/blaze/components/util.ts:17-39` — Util command enum +- `/tmp/.../scratchpad/pamplona-future/src/blaze/components/user-sessions.ts:21-50` — UserSessions command enum +- `/tmp/.../scratchpad/catalyst-mitm/blaze/interceptor.ts` — redirector request XML shape +- `/tmp/.../scratchpad/tdf/src/{writer,reader,tag,types}.rs` — reference TDF codec + +**Our own artefacts:** +- `/home/alex/Documents/OpenFUT/fifa17-recon/captures/blaze/session/session_full.log` — the live session +- `/home/alex/Documents/OpenFUT/fifa17-recon/captures/blaze/session/auth_cmd0x46.bin` — the logout frame +- `/home/alex/Documents/OpenFUT/fifa17-recon/tools/preauth_schema_reflection.md` — prior reflection write-up + Util command table + +**Tooling written/used this pass (scratchpad):** +- `reflect2.py` — TDF type-descriptor walker (`raw ` / `index ` / `byname`) +- `authscan.py`, `stubrange.py`, `allstubs.txt` — `lea/ret` command-name stub recovery +- `findstr2.py`, `nameblk.py`, `allstr.py`, `xref.py`, `rvatab.py`, `notifid.py` — string/xref/constant hunting +- `clusters.json` — all 124 name-stub clusters (includes the UserSessions notification-name cluster + `ServerDraining, UserAdded, UserAuthenticated, UserRemoved, UserSessionExtendedDataUpdate, UserUnauthenticated` @ `0x146de19c0`) + +**Descriptor VAs (FIFA17.exe, base `0x140000000`):** +`LoginRequest 0x14487ca10` · `LoginResponse 0x14487d170` · `UserLoginInfo 0x14487cb00` · `PersonaDetails 0x14487cab0` · `GetAuthTokenResponse 0x14487d080` · `ExpressLoginRequest 0x14487d0d0` · `Entitlement 0x14487d490` · `Entitlements 0x14487d4e0` · `AccountInfo 0x14487c810` · `PersonaInfo 0x14487c7c0` · `UserSessionLoginInfo 0x14486f920` · `UserSessionLogoutInfo 0x14486f970` · `UserSessionExtendedData 0x144870390` · `UserSessionExtendedDataUpdate 0x1448703e0` · `Util::PostAuthRequest 0x1448757c0` · `Util::PostAuthResponse 0x144875810` · `Util::FetchConfigResponse 0x1448752e0` · `Util::PingResponse 0x144875560` · `Util::PreAuthResponse 0x144875600` diff --git a/fifa17-recon/tools/auth_schema_reflection.md b/fifa17-recon/tools/auth_schema_reflection.md new file mode 100644 index 0000000..08513b5 --- /dev/null +++ b/fifa17-recon/tools/auth_schema_reflection.md @@ -0,0 +1,527 @@ +# FIFA 17 Blaze — Authentication component (0x0001) reflection-reversed schema + +**Date:** 2026-07-30 · **Target:** live `FIFA17.exe` PID 19517 (alive throughout, still at the +main menu afterwards) · **Method:** `/proc//mem` reflection walk + targeted disassembly + +in-process function calls via gdb. + +Companion doc: `fifa17-recon/tools/preauth_schema_reflection.md` (Util component / PreAuthResponse). + +--- + +## 0. Clean-room provenance + +Every fact below came from one of three allowed sources: + +1. **The client's own runtime reflection metadata**, read out of the process we own. FIFA 17's + BlazeSDK ships full TDF type descriptors (class names, member names, wire tags, struct offsets) + in `.data`. This is the bulk of §3–§6. +2. **Disassembly of code in the binary we own** (`getCommandName` / `getErrorName` / + `getNotificationName` switches, TDF descriptor initialisers, notification dispatcher). +3. **Calling the client's own name-lookup functions in-process** (§2). This is reading the + binary's answer to its own question — no external artefact involved. + +**No EA/FIFA leaked source was consulted at any point.** No third-party BlazeSDK reimplementation +was consulted for this document either (unlike the Fire2 header note in the preAuth doc, the +findings here are all first-party). + +--- + +## 1. Headline result: the client sent `logout`, not `login` + +The observed live frame + +``` +RX #9 Authentication::cmd:0x0046 msgType=MESSAGE msgNum=16 userIdx=0 payload=0B +``` + +**`Authentication` command `0x0046` (70) = `logout`.** (§2, verified twice.) + +`logout` has **no request TDF and no response TDF** — there is no `Blaze::Authentication::LogoutRequest` +or `LogoutResponse` anywhere in the client's type index, which is exactly why the payload was 0 bytes. +Our empty REPLY was therefore *correct on the wire*. + +This reframes the whole gate: + +- The client **never sent `Authentication::login` (cmd 0x000A)**, never sent `getAuthToken` (0x0024), + never sent `listUserEntitlements2` (0x001D), never sent `listPersonas` (0x0064). +- It went `preAuth` → `ping` → 6× `fetchClientConfig` → **`logout`** → transport-ping loop. +- A client that logs out without ever logging in has decided *before touching Blaze auth* that it has + no credential to present. That is the Origin/LSX layer (layer 1 in the repack recon), not Blaze. + +**Implication for the workflow:** implementing `Authentication::login` server-side is necessary but +**not sufficient and not the current blocker**. The client must first obtain an auth code from the +in-process Origin/LSX stub on `127.0.0.1:4216` (`GetAuthCode` → `AuthCodeResponse`, and +`OriginIsOnline` / `GetInternetConnectedState` must report ONLINE). Until that succeeds the client +will keep skipping straight to `logout`. See §7 for the ordering this implies. + +--- + +## 2. Authentication component RPC table — **complete** + +### How it was recovered + +Each Blaze component has a 7-pointer `ComponentDescription`-style table in `.rdata`. Layout +established by diffing the Util table (whose command map we already knew) against Authentication's: + +| Offset | Meaning | Util | Authentication | +|---|---|---|---| +| `+0x00` | shared helper | `0x146f022d0` | `0x146f022d0` | +| `+0x08` | `getComponentName()` | `0x146df7370` → `"UtilComponent"` | `0x146e0ec00` → `"AuthenticationComponent"` | +| `+0x10` | **`getCommandName(u16)`** | `0x146df6dc0` | **`0x146e0d2a0`** | +| `+0x18` | shared helper | `0x145bf3580` | `0x145bf3580` | +| `+0x20` | `getErrorName(u32)` | `0x146df7380` | `0x146e0ec20` | +| `+0x28` | `getRestResourceInfo(u16)` | `0x146f82070` (null stub) | `0x146e0f2e0` | +| `+0x30` | shared helper | `0x1465734f0` | `0x1465734f0` | + +Table addresses: Util `0x143895820`, Authentication `0x14389d628`, plus one more at `0x143891940` +(Redirector) and six in `0x1438f5xxx` (FIFA-custom components). + +Util's `getCommandName` is clean code — `movzx eax,dx; dec eax; cmp eax,0x1b; ja default;` +jump table of 28 image-relative RVAs at `0x141b17af4`. Decoding it statically **reproduces the +previously-known Util table exactly** (`fetchClientConfig=1, ping=2, preAuth=7, postAuth=8`, …), +which validates the whole approach. + +**Authentication's `getCommandName` at `0x146e0d2a0` is Denuvo-mutated** — it begins +`push rcx; lea rcx,[rip+…]; jmp 0x14e183f34` into the protector arena. Consequently: + +- the 33-name alphabetical string pool at `0x14389d690`–`0x14389d928` has **zero** references + anywhere in the address space (no `lea`, no qword pointer, no image-relative u32 — checked across + every readable region including the heap); +- so the command map cannot be recovered statically. + +It was recovered instead by **calling the function in the live process**: + +``` +gdb --batch -p -ex 'p ((char*(*)(long,long,int))0x146e0d2a0)(0,0,)' +``` + +Note the calling-convention detail: the target is **Windows x64** code (`this`→`rcx`, arg→`rdx`) but +gdb marshals with the **SysV** ABI (`rdi, rsi, rdx, …`), so the command id must be passed as the +**third** gdb argument to land in `rdx`. Validated against Util first (returned `preAuth` for 7, +`ping` for 2, `fetchClientConfig` for 1, `postAuth` for 8), then applied to Authentication. + +Two independent cross-checks on the Authentication result: +- id `0x000B` → `"trustedLogin"`, which matches the static REST-binding struct at `0x143896a80` + (`{u16 component=0x0001, u16 command=0x000B, …, const char* name="trustedLogin", …, "GET"}`). +- every one of the 33 names in the static string pool is accounted for, with no leftovers. + +### The table + +Ids 1–320 were swept; everything not listed returns the empty default string. + +| Cmd | RPC | Cmd | RPC | +|---|---|---|---| +| `0x000A` (10) | **`login`** | `0x003C` (60) | `expressLogin` | +| `0x000B` (11) | `trustedLogin` | **`0x0046` (70)** | **`logout`** ← *observed on the wire* | +| `0x0014` (20) | `updateAccount` | `0x005A` (90) | `getPersona` | +| `0x0015` (21) | `upgradeAccount` | `0x0064` (100) | `listPersonas` | +| `0x001D` (29) | `listUserEntitlements2` | `0x0065` (101) | `expressCreateAccount` | +| `0x001E` (30) | `getAccount` | `0x00E6` (230) | `createWalUserSession` | +| `0x001F` (31) | `grantEntitlement` | `0x00F1` (241) | `acceptLegalDocs` | +| `0x0020` (32) | `listEntitlements` | `0x00F2` (242) | `getEmailOptInSettings` | +| `0x0022` (34) | `getUseCount` | `0x00F6` (246) | `getTermsOfServiceContent` | +| `0x0023` (35) | `decrementUseCount` | `0x0104` (260) | `getOriginPersona` | +| `0x0024` (36) | `getAuthToken` | `0x010E` (270) | `checkEmail` | +| `0x0026` (38) | `getPasswordRules` | `0x0118` (280) | `getPersonaNameSuggestions` | +| `0x0027` (39) | `grantEntitlement2` | `0x0122` (290) | `guestLogin` | +| `0x002B` (43) | `modifyEntitlement2` | | | +| `0x002C` (44) | `consumecode` | | | +| `0x002D` (45) | `passwordForgot` | | | +| `0x002F` (47) | `getPrivacyPolicyContent` | | | +| `0x0030` (48) | `listPersonaEntitlements2` | | | +| `0x0033` (51) | `checkAgeReq` | | | +| `0x0034` (52) | `getOptIn` | | | +| `0x0035` (53) | `enableOptIn` | | | +| `0x0036` (54) | `disableOptIn` | | | + +Confidence: **certain** for every row (the client itself produced these strings). + +### Request / response type binding + +From the reflection type index (`Blaze::Authentication::*`): + +| Cmd | RPC | Request TDF | Response TDF | +|---|---|---|---| +| `0x000A` | `login` | `LoginRequest` | **`LoginResponse`** | +| `0x000B` | `trustedLogin` | `TrustedLoginRequest` | `LoginResponse` | +| `0x003C` | `expressLogin` | `ExpressLoginRequest` | `LoginResponse` | +| `0x0122` | `guestLogin` | *(none)* | `LoginResponse` | +| **`0x0046`** | **`logout`** | ***(none — empty)*** | ***(none — empty)*** | +| `0x0024` | `getAuthToken` | *(none)* | `GetAuthTokenResponse` | +| `0x001D` | `listUserEntitlements2` | `ListUserEntitlements2Request` | `Entitlements` | +| `0x0030` | `listPersonaEntitlements2` | `ListPersonaEntitlements2Request` | `Entitlements` | +| `0x0064` | `listPersonas` | *(none)* | `ListPersonasResponse` | +| `0x005A` | `getPersona` | *(none)* | `GetPersonaResponse` | +| `0x001E` | `getAccount` | *(none)* | `AccountInfo` | +| `0x0104` | `getOriginPersona` | `GetOriginPersonaRequest` | `PersonaInfo` | + +`logout` having no TDFs on either side is confirmed by absence: there is no `LogoutRequest` / +`LogoutResponse` type anywhere in the client's index. + +--- + +## 3. `Blaze::Authentication::LoginResponse` — the login reply (cmd `0x000A`) + +Descriptor `0x14487d170`, member table `0x1448789c0`, 5 members. Serialise in **ascending packed-tag +order** (which is the order the table itself is sorted in). + +| Tag | Member | TDF type | Wire type | Offset | +|---|---|---|---|---| +| `ANON` | `isAnonymous` | bool | `0x00` | `+0x12` | +| `NTOS` | `needsLegalDoc` | bool | `0x00` | `+0x13` | +| `SESS` | `userLoginInfo` | `Blaze::Authentication::UserLoginInfo` | `0x03` | `+0x18` | +| `SPAM` | `isOfLegalContactAge` | bool | `0x00` | `+0x10` | +| `UNDR` | `isUnderage` | bool | `0x00` | `+0x11` | + +### `Blaze::Authentication::UserLoginInfo` (`SESS`) — 8 members, descriptor `0x14487cb00` + +| Tag | Member | Type | Offset | +|---|---|---|---| +| `1CON` | `isFirstConsoleLogin` | bool | `+0x51` | +| `BUID` | `blazeUserId` | int64 | `+0x28` | +| `FRST` | `isFirstLogin` | bool | `+0x50` | +| `KEY ` | `sessionKey` | string | `+0x10` | +| `LLOG` | `lastLoginDateTime` | int64 | `+0x58` | +| `MAIL` | `email` | string | `+0x38` | +| `PDTL` | `personaDetails` | `PersonaDetails` (struct) | `+0x60` | +| `UID ` | `userId` | int64 | `+0x30` | + +> Note the trailing-space tags: `KEY` and `UID` are 3-char tags. `heat2.py`'s `encode_tag` +> pads to 4 with the 6-bit "space" code, which is what the wire expects. + +### `Blaze::Authentication::PersonaDetails` (`PDTL`) — 6 members, descriptor `0x14487cab0` + +| Tag | Member | Type | Offset | +|---|---|---|---| +| `DSNM` | `displayName` | string | `+0x18` | +| `LAST` | `lastAuthenticated` | uint32 | `+0x3c` | +| `PID ` | `personaId` | int64 | `+0x10` | +| `PLAT` | `clientPlatform` | `Blaze::ClientPlatformType` (enum → int) | `+0x38` | +| `STAS` | `status` | `PersonaStatus::Code` (enum → int) | `+0x40` | +| `XREF` | `extId` | uint64 | `+0x30` | + +### Request side (for completeness) + +`Blaze::Authentication::LoginRequest` (`0x14487ca10`, 3 members): + +| Tag | Member | Type | +|---|---|---| +| `AUTH` | `authCode` | string ← *the Origin/LSX auth code* | +| `EXTB` | `externalBlob` | blob | +| `EXTI` | `externalId` | uint64 | + +`TrustedLoginRequest` (`0x14487ca60`): `ID ` id:string, `ITYP` idType:string, `TOKN` accessToken:string. +`ExpressLoginRequest` (`0x14487d0d0`): `MAIL` email:string, `PASS` password:string, `PNAM` personaName:string. +`StressLoginRequest` (`0x14487d120`): `MAIL` email:string, `NUID` nucleusId:uint64, `PNAM` personaName:string. + +The `AUTH` field being a plain string is the whole hinge: **the client cannot fill it without a +working Origin `GetAuthCode`.** + +--- + +## 4. What the server must return for the client to consider itself authenticated + +**Caveat on method.** For `PreAuthResponse` we could read `ConnectionManager::onPreAuthResponse` +directly and prove field-by-field what is and isn't validated. The equivalent login-response handler +lives in the FIFA/OSDK layer, and that code is **Denuvo-mutated** — the same protection that hid +`getCommandName`. So the list below is derived from *structure* (what the types carry, what the +UserSessions notification duplicates, what the client must have in order to name itself), not from +reading the handler. Flagged accordingly. + +| Field | Why it matters | Confidence it is required | +|---|---|---| +| `SESS.KEY ` `sessionKey` | The Blaze session credential. Every later component echoes it; a session with an empty key is not a session. Must be non-empty. | **high** | +| `SESS.BUID` `blazeUserId` | The client's own BlazeId. Used as the identity key in UserManager and in every later `lookupUser*`. Must be non-zero. | **high** | +| `SESS.UID ` `userId` | Nucleus user id. Should equal `blazeUserId` for a single-account offline emu unless you have a reason to split them. | **high** | +| `SESS.PDTL.PID ` `personaId` | **Must equal `33068179`** (the `stp-origin_emu.ini` `PersonaId`). A mismatch is exactly what trips `AUTH_ERR_INVALID_PERSONA` / `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` / `AUTH_ERR_PERSONA_NOT_FOUND`. | **high** | +| `SESS.PDTL.DSNM` `displayName` | **Must equal `CAGE`.** This is the name the UI renders and the value later persona lookups are matched against. | **high** | +| `SESS.PDTL.STAS` `status` | Persona status enum. Must be the "active" value; `AUTH_ERR_PERSONA_INACTIVE` (19) and `AUTH_ERR_PERSONA_BANNED` (32) exist for the other cases. Send `0`. | medium | +| `SESS.PDTL.PLAT` `clientPlatform` | Should match the `PLAT` you returned in `PreAuthResponse` (`pc`). | medium | +| `SESS.PDTL.XREF` `extId` | External (Origin) id. Safe to mirror `personaId`. | low | +| `SESS.MAIL` `email` | Cosmetic; any well-formed address. | low | +| `ANON` `isAnonymous` | **Must be `false`/0.** An anonymous session is precisely a not-really-logged-in session and will not enable online features. | **high** | +| `NTOS` `needsLegalDoc` | **Must be `false`/0**, otherwise the client will branch into the legal-doc flow (`acceptLegalDocs` = 241, `getTermsOfServiceContent` = 246) instead of proceeding. | **high** | +| `UNDR` `isUnderage` | **Must be `false`/0** — underage gates online play. | **high** | +| `SPAM` `isOfLegalContactAge` | Set `true`/1 for symmetry with `UNDR=false`. | medium | +| `FRST` / `1CON` / `LLOG` | First-login flags and timestamp. Cosmetic; `false/false/` is fine. | low | + +Consistency rule that spans both layers: **`personaId` and `personaName` returned over LSX, +returned in `LoginResponse.PDTL`, and pushed in the `UserAuthenticated` notification must be the +same triple** (`33068179` / `CAGE` / `en_US`). Every `AUTH_ERR_*_PERSONA*` code in §6 is a symptom +of these three disagreeing. + +--- + +## 5. The login-success notification the server must push + +Authentication (component `0x0001`) has **exactly one** notification. Its `getNotificationName`-slot +function `0x146e0f2e0` is a one-case switch: + +``` +cmp dx,0xb ; jne default ; lea rax,[0x143896a80] ; ret +``` + +and `0x143896a80` is **not** a notification name — it is the REST-binding struct for +`trustedLogin` (cmd `0x000B`). So Authentication publishes **no async notifications at all**; the +slot is `getRestResourceInfo`, not `getNotificationName`. + +**The login-success notification lives on `UserSessions`, component `0x7802` (30722).** +Component id confirmed independently: that component's error table compares against +`(errorNumber<<16)|0x7802` (`0x17802`, `0x27802`, … `0x177802`). + +`UserSessions::getNotificationName` at `0x146de19a0` is **clean, unmutated** code — +`movzx eax,dx; dec eax; cmp eax,0xb; ja default;` with a 12-entry image-relative jump table at +`0x141b03f70`. Decoded statically: + +| Notification id | Name | Payload TDF | +|---|---|---| +| `0x0001` | `UserSessionExtendedDataUpdate` | `Blaze::UserSessionExtendedDataUpdate` | +| `0x0002` | `UserAdded` | `Blaze::UserData` | +| `0x0003` | `UserRemoved` | `Blaze::UserIdentification` *(or `UserStatus`)* | +| `0x0004` | *(unused)* | — | +| `0x0005` | `UserUpdated` | `Blaze::UserStatus` | +| `0x0006`–`0x0007` | *(unused)* | — | +| **`0x0008`** | **`UserAuthenticated`** | **`Blaze::UserSessionLoginInfo`** | +| `0x0009` | `UserUnauthenticated` | `Blaze::UserSessionLogoutInfo` | +| `0x000A`–`0x000B` | *(unused)* | — | +| `0x000C` | `ServerDraining` | — | + +The **id → name** column is certain (decoded from the client's own jump table). The **payload TDF** +column is *inferred*: the notification dispatcher at `0x146de2803` (12-entry jump table at +`0x141b05258`, branches at `0x146de2bc3 / 289c / 2ae9 / 2dd8 / 29e0 / 2cf1 / 2825`) constructs its +TDFs on the stack rather than through the type-descriptor getters, so I could not bind each branch +to a descriptor by xref. The mapping above is a 1:1 name match against the only session types the +client links in, and `UserSessionLoginInfo` is the only type in the entire index that carries a +session key plus a persona — but treat the payload column as **high confidence, not verified**. + +### `Blaze::UserSessionLoginInfo` — 16 members, descriptor `0x14486f920` + +This is the notification body to push after a successful `login`. Note it is a **superset** of +`UserLoginInfo` with the persona fields flattened in rather than nested. + +| Tag | Member | Type | Offset | +|---|---|---|---| +| `1CON` | `isFirstConsoleLogin` | bool | `+0x51` | +| `ALOC` | `accountLocale` | uint32 | `+0x60` | +| `BUID` | `blazeUserId` | int64 | `+0x28` | +| `CGID` | `connectionGroupObjectId` | ObjectId | `+0xb0` | +| `DSNM` | `displayName` | string | `+0x70` | +| `FRST` | `isFirstLogin` | bool | `+0x50` | +| `KEY ` | `sessionKey` | string | `+0x10` | +| `LAST` | `lastAuthenticated` | uint32 | `+0xac` | +| `LLOG` | `lastLoginDateTime` | int64 | `+0x58` | +| `MAIL` | `email` | string | `+0x38` | +| `NASP` | `personaNamespace` | string | `+0x88` | +| `PID ` | `personaId` | int64 | `+0x68` | +| `PLAT` | `clientPlatform` | enum | `+0xa8` | +| `UID ` | `userId` | int64 | `+0x30` | +| `USTP` | `userSessionType` | `Blaze::UserSessionType` (enum) | `+0xc0` | +| `XREF` | `extId` | uint64 | `+0xa0` | + +`ALOC` (`accountLocale`) is a packed uint32 locale — use the same encoding the client sent in +`ClientData.LANG` in the PreAuth request, i.e. echo it back rather than inventing one. `NASP` +(`personaNamespace`) should match the `NASP` you returned in `PreAuthResponse`. + +### Related UserSessions types (for the rest of the session bring-up) + +``` +Blaze::UserSessionLogoutInfo (0x14486f970, 2) + BID blazeId int64 + USTP userSessionType enum + +Blaze::SessionInfo (0x14486ffa0, 5) + BUID blazeUserId int64 DSNM displayName string + KEY sessionKey string MAIL email string + UID userId int64 + +Blaze::UserIdentification (0x14486ebc0, 9) + AID accountId int64 ALOC accountLocale uint32 + EXBB externalBlob blob EXID externalId uint64 + ID blazeId int64 NAME name string + NASP personaNamespace string ORIG originPersonaId uint64 + PIDI pidId int64 + +Blaze::UserData (0x1448706b0, 3) + EDAT extendedData Blaze::UserSessionExtendedData + FLGS statusFlags Blaze::UserDataFlags (bitfield) + USER userInfo Blaze::UserIdentification + +Blaze::UserStatus (0x14486ed10, 2) + FLGS statusFlags Blaze::UserDataFlags ID blazeId int64 + +Blaze::UserSessionExtendedData (0x144870390, 12) + ADDR address Blaze::NetworkAddress (union, 5 cases) + BPS bestPingSiteAlias string + CTY country string + CVAR clientData variable + DMAP dataMap map + HWFG hardwareFlags Blaze::HardwareFlags (bitfield) + ISP iSP string + PSLM latencyList list + QDAT qosData Blaze::Util::NetworkQosData + TZ timeZone string + UATT userInfoAttribute uint64 + ULST blazeObjectIdList list + +Blaze::UserSessionExtendedDataUpdate (0x1448703e0, 3) + DATA extendedData Blaze::UserSessionExtendedData + SUBS subscribed bool + USID userId int64 +``` + +--- + +## 6. Authentication error codes — **complete** + +Blaze error codes for this component are packed `(errorNumber << 16) | 0x0001`. Recovered by calling +`getErrorName` (`0x146e0ec20`, clean code) in-process across the range; the static `cmp edx,0xNN0001` +chain at `0x146e0ec20`+ corroborates. + +| # | Name | # | Name | +|---|---|---|---| +| 1 | `AUTH_ERR_INVALID_TOKEN` | 72 | `AUTH_ERR_TOO_MANY_ENTITLEMENTS` | +| 2 | `AUTH_ERR_TOS_REQUIRED` | 73 | `AUTH_ERR_PAGESIZE_ZERO` | +| 6 | `AUTH_ERR_INVALID_SANDBOX_ID` | 74 | `AUTH_ERR_ENTITLEMENT_TAG_REQUIRED` | +| 10 | `AUTH_ERR_INVALID_COUNTRY` | 75 | `AUTH_ERR_PAGENO_ZERO` | +| 11 | `AUTH_ERR_INVALID_USER` | 76 | `AUTH_ERR_MODIFIED_STATUS_INVALID` | +| 12 | `AUTH_ERR_INVALID_PASSWORD` | 77 | `AUTH_ERR_USECOUNT_INCREMENT` | +| 14 | `AUTH_ERR_EXPIRED_TOKEN` | 78 | `AUTH_ERR_TERMINATION_INVALID` | +| 16 | `AUTH_ERR_TOO_YOUNG` | 79 | `AUTH_ERR_UNKNOWN_ENTITLEMENT` | +| 17 | `AUTH_ERR_NO_ACCOUNT` | 80 | `AUTH_ERR_EXCEEDS_PSU_LIMIT` | +| 19 | `AUTH_ERR_PERSONA_INACTIVE` | 81 | `AUTH_ERR_OPTIN_NAME_REQUIRED` | +| 20 | `AUTH_ERR_INVALID_PMAIL` | 82 | `AUTH_ERR_INVALID_OPTIN` | +| 21 | `AUTH_ERR_INVALID_FIELD` | 83 | `AUTH_ERR_OPTIN_MISMATCH` | +| 22 | `AUTH_ERR_INVALID_EMAIL` | 84 | `AUTH_ERR_NO_SUCH_OPTIN` | +| 23 | `AUTH_ERR_INVALID_STATUS` | 85 | `AUTH_ERR_AUTHID_REQUIRED` | +| 32 | `AUTH_ERR_PERSONA_BANNED` | 86 | `AUTH_ERR_PERSONA_EXTREFID_REQUIRED` | +| 33 | `AUTH_ERR_INVALID_PERSONA` | 87 | `AUTH_ERR_SOURCE_REQUIRED` | +| 34 | `AUTH_ERR_CURRENT_PASSWORD_REQUIRED` | 88 | `AUTH_ERR_APPLICATION_REQUIRED` | +| 41 | `AUTH_ERR_DEACTIVATED` | 89 | `AUTH_ERR_TOKEN_REQUIRED` | +| 43 | `AUTH_ERR_BANNED` | 90 | `AUTH_ERR_PARAMETER_TOO_LENGTH` | +| 44 | `AUTH_ERR_DISABLED` | 91 | `AUTH_ERR_NO_SUCH_PERSONA_REFERENCE` | +| 50 | `AUTH_ERR_NEED_PCCDKEY` | 93 | `AUTH_ERR_INVALID_SOURCE` | +| 51 | `AUTH_ERR_CODE_ALREADY_USED` | 94 | `AUTH_ERR_NO_SUCH_AUTH_DATA` | +| 52 | `AUTH_ERR_INVALID_CODE` | 101 | `AUTH_ERR_USER_INACTIVE` | +| 53 | `AUTH_ERR_CODE_ALREADY_DISABLED` | 102 | `AUTH_ERR_UNEXPECTED_ACTIVATION` | +| 54 | `AUTH_ERR_NO_ASSOCIATED_PRODUCT` | 103 | `AUTH_ERR_NAME_MISMATCH` | +| 55 | `AUTH_ERR_INVALID_MAPPING_ERROR` | 105 | `AUTH_ERR_INVALID_NAMESPACE` | +| 56 | `AUTH_ERR_NO_SUCH_GROUP_NAME` | 198 | `AUTH_ERR_FIELD_MIN_LOWER_CHARS` | +| 57 | `AUTH_ERR_MISSING_PERSONAID` | 199 | `AUTH_ERR_FIELD_MIN_UPPER_CHARS` | +| 58 | `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` | 200 | `AUTH_ERR_FIELD_MIN_DIGITS` | +| 59 | `AUTH_ERR_WHITELIST` | 201 | `AUTH_ERR_FIELD_INVALID_CHARS` | +| 60 | `AUTH_ERR_LINK_PERSONA` | 202 | `AUTH_ERR_FIELD_TOO_SHORT` | +| 61 | `AUTH_ERR_NO_SUCH_GROUP` | 203 | `AUTH_ERR_FIELD_TOO_LONG` | +| 63 | `AUTH_ERR_NO_SUCH_ENTITLEMENT` | 204 | `AUTH_ERR_FIELD_MUST_BEGIN_WITH_LETTER` | +| 64 | `AUTH_ERR_GROUP_NAME_DOES_NOT_MATCH` | 205 | `AUTH_ERR_FIELD_MISSING` | +| 66 | `AUTH_ERR_USECOUNT_ZERO` | 206 | `AUTH_ERR_FIELD_INVALID` | +| 67 | `AUTH_ERR_ENTITLEMETNTAG_EMPTY` *(sic)* | 207 | `AUTH_ERR_FIELD_NOT_ALLOWED` | +| 70 | `AUTH_ERR_GROUPNAME_REQUIRED` | 208 | `AUTH_ERR_FIELD_NEEDS_SPECIAL_CHARS` | +| 71 | `AUTH_ERR_GROUPNAME_INVALID` | 209 | `AUTH_ERR_FIELD_ALREADY_EXISTS` | +| | | 210 | `AUTH_ERR_FIELD_NEEDS_CONSENT` | +| | | 211 | `AUTH_ERR_FIELD_TOO_YOUNG` | +| | | 212 | `AUTH_ERR_ASSOCIATION_TOO_YOUNG` | + +--- + +## 7. Supporting types for the post-login flow + +``` +Blaze::Authentication::GetAuthTokenResponse (0x14487d080, 1) + AUTH authToken string + +Blaze::Authentication::GetUserAccessTokenResponse (0x14487db80, 2) + ATOK accessToken string CLID clientId string + +Blaze::Authentication::ListPersonasResponse (0x14487d210, 1) + PINF list list + +Blaze::Authentication::GetPersonaResponse (0x14487d1c0, 2) + PINF personaInfo Blaze::Authentication::PersonaInfo + UID userId int64 + +Blaze::Authentication::PersonaInfo (0x14487c7c0, 7) + DSNM displayName string DTCR dateCreated string + LADT lastAuthenticated uint32 NSNM nameSpaceName string + PID personaId int64 STAS status enum + STRC statusReasonCode enum + +Blaze::Authentication::AccountInfo (0x14487c810, 16) + AMU anonymousUser:bool ASRC authenticationSource:string + CO country:string DOB dOB:string + DTCR dateCreated:string GOPT globalOptin:int8 + LATH lastAuth:string LN language:string + MAIL email:string PML parentalEmail:string + RC reasonCode:enum STAS status:enum + STAT emailStatus:enum TPOT thirdPartyOptin:int8 + UDU underageUser:bool UID userId:int64 + +Blaze::Authentication::Entitlements (0x14487d4e0, 1) + NLST entitlements list + +Blaze::Authentication::Entitlement (0x14487d490, 16) + DEVI deviceUri:string GDAY grantDate:string GNAM groupName:string + ID id:uint64 ISCO isConsumable:bool PID personaId:int64 + PJID projectId:string PRCA productCatalog:enum PRID productId:string + STAT status:enum STRC statusReasonCode:enum TAG entitlementTag:string + TDAY terminationDate:string TYPE entitlementType:enum + UCNT useCount:uint32 VER version:uint32 + +Blaze::Authentication::UserProfileInfo (0x14487c860, 8) + CITY city:string CTRY country:string STAT state:string STRT street:string + ZIP zipCode:string GNDR gender:enum UID userId:int64 + ELEM profileInfoElementsByCategory: map> + (the binary's own map<> name string is written value-first — read it backwards, + same caveat as documented for QoS maps in the preAuth doc) +``` + +When `listUserEntitlements2` (`0x001D`) does eventually get called, the entitlement the retail exe +requires is `TAG = "ONLINE_ACCESS"` with `PRID` tied to offer id `1027460`, `STAT` = active, and +`PID` = `33068179`. `AUTH_ERR_NO_SUCH_ENTITLEMENT` (63) and `AUTH_ERR_ENTITLEMENT_TAG_REQUIRED` (74) +are the failure modes. + +--- + +## 8. Recommended server behaviour, in order + +1. **Fix layer 1 first.** Nothing in this document is reachable until the in-process Origin/LSX stub + on `127.0.0.1:4216` answers `OriginIsOnline` / `GetInternetConnectedState` with *online* and + `GetAuthCode` with a non-empty code. Right now the client's `logout` at cmd `0x0046` is the + observable proof that it never got one. This is the single highest-value next step. +2. Keep replying to `Util::fetchClientConfig` (9/1) — an empty `FetchConfigResponse { CONF: {} }` is + structurally valid, but see the preAuth doc for the tunables the client reads out of `CONF`. +3. When `Authentication::login` (`0x0001`/`0x000A`) finally arrives, reply `LoginResponse` per §3 + with the §4 field values, keyed to persona `33068179` / `CAGE`. +4. Immediately after the login REPLY, push a **NOTIFICATION** frame (msgType=2) on component + `0x7802`, notification id `0x0008` (`UserAuthenticated`), payload `Blaze::UserSessionLoginInfo` + per §5 — same session key, same BlazeId, same persona triple. +5. Expect `Util::postAuth` (9/8) next; `PostAuthResponse` is documented in the preAuth doc. +6. Answer `logout` (`0x0046`) with an empty REPLY — which is already correct. + +--- + +## 9. Tooling produced (all in the session scratchpad) + +| Script | Purpose | +|---|---| +| `stubscan.py` | list every `lea rax,[rip+str]; ret` name stub in a VA window, in address order | +| `authcmd.py` | locate RPC-name strings and every reference to them | +| `refscan.py` | find all references (rip-rel lea / qword / image-relative u32) into a VA window | +| `heapref.py` | same, but across *every* readable region including the heap | +| `cmdinfo.py` | scan the metadata arena for `{u16 component, u16 command, …, name}` REST structs | +| `comptabs.py` | enumerate Blaze `ComponentDescription` 7-pointer tables | +| `qdump.py` / `pooldump.py` / `dumpregion.py` | annotated qword dump / string-pool dump / raw region dump | +| `jt.py` | decode an MSVC image-relative jump table of `lea/ret` name stubs | +| `rpcmap.py` / `factref.py` / `vt2.py` | descriptor-getter and factory xref mapping (all three came up empty for Authentication — recorded as negative results) | +| `authcmds*.gdb`, `autherr.gdb` | in-process `getCommandName` / `getErrorName` sweeps | + +Reused from the previous pass: `memtool.py`, `reflect2.py`, `strsearch.py`, `all_types.txt`. + +### Negative results worth recording + +- The 33-name Authentication command-name pool at `0x14389d690` is **completely unreferenced** — + no lea, no pointer, no RVA, anywhere in the process. Do not waste time looking for its xrefs. +- `Blaze::Authentication::LoginRequest`'s vtable (`0x14389ac68`) is referenced only by its own + factory/ctor/dtor. No game code constructs one statically — the login call site is inside + Denuvo-mutated code. +- Every `Blaze::Authentication::*` type-descriptor getter has exactly one caller: the TDF factory + registration run at `0x1451cd0xx`. There is no per-command descriptor table to mine. diff --git a/fifa17-recon/tools/auth_statemachine.md b/fifa17-recon/tools/auth_statemachine.md new file mode 100644 index 0000000..f4d2d17 --- /dev/null +++ b/fifa17-recon/tools/auth_statemachine.md @@ -0,0 +1,274 @@ +# FIFA 17 offline auth — error trace & state machine + +Clean-room. All facts below come from (a) the running `FIFA17.exe` we own (PID 19517, +`/proc/PID/mem`, ptrace_scope=0), (b) the Steampunks Origin stub on disk/in memory, and +(c) our own Blaze session log. **No leak material used.** + +Module base `0x140000000` (Wine maps the PE flat). + +--- + +## 0. Verdict (answer to the CRITICAL question) + +**The blocker is LAYER 1 — the ORIGIN / LSX online-state check. It sits in front of Blaze +auth as a hard gate, and Blaze login is never even attempted.** + +Three independent proofs: + +1. **The frontend flow graph gates Blaze login behind the Origin check.** Recovered verbatim + from the live FeFlow JSON (`0x41bc5df4`): + + ```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" } }, + { "name":"CheckFUTRosters", "type":"external", "file":"/checkFUTRostersFlow.nav", + "outputs":{ "advance":"postFUTBlazeLogin", "back":"mainMenu" } }, + { "name":"postFUTBlazeLogin", ... "transitions":[{"event":"advanceRequest","targets":["futFlow"]}] } + ``` + + `origin.nav` has exactly two exits. Only `OriginIsOnlineTrue` reaches `startFutBlazeLogin`; + anything else is `quit` → `mainMenu`. **Blaze is strictly downstream of Origin.** + +2. **The Origin stub is physically incapable of reporting online.** Unpacked image of + `/mnt/games/FIFA 17/stp-origin_emu.dll` at `0x6ffffc931000-0x6ffffc93d000` contains its + *entire* response repertoire — 11 templates + 1 event: + + ``` + 0x6ffffc9353b0 + 0x6ffffc9351d0 + 0x6ffffc9352b0 + 0x6ffffc935470 ...GetConfigResponse Config="false"... + 0x6ffffc935050/0x935170/0x935530 ...GetSettingResponse Setting="%s" / "false" / "production"... + 0x6ffffc9350b0/0x9354d0 ...GetGameInfoResponse GameInfo="" / "false"... + 0x6ffffc935230 ...IsProgressiveInstallationAvailableResponse ItemId="" Available="false"... + 0x6ffffc935410 + 0x6ffffc935590 + ``` + + `connected="0"` is a **hardcoded literal — there is no `connected="1"` variant anywhere in + the module**. There is likewise **no `AuthCodeResponse`, no `GetAuthTokenResponse`, no + `QueryEntitlementsResponse`** template. Its only imports are `GetPrivateProfileIntA/StringA` + (reads `stp-origin_emu.ini`), `SetEnvironmentVariableA`, `CreateThread`, `getaddrinfo`, + `sprintf_s`, `sscanf_s`, `strstr`. It is an offline activation stub, not an Origin emulator. + +3. **The client never asked for an auth code.** Scanning all of live memory for LSX frames + finds `GetInternetConnectedState`, `GetGameInfo`, `GetSetting`, `GetProfile`, + `IsProgressiveInstallationAvailable` requests/responses — but **zero `GetAuthCode` + requests**. It stops at the online check. + +--- + +## 1. The error string → exact trigger + +The message is a **localization entry**, not a code literal (all copies live in heap/loc data, +none in the module), so it is reached by loc key: + +| Item | VA | Value | +|---|---|---| +| loc key | `0x143b16360` | `TXT_ORIGIN_OFFLINE_POPUP_TEXT` | +| popup spec | `0x143b16380` | `ORIGIN_OFFLINE_POPUP\|%s\|Ok\|Ok` | + +Both have **exactly one xref each**, inside one function: + +``` +0x147c3c050 <- function entry (the FeFlow action "onlineLoginPopupShow" handler) +0x147c3c09a call 0x146f38aa0 ; -> returns g_originOnline +0x147c3c09f test al,al +0x147c3c0a1 jne 0x147c3c1b3 ; ONLINE -> skip, fall through to normal login popups + ... ; OFFLINE -> build the Origin popup: +0x147c3c108 lea r8, [0x143b16360] ; "TXT_ORIGIN_OFFLINE_POPUP_TEXT" +0x147c3c121 lea rdx,[0x143b16380] ; "ORIGIN_OFFLINE_POPUP|%s|Ok|Ok" +0x147c3c147 lea r8, [0x1438feea8] ; "ShowPopup" +0x147c3c14e lea rdx,[0x1438fc240] ; "_global" +``` + +The predicate is a bare global read: + +``` +0x146f38aa0 call 0x147199590 ; singleton getter: mov rax,[0x144b86bf0]; ret (no refresh) +0x146f38aa9 movzx eax, BYTE PTR [0x1443337f8] ; <<< g_originOnline +0x146f38ab4 ret +``` + +`g_originOnline @ 0x1443337f8` has exactly **one writer** — the LSX +`GetInternetConnectedState` callback: + +``` +0x146f1e6b0 sub rsp,0x58 +0x146f1e6b4 test r8,r8 ; je out ; r8 = result struct +0x146f1e6d0 movzx eax, BYTE PTR [r8] ; the parsed `connected` attribute +0x146f1e6d9 mov BYTE PTR [0x1443337f8], al ; <<< store +0x146f1e6e8 lea rdx,[0x1438fe758] ; "FE::FIFA::OriginOnlineEvent" +0x146f1e737 call [r10+0x48] ; broadcast the event +``` + +**Chain:** LSX `GetInternetConnectedState` → callback `0x146f1e6b0` → `g_originOnline` + +`FE::FIFA::OriginOnlineEvent` → FeFlow events → `origin.nav` output → and, on the popup path, +`onlineLoginPopupShow` → `ORIGIN_OFFLINE_POPUP` / `TXT_ORIGIN_OFFLINE_POPUP_TEXT`. + +### FeFlow IDs (from the registration table at `0x147de8480`+) + +| Name | VA | FeFlow ID | +|---|---|---| +| `onlineLoginPopupHide` | `0x143b4c918` | `0x27a9` | +| `onlineLoginPopupShow` | `0x143b4c900` | `0x27aa` | +| `checkOriginConnected` | `0x143b4cb40` | `0x27e1` | +| `OriginIsOnline` | `0x143b4cb58` | `0x27e2` | +| `OriginIsOffline` | `0x143b4cb68` | `0x27e3` | + +### Caveat worth knowing (honest reading of the live state) + +`g_originOnline` currently reads **`0x01`**, not 0. The stub answered the *first* +`GetInternetConnectedState` (id 17) with a well-formed `connected="0"`, but answered the +**later ones (ids 19–22) with a generic `ErrorSuccess Code="0"`** — a *type-mismatched* reply +to a `GetInternetConnectedState` request (observed verbatim at `0x28793008`/`0x28793668`/ +`0x28793728`…). The client's Origin SDK finds no `connected` attribute to parse, so the byte +it stores is stale/garbage. Net effect: **the online flag is non-deterministic garbage, never +a genuine "online".** This is consistent with the flow still failing while the byte happens to +read 1, and it means fixing the LSX layer must make *every* `GetInternetConnectedState` return +a well-formed `connected="1"`, not just the first. + +--- + +## 2. Order of operations the client actually performs + +**Observed LSX order (boot, on 127.0.0.1:4216, in-process):** +`IsProgressiveInstallationAvailable` (id 9) → `GetSetting` (→"production") → `GetGameInfo` +(`GameInfoId="FREETRIAL"`, id 15) → `GetGameInfo` (id 16) → **`GetInternetConnectedState` +(id 17) → `connected="0"`** → `GetProfile` (→ `Persona="CAGE"`) → repeated +`GetInternetConnectedState` polls (ids 19–22) → all answered `ErrorSuccess`. +**`GetAuthCode` is never reached.** + +**FeFlow order:** +`mainMenu` → `launchFUTFlow` (`/online/origin.nav`, action `checkOriginConnected` 0x27e1) +→ **[GATE]** `OriginIsOnline` 0x27e2 → output `OriginIsOnlineTrue` +→ `futBlazeLogin` (`/online/onlineLoginFlow.nav`, entry state `startLoginWithoutMultiplayerCheck`, + which fires `sendScreenEvent ["OnlineLogin","0"]`) +→ on success event `loginSuccess` → `TrialWelcomeCheck` → `CheckFUTRosters` +→ `advance` → `postFUTBlazeLogin` → `futFlow` (`/fut/futFlow.nav`). + +Failure branches inside `onlineLoginFlow.nav`: `evt_onlineLoginFailurePopup` / +`evt_onlineBootLoginFailurePopup` → `onlineFailureLoginPopup` → `processLoginFailure` +→ `loginFail` → `mainMenu`. Every one of those popups renders via the C++ action +`onlineLoginPopupShow` — i.e. the same function that prints the Origin-offline text. + +**Observed Blaze order (our session log):** `Util::preAuth` (9/0x07) → `Util::ping` (9/0x02) +→ `Util::fetchClientConfig` (9/0x01) ×6 for `OSDK_CORE`, `OSDK_CLIENT`, `OSDK_NUCLEUS`, +`OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_XMS_ABUSE_REPORTING` → `Authentication` (1/0x46, +empty payload) → reconnect loop. **`Authentication::login` is never sent** — consistent with the +Origin gate blocking upstream. + +--- + +## 3. What makes it stop looping — the notification it waits on + +Recovered the **UserSessions notification name table** (jump table `0x141b03f70`, +switch at `0x146de19b1`, ids 1-based): + +| ID | Notification | +|---|---| +| `0x01` | `UserSessionExtendedDataUpdate` | +| `0x02` | `UserAdded` | +| `0x03` | `UserRemoved` | +| `0x05` | `UserUpdated` | +| **`0x08`** | **`UserAuthenticated`** | +| `0x09` | `UserUnauthenticated` | +| `0x0c` | `ServerDraining` | + +**`UserAuthenticated` (notification `0x08`) is the signal that flips the session to +authenticated.** Expect to also need `UserAdded` (0x02) and +`UserSessionExtendedDataUpdate` (0x01) so the local user object is populated. + +### Util component (0x0009) — full command table, recovered + +Jump table `0x141b17af4`, switch `0x146df6dd5`. This *validates the whole technique*: it +matches our observed traffic exactly (preAuth=0x07, ping=0x02, fetchClientConfig=0x01). + +| ID | Command | | ID | Command | +|---|---|---|---|---| +| `0x01` | `fetchClientConfig` | | `0x0f` | `userSettingsLoadMultiple` | +| `0x02` | `ping` | | `0x14` | `filterForProfanity` | +| `0x03` | `setClientData` | | `0x15` | `fetchQosConfig` | +| `0x04` | `localizeStrings` | | `0x16` | `setClientMetrics` | +| `0x05` | `getTelemetryServer` | | `0x17` | `setConnectionState` | +| `0x06` | `getTickerServer` | | `0x19` | `getUserOptions` | +| `0x07` | `preAuth` | | `0x1a` | `setUserOptions` | +| **`0x08`** | **`postAuth`** | | `0x1b` | `suspendUserPing` | +| `0x0a` | `userSettingsLoad` | | **`0x1c`** | **`setClientState`** | +| `0x0b` | `userSettingsSave` | | `0x0e` | `deleteUserSettings` | +| `0x0c` | `userSettingsLoadAll` | | | | + +### Unresolved: Authentication (0x0001) command `0x46` + +The Authentication component's `getCommandName` name table is **not compiled into this +binary** (I scanned every MSVC jump-table switch in the module, both the dword-index and +byte-index forms — 13 tables total; Util, UserSessions, Stats, Messaging, AssociationLists, +Tournaments, GameReporting, OSDK are present, Authentication is not). So `0x46` cannot be named +by table lookup. What *is* known: the client sends it with an **empty payload**, immediately +after the six `fetchClientConfig` calls and **before** any login, and accepts our empty reply +without erroring. Available Authentication TDF types are catalogued in +`scratchpad/all_types.txt` (`Blaze::Authentication::*`, 290 entries incl. `ExpressLoginRequest`, +`GetAuthTokenResponse`, `Entitlements`, `AcceptLegalDocsRequest`, `CheckLegalDocRequest`). +To resolve it properly, use the descriptor-reflection method from +`fifa17-recon/tools/preauth_schema_reflection.md` against the handler that dispatches +component 1 replies. + +--- + +## 4. Concrete fix order + +**a4 (LSX / Origin) must be done first — it is the true first blocker.** Replace or shim the +Steampunks stub's LSX server on `127.0.0.1:4216` (it is in-process, both socket ends are +`FIFA17.exe`, so this means either patching `stp-origin_emu.dll`, or hooking its WS2_32 use, +or supplying our own LSX responder). Required, all consistent with +`stp-origin_emu.ini [Globals] PersonaId=33068179, PersonaName=CAGE, Language=en_US`: + +1. `GetInternetConnectedState` → `` — **for every + request id, not just the first.** (Client verb strings live at `0x14394dd40`; the + `connected` attribute name at `0x14394e0a8`.) +2. `GetProfile` → `GetProfileResponse` with `PersonaId="33068179"`, `UserId="33068179"`, + `Persona="CAGE"` — mismatches here trip `AUTH_ERR_INVALID_PERSONA` / + `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` / `AUTH_ERR_PERSONA_NOT_FOUND` later. +3. `GetAuthCode` → `AuthCodeResponse` (verb string at `0x14394dd30`) with a synthetic code — + **the stub implements nothing here today**; this is the token the game then presents to + Blaze `Authentication`. +4. `QueryEntitlements` → `QueryEntitlementsResponseT` carrying `OriginItemT` with the + `ONLINE_ACCESS` tag for offer/content id `1027460` (retail exe). Missing this yields + `AUTH_ERR_NO_SUCH_ENTITLEMENT` / `AUTH_ERR_ENTITLEMENT_TAG_REQUIRED`. +5. Keep `ChallengeResponse`/`ChallengeAccepted` working (the stub already handles it). + +Only once `origin.nav` emits `OriginIsOnlineTrue` does the client enter `futBlazeLogin`. + +**Then a1/a3 (Blaze), in this order:** + +1. `Util::preAuth` (9/0x07) — already accepted. +2. `Util::fetchClientConfig` (9/0x01) — return real config maps for `OSDK_CORE`, + `OSDK_CLIENT`, `OSDK_NUCLEUS`, `OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, + `OSDK_XMS_ABUSE_REPORTING` (currently empty — a known hole). +3. `Authentication` (1/0x46) — currently answered empty and tolerated; revisit after naming it. +4. `Authentication::login` with the Origin auth code from step a4.3 → reply must carry the + session key / persona consistent with PersonaId 33068179 / `CAGE`. +5. Push `UserSessions` notifications: `UserAdded` (0x02), + `UserSessionExtendedDataUpdate` (0x01), and **`UserAuthenticated` (0x08)** — this is the + one that makes the client consider itself logged in. +6. `Util::postAuth` (9/0x08), then `Util::setClientState` (9/0x1c) to enable online features. + +--- + +## Tooling produced (scratchpad, reusable) + +| File | Purpose | +|---|---| +| `hunt.py` | ASCII+UTF-16 keyword search over all live memory | +| `dumprange.py` | printable-string dump of an arbitrary VA range | +| `modstrings.py` / `modstrings.txt` | full module string index (930 795 strings) — grep instead of re-scanning | +| `xrefs2.py` | fast numpy rip-relative + abs-pointer xref finder | +| `switchtab.py` / `switchtab2.py` | recover MSVC jump-table switches (dword-index / byte-index) → Blaze command & notification name tables | +| `feflow.py` | locate + window-dump FeFlow nav JSON blobs | +| `lsxscan.py` | recover resident LSX frames | +| `alltabs.txt`, `origin_emu_strings.txt`, `clusters.json` | captured outputs | diff --git a/fifa17-recon/tools/auth_watch.py b/fifa17-recon/tools/auth_watch.py new file mode 100644 index 0000000..77f29dd --- /dev/null +++ b/fifa17-recon/tools/auth_watch.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Detached poller: watch the FirstPartyAuthTokenRetriever auth-request region for +ANY change (does FUT-entry ever enqueue an auth request?). + +DoTick @0x146f199c0 (read at rip 0x146f199e3) polls *[0x1448a3b20]+0x4e98+0x08 every +frame and always sees 0 -> never requests a token. If entering FUT enqueues a request, +one of these bytes changes. Pure /proc/mem reads (no ptrace) so it survives across turns. +Logs every change with a timestamp to /tmp/auth_watch.log. +""" +import glob, os, struct, time + +AUTHBLOCK_PP = 0x1448a3b20 +SLOT_OFF = 0x4e98 +SPAN = 0x40 +ORIGINMGR_PP = 0x1448acf50 +LOG = "/tmp/auth_watch.log" + +def log(m): + line = f"[{time.strftime('%H:%M:%S')}] {m}" + print(line, flush=True) + with open(LOG, "a") as f: f.write(line + "\n") + +def find_pid(): + for d in glob.glob('/proc/[0-9]*'): + try: + if open(d + '/comm').read().strip() == 'FIFA17.exe': + return int(os.path.basename(d)) + except Exception: + pass + return None + +def main(): + open(LOG, "w").close() + log("=== auth_watch start ===") + pid = None; f = None; last = None; last_flag = None + while True: + p = find_pid() + if p != pid: + pid = p; last = None; last_flag = None + if f: f.close(); f = None + if pid: + f = open(f"/proc/{pid}/mem", "rb") + log(f"FIFA pid={pid}") + if not pid: + time.sleep(0.2); continue + try: + f.seek(AUTHBLOCK_PP); ab = struct.unpack(' {flag}") + last_flag = flag + time.sleep(0.01) + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/blaze_responder.py b/fifa17-recon/tools/blaze_responder.py new file mode 100644 index 0000000..e44b490 --- /dev/null +++ b/fifa17-recon/tools/blaze_responder.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""FIFA17 Blaze redirector RESPONDER + second-hop Fire2 capture. +- TLS on 42127: answers POST /redirector/getServerInstance with a + pointing the client at 127.0.0.1:BLAZE_PORT (secure=0). +- Plain TCP on BLAZE_PORT: logs the client's second-hop Fire2/Heat2 handshake. +All XML schema is a clean-room best-guess from the client's own TDF field names; +iterate based on client reaction (re-POST = parse reject; connect on BLAZE_PORT = success). +""" +import socket, ssl, threading, time, binascii + +HOST="127.0.0.1"; REDIR_PORT=42127; BLAZE_PORT=42130 +BLAZE_IP_STR="127.0.0.1"; BLAZE_IP_U32=(127<<24)|1 # 2130706433 +LOG="/tmp/blaze_responder.log" + +def log(m): + line=f"[{time.strftime('%H:%M:%S')}] {m}" + print(line,flush=True) + open(LOG,"a").write(line+"\n") + +def build_response(): + # Confirmed schema (clean-room, MEC Catalyst): ServerInstanceInfo.address is a + # ServerAddress union -> Heat2 XML union =
...
. + # member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)}. + body=( + '\n' + '\n' + '\t
\n' + '\t\t\n' + f'\t\t\t{BLAZE_IP_STR}\n' + f'\t\t\t{BLAZE_IP_U32}\n' + f'\t\t\t{BLAZE_PORT}\n' + '\t\t\n' + '\t
\n' + '\t0\n' + '\t\n' + '\t0\n' + '
\n' + ) + b=body.encode() + hdr=(f"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n" + f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode() + return hdr+b + +ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain("redir_cert.pem","redir_key.pem") +ctx.minimum_version=ssl.TLSVersion.TLSv1 +ctx.set_ciphers("ALL:@SECLEVEL=0") + +def redir_handle(raw,addr): + try: + tls=ctx.wrap_socket(raw,server_side=True) + except ssl.SSLError as e: + log(f"REDIR REJECTED {addr}: {e}"); raw.close(); return + log(f"REDIR TLS-OK {addr} cipher={tls.cipher()[0]}") + try: + tls.settimeout(8) + req=b"" + while b"\r\n\r\n" not in req: + c=tls.recv(4096) + if not c: break + req+=c + # read body per content-length + if b"content-length:" in req.lower(): + hdr,_,rest=req.partition(b"\r\n\r\n") + cl=int([l.split(b":")[1] for l in hdr.split(b"\r\n") if l.lower().startswith(b"content-length")][0]) + while len(rest) {BLAZE_IP_STR}:{BLAZE_PORT}") + time.sleep(0.3) + tls.close() + except Exception as e: + log(f"REDIR ERR {addr}: {e}") + +def blaze_handle(raw,addr): + log(f"*** BLAZE 2nd-HOP CONNECT from {addr} (client accepted our redirect!) ***") + try: + raw.settimeout(8) + blob=b"" + while len(blob)<65536: + c=raw.recv(4096) + if not c: break + blob+=c + # Fire2 frames are short; log as we go + if len(blob)>=16 and len(c)<4096: break + if blob: + fn=f"/tmp/blaze_fire2_{addr[1]}.bin" + open(fn,"wb").write(blob) + log(f"BLAZE FIRE2 {len(blob)}B -> {fn}") + log("HEX:\n"+"\n".join(f" {i:04x}: {binascii.hexlify(blob[i:i+16]).decode()}" for i in range(0,min(len(blob),192),16))) + else: + log(f"BLAZE connect but no bytes from {addr}") + raw.close() + except Exception as e: + log(f"BLAZE ERR {addr}: {e}") + +def serve(port,handler,name): + s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) + s.bind((HOST,port)); s.listen(16) + log(f"{name} listening on {HOST}:{port}") + while True: + c,a=s.accept() + threading.Thread(target=handler,args=(c,a),daemon=True).start() + +log("=== RESPONDER START ===") +threading.Thread(target=serve,args=(BLAZE_PORT,blaze_handle,"BLAZE"),daemon=True).start() +serve(REDIR_PORT,redir_handle,"REDIR") diff --git a/fifa17-recon/tools/blaze_responder_v2.py b/fifa17-recon/tools/blaze_responder_v2.py new file mode 100644 index 0000000..004e91b --- /dev/null +++ b/fifa17-recon/tools/blaze_responder_v2.py @@ -0,0 +1,575 @@ +#!/usr/bin/env python3 +"""FIFA17 Blaze redirector + SESSION SERVER (v2). + +Two listeners: + + * TLS on 42127 -- the redirector. Answers POST /redirector/getServerInstance + with a pointing the client at 127.0.0.1:BLAZE_PORT + (secure=0). UNCHANGED from blaze_responder.py -- it already works. + + * Plain TCP on 42130 -- the Blaze session server. Properly frames Fire2, + decodes the Heat2 TDF body, logs everything, and ANSWERS: + Util(0x0009)/preAuth(0x0007) -> PreAuthResponse (the current gate) + Util(0x0009)/ping(0x0002) -> PingResponse {STIM, TIME} + msgType PING(4) -> PING_REPLY(5), empty body + Everything else is logged in full and (optionally) answered with an empty + REPLY so the client is never left hanging. See the TODO block near + dispatch() for the next RPCs on the path. + +CLEAN ROOM. Schema comes from (a) FIFA17.exe's own in-process TDF reflection +metadata that we walked in live memory, (b) our own captured preAuth REQUEST, +and (c) independent third-party clean-room BlazeSDK-15.x reimplementations used +only to cross-check structure. No EA/FIFA leaked source was consulted. + +Run: python3 blaze_responder_v2.py (binds 42127 + 42130) +Log: /tmp/blaze_responder.log +""" + +from __future__ import annotations + +import binascii +import os +import socket +import ssl +import struct +import sys +import threading +import time +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import heat2 # noqa: E402 +from heat2 import INT, STRING, STRUCT, LIST, MAP, encode_tdf, decode_tdf # noqa: E402 + +# ------------------------------------------------------------------ config + +HOST = "127.0.0.1" +REDIR_PORT = 42127 +BLAZE_PORT = 42130 +BLAZE_IP_STR = "127.0.0.1" +BLAZE_IP_U32 = (127 << 24) | 1 # 2130706433 +LOG = "/tmp/blaze_responder.log" +HERE = os.path.dirname(os.path.abspath(__file__)) +CERT = os.path.join(HERE, "redir_cert.pem") +KEY = os.path.join(HERE, "redir_key.pem") + +# If True, any RPC we do not implement still gets an empty REPLY frame so the +# client's request does not time out. Flip to False to see which RPC the +# client is actually blocking on. +REPLY_EMPTY_TO_UNKNOWN = True + +# Dump every frame we receive to /tmp/blaze_rx___.bin +DUMP_FRAMES = True + +_log_lock = threading.Lock() + + +def log(m: str) -> None: + line = "[%s] %s" % (time.strftime("%H:%M:%S"), m) + with _log_lock: + print(line, flush=True) + with open(LOG, "a") as fh: + fh.write(line + "\n") + + +def hexdump(b: bytes, limit: int = 512) -> str: + out = [] + for i in range(0, min(len(b), limit), 16): + chunk = b[i:i + 16] + txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk) + out.append(" %04x: %-47s %s" + % (i, binascii.hexlify(chunk, " ").decode(), txt)) + if len(b) > limit: + out.append(" ... (%d more bytes)" % (len(b) - limit)) + return "\n".join(out) + + +# ------------------------------------------------------------------ Fire2 +# +# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame / +# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them): +# +# [0:4] u32 payload length +# [4:6] u16 metadata length +# [6:8] u16 component +# [8:10] u16 command +# [10:13] u24 msgNum <- 3 bytes; what we once read as "msgType" +# [13] u8 (msgType << 5) | (userIndex & 0x1F) +# [14] u8 options +# [15] u8 reserved +# wire = header(16) || metadata || payload +# +# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame). + +FIRE2_HDR = 16 + +MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6) +MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION", + 3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"} + +COMP_AUTH = 0x0001 +COMP_GAMEMANAGER = 0x0004 +COMP_REDIRECTOR = 0x0005 +COMP_STATS = 0x0007 +COMP_UTIL = 0x0009 +COMP_MESSAGING = 0x000F +COMP_ASSOCLISTS = 0x0019 +COMP_GAMEREPORTING = 0x001C +COMP_USERSESSIONS = 0x7802 + +CMD_FETCHCLIENTCONFIG = 0x0001 +CMD_PING = 0x0002 +CMD_PREAUTH = 0x0007 +CMD_POSTAUTH = 0x0008 +CMD_SETCLIENTSTATE = 0x001C + +# Util command table recovered from the binary's getCommandName switch. +UTIL_CMDS = { + 0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData", + 0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer", + 0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad", + 0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll", + 0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser", + 0x14: "filterForProfanity", 0x15: "fetchQosConfig", + 0x16: "setClientMetrics", 0x17: "setConnectionState", + 0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing", + 0x1C: "setClientState", +} +COMP_NAMES = { + COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager", + COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util", + COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists", + COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions", +} + + +def rpc_name(component: int, command: int) -> str: + comp = COMP_NAMES.get(component, "Component:0x%04x" % component) + if component == COMP_UTIL: + cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command) + else: + cmd = "cmd:0x%04x" % command + return "%s::%s" % (comp, cmd) + + +def fire2(component: int, command: int, msg_num: int, msg_type: int, + payload: bytes = b"", metadata: bytes = b"", + user_index: int = 0, options: int = 0) -> bytes: + h = bytearray(16) + struct.pack_into(">I", h, 0, len(payload)) + struct.pack_into(">H", h, 4, len(metadata)) + struct.pack_into(">H", h, 6, component & 0xFFFF) + struct.pack_into(">H", h, 8, command & 0xFFFF) + h[10] = (msg_num >> 16) & 0xFF + h[11] = (msg_num >> 8) & 0xFF + h[12] = msg_num & 0xFF + h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F) + h[14] = options & 0xFF + h[15] = 0 + return bytes(h) + metadata + payload + + +def parse_fire2_header(buf: bytes) -> dict: + return dict( + payload_len=struct.unpack_from(">I", buf, 0)[0], + metadata_len=struct.unpack_from(">H", buf, 4)[0], + component=struct.unpack_from(">H", buf, 6)[0], + command=struct.unpack_from(">H", buf, 8)[0], + msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12], + msg_type=(buf[13] >> 5) & 0x07, + user_index=buf[13] & 0x1F, + options=buf[14], + reserved=buf[15], + ) + + +def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes: + """A Blaze reply echoes component/command/msgNum/userIndex verbatim and + only overwrites the msgType bits.""" + return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type, + payload, user_index=hdr["user_index"]) + + +# ------------------------------------------------------- PreAuthResponse +# +# Reconciled schema: reflection descriptor VA 0x144875600 (14 members) INTERSECT +# the independent clean-room emulators. Members are emitted in ascending +# packed-tag order; heat2.encode_tdf enforces that automatically. + +# EA numeric title id. NOT reverse engineered -- onPreAuthResponse only +# memcpy's ASRC/ESRC/RSRC, so any value is accepted here; Authentication +# (component 1) may care later. +TITLE_ID = "309111" + +# Nucleus client id. Plausible convention, not RE'd. +CLIENT_ID = "FIFA17-PC-SERVER-BLAZE" + +# Persona namespace. Client caps this field at 32 bytes. +PERSONA_NAMESPACE = "cem_ea_id" + +PLATFORM = "pc" +SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" # EA's real value ends in \n + +# Component ids recovered from each component's own notification dispatcher in +# FIFA17.exe. This is the client's view of "which components exist server +# side"; later components look themselves up in this list. +COMPONENT_IDS = [ + COMP_AUTH, # 1 Authentication + COMP_GAMEMANAGER, # 4 GameManager + COMP_REDIRECTOR, # 5 Redirector + COMP_STATS, # 7 Stats + COMP_UTIL, # 9 Util + COMP_MESSAGING, # 15 Messaging + COMP_ASSOCLISTS, # 25 AssociationLists + COMP_GAMEREPORTING, # 28 GameReporting + COMP_USERSESSIONS, # 30722 UserSessions +] + +# The request carried FCCR{CFID="BlazeSDK"}, i.e. an embedded fetchClientConfig +# for the "BlazeSDK" section -- so CONF.CONF is that section. These five keys +# are the ones ConnectionManager::onPreAuthResponse actually reads (verified by +# disassembly); every one has a fallback, so nothing here is strictly required. +# Time values are MICROSECONDS: the client divides by 1000 to get ms. +BLAZESDK_CONFIG = [ + ("connIdleTimeout", "90000000"), # 90 s + ("defaultRequestTimeout", "30000000"), # 30 s + ("enableQosBandwidthTest", "false"), # exact string "false" clears bit 1 + ("enableQosFirewallTest", "false"), # exact string "false" clears bit 0 + ("pingPeriod", "20000000"), # 20 s (default would be 15000 ms) +] + +# TODO(auth gate): the BlazeSDK section also carries the Nucleus endpoints +# nucleusConnect / nucleusConnectTrusted / nucleusPortal / nucleusProxy. +# Pointing those at a local HTTPS shim is our lever for offline login. +# Not emitted yet -- unread at preAuth, and wrong values may send the client +# at a real EA host during Authentication::login. + + +def qos_config() -> "OrderedDict": + """Blaze::QosConfigInfo -- 4 members per reflection (there is NO SVID in + FIFA17's descriptor, unlike Mirror's Edge Catalyst).""" + return OrderedDict([ + ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo + ("PSA", (STRING, "127.0.0.1")), # address + ("PSP", (INT, 17502)), # port + ]))), + ("LNP", (INT, 10)), # numLatencyProbes + ("LTPS", (MAP, (STRING, STRUCT, []))), # pingSiteInfoByAliasMap: EMPTY + ("TIME", (INT, 5000000)), # timeout, microseconds + ]) + + +def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict": + return OrderedDict([ + ("ASRC", (STRING, TITLE_ID)), # authenticationSource + ("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds + ("CLID", (STRING, CLIENT_ID)), # clientId + ("CONF", (STRUCT, OrderedDict([ # Util::FetchConfigResponse + ("CONF", (MAP, (STRING, STRING, list(BLAZESDK_CONFIG)))), + ]))), + ("ESRC", (STRING, TITLE_ID)), # entitlementSource + ("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN + ("MAID", (INT, 0)), # machineId + ("MINR", (INT, 0)), # underageSupported = false + ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace + ("PILD", (STRING, "")), # legalDocGameIdentifier + ("PLAT", (STRING, PLATFORM)), # platform + ("QOSS", (STRUCT, qos_config())), # qosSettings + ("RSRC", (STRING, TITLE_ID)), # registrationSource + ("SVER", (STRING, SERVER_VERSION)), # serverVersion + ]) + + +def ping_response_fields() -> "OrderedDict": + """Blaze 15.1.1.1.0+ reads STIM, 15.1.1.0.x reads TIME. FIFA17 reports + BSDK 15.1.1.3.0, so STIM is the live one -- but unknown tags are ignored, + so emit both and stay version-proof. (Tag order STIM < TIME is handled by + heat2's ascending-tag sort.)""" + now = int(time.time()) + return OrderedDict([("STIM", (INT, now)), ("TIME", (INT, now))]) + + +def extract_service_name(fields) -> str: + """PreAuthRequest.CDAT.SVCN -- echo it back as INST.""" + try: + cdat = fields.get("CDAT") + if cdat and cdat[0] == STRUCT: + svcn = cdat[1].get("SVCN") + if svcn and svcn[0] == STRING and svcn[1]: + return svcn[1] + except Exception: + pass + return "fifa-2017-pc" + + +# ------------------------------------------------------------------ dispatch + +def dispatch(hdr: dict, fields, raw_payload: bytes): + """-> bytes to send back, or None to stay silent.""" + comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"] + + # Transport-level PING frame (msgType 4) -- answer with PING_REPLY (5). + if mtype == PING: + log(" -> transport PING, answering PING_REPLY (empty)") + return reply_to(hdr, b"", msg_type=PING_REPLY) + + if mtype not in (MESSAGE, PING): + log(" -> msgType %s is not a request; not answering" + % MSGTYPE_NAME.get(mtype, mtype)) + return None + + if comp == COMP_UTIL and cmd == CMD_PREAUTH: + svcn = extract_service_name(fields) if fields is not None else "fifa-2017-pc" + resp = preauth_response_fields(service_name=svcn) + payload = encode_tdf(resp) + log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" + % (svcn, len(payload), heat2.dump(resp))) + return reply_to(hdr, payload) + + if comp == COMP_UTIL and cmd == CMD_PING: + resp = ping_response_fields() + log(" -> PingResponse %s" % dict((k, v[1]) for k, v in resp.items())) + return reply_to(hdr, encode_tdf(resp)) + + # ---------------------------------------------------------------- TODO + # Expected next RPCs on the FIFA17 login path (in order): + # + # 1. Util::fetchClientConfig (9/1) with FCCR/CFID="IdentityParams" + # -> FetchConfigResponse{CONF: map} carrying `display` and + # `redirect_uri`; this drives the Nucleus web login overlay. + # 2. Authentication::login (1/0x0A) with AUTH= + # -> plus server NOTIFICATION 0x7802/8 UserAuthenticated. + # 3. Util::postAuth (9/8) + # -> PostAuthResponse{TELE, TICK, UROP}; plus notifications + # 0x7802/5 and 0x7802/1|2 (UserExtendedData). + # 4. Util::setClientState (9/0x1C), Authentication::getAuthToken (1/0x24), + # AssociationLists::getLists (25/6), UserSessions::updateNetworkInfo + # (0x7802/0x14). + # + # Notifications are msgType=2 with msgNum=0 and are pushed unsolicited. + # Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED + # (three clean-room sources disagree: header[14:16] vs metadata ERRC vs + # payload CNTX/ERRC) -- do not emit one until it is verified. + # ---------------------------------------------------------------------- + + if REPLY_EMPTY_TO_UNKNOWN: + log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not " + "hang (all fields fall back to client-side defaults)" + % rpc_name(comp, cmd)) + return reply_to(hdr, b"") + + log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd)) + return None + + +# ------------------------------------------------------------- blaze server + +def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool: + """Fill `buf` to at least n bytes. False on clean EOF / short close.""" + while len(buf) < n: + try: + chunk = sock.recv(65536) + except socket.timeout: + return False + if not chunk: + return False + buf += chunk + return True + + +_frame_counter = [0] + + +def blaze_handle(raw: socket.socket, addr) -> None: + log("*** BLAZE CONNECT from %s ***" % (addr,)) + buf = bytearray() + raw.settimeout(300) + try: + while True: + if not recv_exactly(raw, FIRE2_HDR, buf): + break + hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR])) + total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"] + if hdr["payload_len"] > 4 * 1024 * 1024: + log("BLAZE %s: absurd payload_len %d, dropping connection\n%s" + % (addr, hdr["payload_len"], hexdump(bytes(buf[:64])))) + break + if not recv_exactly(raw, total, buf): + log("BLAZE %s: EOF mid-frame (want %d, have %d)" + % (addr, total, len(buf))) + break + + frame = bytes(buf[:total]) + del buf[:total] + metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]] + payload = frame[FIRE2_HDR + hdr["metadata_len"]:] + + _frame_counter[0] += 1 + n = _frame_counter[0] + log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x " + "meta=%dB payload=%dB" + % (n, rpc_name(hdr["component"], hdr["command"]), + MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), + hdr["msg_num"], hdr["user_index"], hdr["options"], + hdr["metadata_len"], hdr["payload_len"])) + log("RX #%d HEX:\n%s" % (n, hexdump(frame))) + if metadata: + log("RX #%d METADATA:\n%s" % (n, hexdump(metadata))) + if DUMP_FRAMES: + try: + fn = "/tmp/blaze_rx_%04x_%04x_%d.bin" % ( + hdr["component"], hdr["command"], n) + with open(fn, "wb") as fh: + fh.write(frame) + log("RX #%d saved -> %s" % (n, fn)) + except Exception as e: + log("RX #%d save failed: %s" % (n, e)) + + fields = None + if payload: + try: + fields = decode_tdf(payload) + log("RX #%d TDF:\n%s" % (n, heat2.dump(fields))) + except Exception as e: + log("RX #%d TDF DECODE FAILED: %s" % (n, e)) + else: + log("RX #%d TDF: (empty payload)" % n) + + try: + out = dispatch(hdr, fields, payload) + except Exception as e: + log("RX #%d DISPATCH ERROR: %r" % (n, e)) + out = None + + if out: + raw.sendall(out) + ohdr = parse_fire2_header(out) + log("TX #%d %s msgType=%s msgNum=%d %dB total (%d payload)" + % (n, rpc_name(ohdr["component"], ohdr["command"]), + MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), + ohdr["msg_num"], len(out), ohdr["payload_len"])) + log("TX #%d HEX:\n%s" % (n, hexdump(out, limit=1024))) + except ConnectionResetError: + log("BLAZE %s: connection reset by client" % (addr,)) + except Exception as e: + log("BLAZE %s ERR: %r" % (addr, e)) + finally: + try: + raw.close() + except Exception: + pass + log("BLAZE %s: closed" % (addr,)) + + +# --------------------------------------------------------- redirector (TLS) + +def build_redirect_response() -> bytes: + # Confirmed schema (clean-room, MEC Catalyst): ServerInstanceInfo.address is + # a ServerAddress union -> Heat2 XML union =
... + # member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)} + body = ( + '\n' + '\n' + '\t
\n' + '\t\t\n' + f'\t\t\t{BLAZE_IP_STR}\n' + f'\t\t\t{BLAZE_IP_U32}\n' + f'\t\t\t{BLAZE_PORT}\n' + '\t\t\n' + '\t
\n' + '\t0\n' + '\t\n' + '\t0\n' + '
\n' + ) + b = body.encode() + hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n" + f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode() + return hdr + b + + +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain(CERT, KEY) +ctx.minimum_version = ssl.TLSVersion.TLSv1 +ctx.set_ciphers("ALL:@SECLEVEL=0") + + +def redir_handle(raw: socket.socket, addr) -> None: + try: + tls = ctx.wrap_socket(raw, server_side=True) + except ssl.SSLError as e: + log("REDIR REJECTED %s: %s" % (addr, e)) + raw.close() + return + log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0])) + try: + tls.settimeout(8) + req = b"" + while b"\r\n\r\n" not in req: + c = tls.recv(4096) + if not c: + break + req += c + if b"content-length:" in req.lower(): + head, _, rest = req.partition(b"\r\n\r\n") + cl = int([l.split(b":")[1] for l in head.split(b"\r\n") + if l.lower().startswith(b"content-length")][0]) + while len(rest) < cl: + c = tls.recv(4096) + if not c: + break + rest += c + req = head + b"\r\n\r\n" + rest + line0 = req.split(b"\r\n", 1)[0].decode(errors="replace") + log("REDIR REQ %s: %s" % (addr, line0)) + resp = build_redirect_response() + tls.sendall(resp) + log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d" + % (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT)) + time.sleep(0.3) + tls.close() + except Exception as e: + log("REDIR ERR %s: %s" % (addr, e)) + + +# ------------------------------------------------------------------ serve + +def serve(port: int, handler, name: str) -> None: + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((HOST, port)) + s.listen(16) + log("%s listening on %s:%d" % (name, HOST, port)) + while True: + c, a = s.accept() + threading.Thread(target=handler, args=(c, a), daemon=True).start() + + +def _selftest() -> None: + """Sanity: build the preAuth reply and round-trip it through the decoder.""" + fields = preauth_response_fields() + payload = encode_tdf(fields) + frame = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, payload) + h = parse_fire2_header(frame) + assert h["component"] == COMP_UTIL and h["command"] == CMD_PREAUTH + assert h["msg_type"] == REPLY and h["payload_len"] == len(payload) + assert frame[13] == 0x20, frame[13] + back = decode_tdf(frame[16:]) + assert list(back.keys()) == ["ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST", + "MAID", "MINR", "NASP", "PILD", "PLAT", "QOSS", + "RSRC", "SVER"], list(back.keys()) + assert encode_tdf(back) == payload + print("selftest OK: preAuth reply = %d bytes (%d payload)" + % (len(frame), len(payload))) + print("header:", frame[:16].hex(" ")) + print(heat2.dump(fields)) + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + _selftest() + raise SystemExit(0) + log("=== RESPONDER v2 START (redir %d / blaze %d) ===" % (REDIR_PORT, BLAZE_PORT)) + threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"), + daemon=True).start() + serve(REDIR_PORT, redir_handle, "REDIR") diff --git a/fifa17-recon/tools/blaze_responder_v3.py b/fifa17-recon/tools/blaze_responder_v3.py new file mode 100644 index 0000000..75b3b5f --- /dev/null +++ b/fifa17-recon/tools/blaze_responder_v3.py @@ -0,0 +1,1387 @@ +#!/usr/bin/env python3 +"""FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication. + +WHAT IS NEW vs v2 +----------------- + * Util::fetchClientConfig (9/1) answered for real, per-CFID, with a proper + FetchConfigResponse{CONF: map}. Unknown CFID -> EMPTY MAP + (a present-but-empty CONF), never an empty frame. + * Authentication (component 0x0001): + login (1/0x0A) -> forged offline LoginResponse (persona 33068179/CAGE) + logout (1/0x46) -> empty REPLY, and logged as a FAILURE SIGNAL + getAuthToken (1/0x24), listUserEntitlements2 (1/0x1D) + * UserSessions (0x7802) NOTIFICATIONs pushed unsolicited: + 0x0008 UserAuthenticated (Blaze::UserSessionLoginInfo) + 0x0001 UserSessionExtendedDataUpdate + 0x0002 UserAdded (Blaze::UserData) + * Util::postAuth (9/8), setClientState (9/0x1C), userSettingsLoad (9/0x0A), + setClientMetrics (9/0x16), AssociationLists::getLists (25/6), + UserSessions::updateNetworkInfo (0x7802/0x14). + * Optional local Nucleus OAuth stub on 42131 serving POST /connect/token, with + nucleusConnect / nucleusConnectTrusted in the BlazeSDK config pointed at it. + * PingResponse now carries ONLY STIM (FIFA 17's PingResponse @0x144875560 has + exactly one member; v2's extra TIME was Mirror's-Edge-Catalyst's field). + +TWO-LAYER ORDERING -- READ THIS FIRST +------------------------------------- +This file is layer 2. Layer 1 is Origin/LSX on 127.0.0.1:4216. The client's +`origin.nav` gates FUT on `OriginIsOnlineTrue` and only then runs +`futBlazeLogin`; without a layer-1 auth code the client sends +`Authentication::logout` (1/0x46) instead of `login` and shows +"Unable to connect to the EA Servers ... log in to Origin in Online Mode". +Start `lsx_responder.py` (before FIFA) or apply `lsx_force_online.py` FIRST. +Receiving 1/0x46 here means layer 1 is still broken. + +CLEAN ROOM. Every TDF tag/type below comes from FIFA17.exe's own in-process TDF +reflection metadata that we walked in live memory, plus our own captured wire +bytes. Independent third-party clean-room BlazeSDK-15.x reimplementations were +consulted only to cross-check *structure*. No EA/FIFA leaked source was used. + +Run: python3 blaze_responder_v3.py (binds 42127 + 42130 [+ 42131]) +Selftest: python3 blaze_responder_v3.py --selftest +Log: /tmp/blaze_responder.log +Frames: /tmp/blaze_rx/ +""" + +from __future__ import annotations + +import binascii +import json +import os +import random +import socket +import ssl +import string +import struct +import sys +import threading +import time +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import heat2 # noqa: E402 +from heat2 import ( # noqa: E402 + INT, STRING, BLOB, STRUCT, LIST, MAP, encode_tdf, decode_tdf, +) + +# ================================================================== identity +# SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py. +# Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language). +# A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26), +# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND. + +PERSONA_ID = 33068179 +PERSONA_NAME = "CAGE" +USER_ID = 33068179 # blazeId / userId; same value keeps BUID==UID==PID +EXT_ID = 33068179 # XREF externalId +EMAIL = "cage@openfut.local" +PERSONA_NAMESPACE = "cem_ea_id" # must equal PreAuthResponse.NASP +CLIENT_PLATFORM = 4 # Blaze::ClientPlatformType -> pc +PERSONA_STATUS = 2 # PersonaStatus::Code -> ACTIVE (verified live: table 0x14487ad20, ACTIVE==2) +USER_SESSION_TYPE = 0 # Blaze::UserSessionType -> normal/console user +ACCOUNT_LOCALE_FALLBACK = 0x656E5553 # 'enUS'; overwritten by the client's own + # PreAuthRequest LANG/LOC when we see it. + +CONTENT_ID = "1027460" # FIFA 17 EA offer id (retail) +ENTITLEMENT_TAG = "ONLINE_ACCESS" # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe +ENTITLEMENT_GROUP = "FIFA17PC" + +TITLE_ID = "309111" +CLIENT_ID = "FIFA17-PC-SERVER-BLAZE" +PLATFORM = "pc" +SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" + +# ================================================================== config + +HOST = "127.0.0.1" +REDIR_PORT = 42127 +BLAZE_PORT = 42130 +NUCLEUS_PORT = 42131 +BLAZE_IP_STR = "127.0.0.1" +BLAZE_IP_U32 = (127 << 24) | 1 +LOG = "/tmp/blaze_responder.log" +RXDIR = "/tmp/blaze_rx" +HERE = os.path.dirname(os.path.abspath(__file__)) +CERT = os.path.join(HERE, "redir_cert.pem") +KEY = os.path.join(HERE, "redir_key.pem") + +# Serve a local OAuth stub and advertise it as nucleusConnect. The client's +# LoginStateMachineImpl builds "/connect/token", POSTs +# grant_type=client_credentials, and scrapes '"access_token" : "'. +NUCLEUS_STUB_ENABLED = True +EMIT_NUCLEUS_URLS = True +NUCLEUS_BASE = "http://%s:%d" % (HOST, NUCLEUS_PORT) + +# Any RPC we do not implement still gets an empty REPLY so the client's request +# never times out. Flip to False to find out what it truly blocks on. +REPLY_EMPTY_TO_UNKNOWN = True + +# Push the UserAuthenticated notification BEFORE writing the login reply +# (grid-blaze order) or after (pamplona order). Both are reported to work. +NOTIFY_BEFORE_LOGIN_REPLY = False + +DUMP_FRAMES = True + +_log_lock = threading.Lock() + + +def log(m: str) -> None: + line = "[%s] %s" % (time.strftime("%H:%M:%S"), m) + with _log_lock: + print(line, flush=True) + try: + with open(LOG, "a") as fh: + fh.write(line + "\n") + except Exception: + pass + + +def hexdump(b: bytes, limit: int = 512) -> str: + out = [] + for i in range(0, min(len(b), limit), 16): + chunk = b[i:i + 16] + txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk) + out.append(" %04x: %-47s %s" + % (i, binascii.hexlify(chunk, " ").decode(), txt)) + if len(b) > limit: + out.append(" ... (%d more bytes)" % (len(b) - limit)) + return "\n".join(out) + + +# ================================================================== Fire2 +# +# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame / +# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them): +# +# [0:4] u32 payload length +# [4:6] u16 metadata length +# [6:8] u16 component +# [8:10] u16 command (== notification id on NOTIFICATION) +# [10:13] u24 msgNum +# [13] u8 (msgType << 5) | (userIndex & 0x1F) +# [14] u8 options +# [15] u8 reserved +# wire = header(16) || metadata || payload +# +# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame). + +FIRE2_HDR = 16 + +MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6) +MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION", + 3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"} + +COMP_AUTH = 0x0001 +COMP_GAMEMANAGER = 0x0004 +COMP_REDIRECTOR = 0x0005 +COMP_STATS = 0x0007 +COMP_UTIL = 0x0009 +COMP_MESSAGING = 0x000F +COMP_ASSOCLISTS = 0x0019 +COMP_GAMEREPORTING = 0x001C +COMP_USERSESSIONS = 0x7802 + +# ---- Util (0x0009) command table, recovered from the binary's own +# getCommandName switch (jump table 0x141b17af4). +CMD_FETCHCLIENTCONFIG = 0x0001 +CMD_PING = 0x0002 +CMD_PREAUTH = 0x0007 +CMD_POSTAUTH = 0x0008 +CMD_USERSETTINGSLOAD = 0x000A +CMD_USERSETTINGSSAVE = 0x000B +CMD_SETCLIENTMETRICS = 0x0016 +CMD_SETCLIENTSTATE = 0x001C + +UTIL_CMDS = { + 0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData", + 0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer", + 0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad", + 0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll", + 0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser", + 0x14: "filterForProfanity", 0x15: "fetchQosConfig", + 0x16: "setClientMetrics", 0x17: "setConnectionState", + 0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing", + 0x1C: "setClientState", +} + +# ---- Authentication (0x0001) command table. Recovered by CALLING the client's +# own getCommandName (0x146e0d2a0) in-process over ids 1..320 -- the name +# pool is Denuvo-mutated and statically unrecoverable. Validated against +# Util (reproduced preAuth=7/ping=2/fetchClientConfig=1) and cross-checked +# against a static REST-binding struct (0x143896a80 -> trustedLogin=0x0B). +CMD_LOGIN = 0x000A +CMD_TRUSTEDLOGIN = 0x000B +CMD_LISTUSERENTITLEMENTS2 = 0x001D +CMD_GETAUTHTOKEN = 0x0024 +CMD_EXPRESSLOGIN = 0x003C +CMD_LOGOUT = 0x0046 # <-- the "we gave up" RPC, NOT a login +CMD_GETPERSONA = 0x005A +CMD_LISTPERSONAS = 0x0064 + +AUTH_CMDS = { + 0x0A: "login", 0x0B: "trustedLogin", 0x14: "updateAccount", + 0x15: "upgradeAccount", 0x1D: "listUserEntitlements2", 0x1E: "getAccount", + 0x1F: "grantEntitlement", 0x20: "listEntitlements", 0x22: "getUseCount", + 0x23: "decrementUseCount", 0x24: "getAuthToken", 0x26: "getPasswordRules", + 0x27: "grantEntitlement2", 0x2B: "modifyEntitlement2", 0x2C: "consumecode", + 0x2D: "passwordForgot", 0x2F: "getPrivacyPolicyContent", + 0x30: "listPersonaEntitlements2", 0x33: "checkAgeReq", 0x34: "getOptIn", + 0x35: "enableOptIn", 0x36: "disableOptIn", 0x3C: "expressLogin", + 0x46: "logout", 0x5A: "getPersona", 0x64: "listPersonas", + 0x65: "expressCreateAccount", 0xE6: "createWalUserSession", + 0xF1: "acceptLegalDocs", 0xF2: "getEmailOptInSettings", + 0xF6: "getTermsOfServiceContent", 0x104: "getOriginPersona", + 0x10E: "checkEmail", 0x118: "getPersonaNameSuggestions", 0x122: "guestLogin", +} + +# ---- UserSessions (0x7802). Commands and NOTIFICATIONS live in separate +# number spaces. Notification ids decoded statically from the client's +# own getNotificationName jump table at 0x141b03f70 (clean, unmutated). +NOTIFY_USER_EXTENDED_DATA_UPDATE = 0x0001 +NOTIFY_USER_ADDED = 0x0002 +NOTIFY_USER_REMOVED = 0x0003 +NOTIFY_USER_UPDATED = 0x0005 +NOTIFY_USER_AUTHENTICATED = 0x0008 +NOTIFY_USER_UNAUTHENTICATED = 0x0009 +NOTIFY_SERVER_DRAINING = 0x000C + +USERSESSIONS_NOTIFY_NAMES = { + 0x01: "UserSessionExtendedDataUpdate", 0x02: "UserAdded", + 0x03: "UserRemoved", 0x05: "UserUpdated", 0x08: "UserAuthenticated", + 0x09: "UserUnauthenticated", 0x0C: "ServerDraining", +} + +CMD_UPDATENETWORKINFO = 0x0014 # UserSessions command space +CMD_GETLISTS = 0x0006 # AssociationLists + +COMP_NAMES = { + COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager", + COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util", + COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists", + COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions", +} + + +def rpc_name(component: int, command: int, msg_type: int = MESSAGE) -> str: + comp = COMP_NAMES.get(component, "Component:0x%04x" % component) + if component == COMP_USERSESSIONS and msg_type == NOTIFICATION: + cmd = USERSESSIONS_NOTIFY_NAMES.get(command, "notify:0x%04x" % command) + return "%s::<%s>" % (comp, cmd) + if component == COMP_UTIL: + cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command) + elif component == COMP_AUTH: + cmd = AUTH_CMDS.get(command, "cmd:0x%04x" % command) + else: + cmd = "cmd:0x%04x" % command + return "%s::%s" % (comp, cmd) + + +def fire2(component: int, command: int, msg_num: int, msg_type: int, + payload: bytes = b"", metadata: bytes = b"", + user_index: int = 0, options: int = 0) -> bytes: + h = bytearray(16) + struct.pack_into(">I", h, 0, len(payload)) + struct.pack_into(">H", h, 4, len(metadata)) + struct.pack_into(">H", h, 6, component & 0xFFFF) + struct.pack_into(">H", h, 8, command & 0xFFFF) + h[10] = (msg_num >> 16) & 0xFF + h[11] = (msg_num >> 8) & 0xFF + h[12] = msg_num & 0xFF + h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F) + h[14] = options & 0xFF + h[15] = 0 + return bytes(h) + metadata + payload + + +def parse_fire2_header(buf: bytes) -> dict: + return dict( + payload_len=struct.unpack_from(">I", buf, 0)[0], + metadata_len=struct.unpack_from(">H", buf, 4)[0], + component=struct.unpack_from(">H", buf, 6)[0], + command=struct.unpack_from(">H", buf, 8)[0], + msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12], + msg_type=(buf[13] >> 5) & 0x07, + user_index=buf[13] & 0x1F, + options=buf[14], + reserved=buf[15], + ) + + +def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes: + """A Blaze reply echoes component/command/msgNum/userIndex verbatim and only + overwrites the msgType bits (byte[13] = 0x20 for REPLY + userIndex 0).""" + return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type, + payload, user_index=hdr["user_index"]) + + +def notification(component: int, notify_id: int, payload: bytes = b"", + user_index: int = 0) -> bytes: + """Unsolicited server push: msgType = NOTIFICATION (2) -> byte[13] = 0x40, + msgNum = 0 (notifications are not correlated to a request).""" + return fire2(component, notify_id, 0, NOTIFICATION, payload, + user_index=user_index) + + +# ================================================================== session + +class Session(object): + """Per-connection forged session state.""" + + def __init__(self): + self.session_key = make_session_key() + self.auth_code = "" # whatever LoginRequest.AUTH carried + self.account_locale = ACCOUNT_LOCALE_FALLBACK + self.service_name = "fifa-2017-pc" + self.logged_in = False + self.login_time = 0 + + +def make_session_key() -> str: + """Real Blaze session keys look like <16 hex>_<44 base64-ish chars>. + The client never validates it -- grid-blaze literally ships "0" -- but the + SAME string must appear in LoginResponse.SESS.KEY and in the + UserAuthenticated notification's KEY, so we mint it once per session.""" + alpha = string.ascii_letters + string.digits + "$*" + return ("%016x_" % random.getrandbits(64)) + \ + "".join(random.choice(alpha) for _ in range(44)) + + +# ============================================== Util::fetchClientConfig (9/1) +# +# Response type: Blaze::Util::FetchConfigResponse @0x1448752e0 -- a SINGLE +# member `CONF` : map. NOT double-nested: the extra nesting only +# exists inside PreAuthResponse, where CONF is itself a FetchConfigResponse +# whose own single member is also called CONF. Easy to get wrong. + +# Keys verified present as string literals in FIFA17.exe (owner in comment). +# Anything not in the binary is silently ignored, so the map is kept minimal. +# All time values are MICROSECONDS -- the client divides by 1000 to get ms. +def blazesdk_config() -> list: + cfg = [ + ("associationListSkipInitialSet", "1"), # 0x143b6eb88 AssocListAPI + ("autoReconnectEnabled", "1"), # 0x1438a0a68 ConnMgr + ("connIdleTimeout", "90000000"), # 0x1438a0a58 ConnMgr + ("defaultRequestTimeout", "30000000"), # 0x1438a0a40 ConnMgr + ("enableQosBandwidthTest", "false"), # 0x1438a0a08 clears bit1 + ("enableQosFirewallTest", "false"), # 0x1438a09f0 clears bit0 + ("maxReconnectAttempts", "5"), # 0x1438a0a80 ConnMgr + ("pingPeriod", "20000000"), # 0x1438a0a30 ConnMgr + ("userManagerMaxCachedUsers", "128"), # UserManager + ("voipHeadsetUpdateRate", "0"), # VoIP + ] + if EMIT_NUCLEUS_URLS: + # LoginStateMachineImpl (0x14389fd50-0x14389fef8) builds + # "/connect/token", POSTs grant_type=client_credentials, + # and scrapes '"access_token" : "' out of the reply. Point it at our own + # stub (see nucleus_handle) so it can never reach a real EA host. + cfg += [ + ("nucleusConnect", NUCLEUS_BASE), # 0x14389fef8 + ("nucleusConnectTrusted", NUCLEUS_BASE), # 0x14389fdf8 + ] + return sorted(cfg) + + +# The OSDK_* sections are NOT Blaze plumbing. They are FIFA's own OSDK +# (8.01.03.00-fifa.01) ResourceLoader tuning maps; every key falls back to a +# built-in default, which is why our empty replies did not by themselves kill +# the login. Answering them non-empty is cheap insurance and removes a +# variable. Key names are literals observed in FIFA17.exe. +OSDK_CORE = [ + ("OSDK_PRESENCE_DELAY", "5"), + ("OSDK_PRESENCE_POLL", "60"), + ("OSDK_ANTIGRIEFING_MAX_COUNT", "0"), + ("OSDK_ARENA_ENABLED", "0"), +] +OSDK_CLIENT = [ + ("OSDK_CLUBS_MAX_SEARCH_RESULT", "50"), + ("OSDK_CLUBS_LOAD_MEMBER_PAGE_SIZE", "25"), + ("OSDK_CLUBS_MAX_USERS_FOR_GAME", "22"), + ("OSDK_CLUBS_LEADERBOARD_CLUB_MAX", "100"), + ("OSDK_CLUBS_INCOME_SEARCH_MAX", "100"), +] +# OSDK_NUCLEUS is Nucleus *tuning* only -- no URL keys were found in it. The +# real Nucleus endpoints are BlazeSDK-level (nucleusConnect, above). +OSDK_NUCLEUS = [ + ("OSDK_NUCLEUS_ENABLED", "1"), + ("OSDK_NUCLEUS_POLL", "60"), + ("OSDK_NUCLEUS_RETRY_COUNT", "3"), + ("OSDK_NUCLEUS_TIMEOUT", "30"), +] +# Keep the online storefront and abuse-report web views switched OFF: with no +# EA web backend reachable, an enabled one is a hang waiting to happen. +OSDK_WEBOFFER = [ + ("OSDK_WEBOFFER_ENABLED", "0"), + ("OSDK_WEBOFFER_URL", ""), +] +OSDK_ABUSE_REPORTING = [ + ("OSDK_ABUSE_REPORTING_ENABLED", "0"), + ("OSDK_ABUSE_NUM_TYPES", "0"), +] +OSDK_TICKER = [ + ("OSDK_TICKER_ENABLED", "0"), +] +# Not requested by FIFA 17 in our capture, but both independent clean-room +# emulators answer it identically; harmless to have ready. +IDENTITY_PARAMS = [ + ("display", "console2/welcome"), + ("redirect_uri", "http://127.0.0.1/success"), +] + +CLIENT_CONFIGS = { + "BlazeSDK": None, # built dynamically, see below + "OSDK_CORE": OSDK_CORE, + "OSDK_CLIENT": OSDK_CLIENT, + "OSDK_NUCLEUS": OSDK_NUCLEUS, + "OSDK_WEBOFFER": OSDK_WEBOFFER, + "OSDK_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, + "OSDK_XMS_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, + "OSDK_TICKER": OSDK_TICKER, + "IdentityParams": IDENTITY_PARAMS, +} + + +def client_config_for(cfid: str) -> list: + """-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we + still wrap in a present CONF field -- never an empty frame).""" + if cfid == "BlazeSDK": + return blazesdk_config() + return sorted(CLIENT_CONFIGS.get(cfid) or []) + + +def fetch_config_response_fields(cfid: str) -> "OrderedDict": + """Blaze::Util::FetchConfigResponse -- single member CONF : map.""" + return OrderedDict([ + ("CONF", (MAP, (STRING, STRING, client_config_for(cfid)))), + ]) + + +# ================================================== Util::preAuth (9/7) reply + +COMPONENT_IDS = [ + COMP_AUTH, COMP_GAMEMANAGER, COMP_REDIRECTOR, COMP_STATS, COMP_UTIL, + COMP_MESSAGING, COMP_ASSOCLISTS, COMP_GAMEREPORTING, COMP_USERSESSIONS, +] + + +def qos_config() -> "OrderedDict": + """Blaze::QosConfigInfo -- 4 members per reflection (FIFA 17's descriptor + has NO SVID, unlike Mirror's Edge Catalyst).""" + return OrderedDict([ + ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo + ("PSA", (STRING, "127.0.0.1")), + ("PSP", (INT, 17502)), + ]))), + ("LNP", (INT, 10)), + ("LTPS", (MAP, (STRING, STRUCT, []))), + ("TIME", (INT, 5000000)), + ]) + + +def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict": + return OrderedDict([ + ("ASRC", (STRING, TITLE_ID)), # authenticationSource + ("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds + ("CLID", (STRING, CLIENT_ID)), # clientId + ("CONF", (STRUCT, fetch_config_response_fields("BlazeSDK"))), + ("ESRC", (STRING, TITLE_ID)), # entitlementSource + ("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN + ("MAID", (INT, 0)), # machineId + ("MINR", (INT, 0)), # underageSupported = false + ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace + ("PILD", (STRING, "")), # legalDocGameIdentifier + ("PLAT", (STRING, PLATFORM)), # platform + ("QOSS", (STRUCT, qos_config())), # qosSettings + ("RSRC", (STRING, TITLE_ID)), # registrationSource + ("SVER", (STRING, SERVER_VERSION)), # serverVersion + ]) + + +def ping_response_fields() -> "OrderedDict": + """Blaze::Util::PingResponse @0x144875560 has EXACTLY ONE member: STIM + (serverTime, uint32). v2 also sent TIME -- that is MEC's field, not + FIFA 17's. Dropped.""" + return OrderedDict([("STIM", (INT, int(time.time())))]) + + +# ==================================== Authentication::login (1/0x0A) -- FORGED +# +# Blaze::Authentication::LoginResponse @0x14487d170 -- EXACTLY 5 members. +# NOTE the divergence from both MEC emulators: they emit CNTX, ERRC and a +# top-level SKEY. FIFA 17's LoginResponse has NONE of those -- CNTX/ERRC are +# the Blaze *error metadata* block, and the session key lives at SESS.KEY. + +def persona_details_fields(now: int) -> "OrderedDict": + """Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members.""" + return OrderedDict([ + ("DSNM", (STRING, PERSONA_NAME)), # displayName MUST be "CAGE" + ("LAST", (INT, now)), # lastAuthenticated uint32 + ("PID", (INT, PERSONA_ID)), # personaId int64 MUST be 33068179 + ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform enum -> pc + ("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE + ("XREF", (INT, EXT_ID)), # extId uint64 + ]) + + +def user_login_info_fields(sess: Session, now: int) -> "OrderedDict": + """Blaze::Authentication::UserLoginInfo @0x14487cb00 -- 8 members. + ('1CON' packs to 0x11 which sorts BELOW 'A'=0x21, so it is first.)""" + return OrderedDict([ + ("1CON", (INT, 0)), # isFirstConsoleLogin = false + ("BUID", (INT, USER_ID)), # blazeUserId -- MUST be != 0 + ("FRST", (INT, 0)), # isFirstLogin = false + ("KEY", (STRING, sess.session_key)), # sessionKey -- MUST be non-empty + ("LLOG", (INT, now)), # lastLoginDateTime + ("MAIL", (STRING, EMAIL)), # email + ("PDTL", (STRUCT, persona_details_fields(now))), + ("UID", (INT, USER_ID)), # userId -- MUST be != 0 + ]) + + +def login_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Authentication::LoginResponse @0x14487d170 -- 5 members only.""" + now = int(time.time()) + return OrderedDict([ + ("ANON", (INT, 0)), # isAnonymous -- 1 would give a guest session + ("NTOS", (INT, 0)), # needsLegalDoc -- 1 diverts to the legal-doc flow + ("SESS", (STRUCT, user_login_info_fields(sess, now))), + ("SPAM", (INT, 1)), # isOfLegalContactAge + ("UNDR", (INT, 0)), # isUnderage -- 1 strips online features + ]) + + +# ============================ UserSessions notifications (component 0x7802) +# +# Authentication (0x0001) publishes NO notifications at all -- its +0x28 slot is +# getRestResourceInfo, not getNotificationName. The login-success notification +# lives on UserSessions, whose getNotificationName (0x146de19a0) is clean, +# unmutated code with a 12-entry jump table at 0x141b03f70: +# 1 UserSessionExtendedDataUpdate 2 UserAdded 3 UserRemoved +# 5 UserUpdated 8 UserAuthenticated 9 UserUnauthenticated +# 12 ServerDraining + +# CGID (connectionGroupObjectId) is an ObjectId triple. heat2's OBJID encoding +# is UNVERIFIED on the wire and a wrong encoding desynchronises the whole TDF +# parse, whereas an ABSENT member simply keeps its client-side default. So we +# omit it. Flip this once OBJID is confirmed against a real capture. +EMIT_OBJID_FIELDS = False + + +def user_session_login_info_fields(sess: Session, now: int) -> "OrderedDict": + """Blaze::UserSessionLoginInfo @0x14486f920 -- 16 members. This is a + SUPERSET of UserLoginInfo with the persona fields flattened in rather than + nested. KEY must be byte-identical to LoginResponse.SESS.KEY.""" + f = OrderedDict([ + ("1CON", (INT, 0)), # isFirstConsoleLogin + ("ALOC", (INT, sess.account_locale)), # accountLocale (echo client's) + ("BUID", (INT, USER_ID)), # blazeUserId + ("DSNM", (STRING, PERSONA_NAME)), # displayName + ("FRST", (INT, 0)), # isFirstLogin + ("KEY", (STRING, sess.session_key)), # sessionKey <- SAME string + ("LAST", (INT, now)), # lastAuthenticated + ("LLOG", (INT, now)), # lastLoginDateTime + ("MAIL", (STRING, EMAIL)), # email + ("NASP", (STRING, PERSONA_NAMESPACE)), # must match PreAuthResponse + ("PID", (INT, PERSONA_ID)), # personaId + ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform + ("UID", (INT, USER_ID)), # userId + ("USTP", (INT, USER_SESSION_TYPE)), # userSessionType + ("XREF", (INT, EXT_ID)), # extId + ]) + if EMIT_OBJID_FIELDS: + from heat2 import OBJID + f["CGID"] = (OBJID, (COMP_USERSESSIONS, 1, USER_ID)) + return f + + +def network_qos_data_fields() -> "OrderedDict": + """Blaze::Util::NetworkQosData @0x14486e680 -- 5 members. + NATT = NatType; 0 = OPEN, which is what we want offline.""" + return OrderedDict([ + ("BWHR", (INT, 0)), # bandwidthHostedRate + ("DBPS", (INT, 100000)), # downstream bits/s + ("NAHR", (INT, 0)), # natHostedRate + ("NATT", (INT, 0)), # NatType -> OPEN + ("UBPS", (INT, 100000)), # upstream bits/s + ]) + + +def user_session_extended_data_fields() -> "OrderedDict": + """Blaze::UserSessionExtendedData @0x144870390 -- 12 members. + + TWO FIFA-17-SPECIFIC DELTAS vs the MEC emulators: FIFA HAS `PSLM` + (latencyList) which they lack, and FIFA has `BPS` as a TOP-LEVEL string + member whereas they bury it inside the ADDR union. Follow FIFA's layout. + + ADDR (NetworkAddress union), CVAR (variable) and ULST (list) are + omitted: heat2's UNION/OBJID encodings are unverified and a bad one breaks + the whole parse, while an absent member just keeps its default.""" + return OrderedDict([ + ("BPS", (STRING, "openfut")), # bestPingSiteAlias + ("CTY", (STRING, "US")), # country + ("DMAP", (MAP, (INT, INT, []))), # dataMap map + ("HWFG", (INT, 0)), # hardwareFlags bitfield + ("ISP", (STRING, "OpenFUT")), # iSP + ("PSLM", (LIST, (INT, [0]))), # latencyList <- FIFA-only + ("QDAT", (STRUCT, network_qos_data_fields())), + ("TZ", (STRING, "")), # timeZone + ("UATT", (INT, 0)), # userInfoAttribute + ]) + + +def user_session_extended_data_update_fields() -> "OrderedDict": + """Blaze::UserSessionExtendedDataUpdate @0x1448703e0 -- 3 members.""" + return OrderedDict([ + ("DATA", (STRUCT, user_session_extended_data_fields())), + ("SUBS", (INT, 1)), # subscribed + ("USID", (INT, USER_ID)), # userId + ]) + + +def user_identification_fields() -> "OrderedDict": + """Blaze::UserIdentification @0x14486ebc0 -- 9 members.""" + return OrderedDict([ + ("AID", (INT, USER_ID)), # accountId + ("ALOC", (INT, ACCOUNT_LOCALE_FALLBACK)), # accountLocale + ("EXBB", (BLOB, b"")), # externalBlob + ("EXID", (INT, EXT_ID)), # externalId + ("ID", (INT, USER_ID)), # blazeId + ("NAME", (STRING, PERSONA_NAME)), # name + ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace + ("ORIG", (INT, PERSONA_ID)), # originPersonaId + ("PIDI", (INT, PERSONA_ID)), # pidId + ]) + + +def user_data_fields() -> "OrderedDict": + """Blaze::UserData @0x1448706b0 -- 3 members. Payload of UserAdded (2). + FLGS is a UserDataFlags bitfield; bit0 = online/authenticated.""" + return OrderedDict([ + ("EDAT", (STRUCT, user_session_extended_data_fields())), + ("FLGS", (INT, 3)), + ("USER", (STRUCT, user_identification_fields())), + ]) + + +def build_login_notifications(sess: Session, now: int) -> list: + """The push sequence the client waits on after a successful login.""" + return [ + ("UserAuthenticated", notification( + COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, + encode_tdf(user_session_login_info_fields(sess, now)))), + ("UserSessionExtendedDataUpdate", notification( + COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, + encode_tdf(user_session_extended_data_update_fields()))), + ("UserAdded", notification( + COMP_USERSESSIONS, NOTIFY_USER_ADDED, + encode_tdf(user_data_fields()))), + ] + + +# ================================================ post-login RPC bodies + +def post_auth_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Util::PostAuthResponse @0x144875810 -- TELE, TICK, UROP. + Telemetry/ticker are pointed at a dead local port on purpose: we want the + client to have a well-formed config and then fail to connect quietly rather + than resolve a real EA hostname.""" + tele = OrderedDict([ # GetTelemetryServerResponse (15) + ("ADRS", (STRING, "127.0.0.1")), + ("ANON", (INT, 0)), + ("DISA", (STRING, "")), + ("EDCT", (INT, 0)), + ("FILT", (STRING, "")), + ("LOC", (INT, sess.account_locale)), + ("MINR", (INT, 0)), + ("NOOK", (STRING, "")), + ("PORT", (INT, 9988)), + ("SDLY", (INT, 15000)), + ("SESS", (STRING, sess.session_key)), + ("SKEY", (STRING, "")), + ("SPCT", (INT, 75)), + ("STIM", (STRING, "")), + ("SVNM", (STRING, "telemetry-openfut")), + ]) + tick = OrderedDict([ # GetTickerServerResponse (3) + ("ADRS", (STRING, "127.0.0.1")), + ("PORT", (INT, 8999)), + ("SKEY", (STRING, "")), + ]) + urop = OrderedDict([ # UserOptions (2) + ("TMOP", (INT, 0)), # TelemetryOpt -> out/disabled + ("UID", (INT, USER_ID)), + ]) + return OrderedDict([ + ("TELE", (STRUCT, tele)), + ("TICK", (STRUCT, tick)), + ("UROP", (STRUCT, urop)), + ]) + + +def entitlement_fields(now: int) -> "OrderedDict": + """Blaze::Authentication::Entitlement @0x14487d490 -- 16 members. + The retail exe requires TAG='ONLINE_ACCESS' tied to offer 1027460, STAT + active, PID 33068179. Failure modes: AUTH_ERR_NO_SUCH_ENTITLEMENT (63), + AUTH_ERR_ENTITLEMENT_TAG_REQUIRED (74).""" + day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) + return OrderedDict([ + ("DEVI", (STRING, "")), # deviceUri + ("GDAY", (STRING, "2016-09-01T00:00:00Z")), # grantDate + ("GNAM", (STRING, ENTITLEMENT_GROUP)), # groupName + ("ID", (INT, 1)), # id + ("ISCO", (INT, 0)), # isConsumable + ("PID", (INT, PERSONA_ID)), # personaId + ("PJID", (STRING, CONTENT_ID)), # projectId (EA offer id) + ("PRCA", (INT, 2)), # productCatalog + ("PRID", (STRING, CONTENT_ID)), # productId + ("STAT", (INT, 1)), # EntitlementStatus -> ACTIVE (1, verified) + ("STRC", (INT, 0)), # statusReasonCode + ("TAG", (STRING, ENTITLEMENT_TAG)), # entitlementTag + ("TDAY", (STRING, "")), # terminationDate (never) + ("TYPE", (INT, 1)), # EntitlementType -> ONLINE_ACCESS (1, verified) + ("UCNT", (INT, 0)), # useCount + ("VER", (INT, 1)), # version + ]) + del day # (kept for readability of the date format above) + + +def entitlements_response_fields() -> "OrderedDict": + """Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST.""" + now = int(time.time()) + return OrderedDict([ + ("NLST", (LIST, (STRUCT, [entitlement_fields(now)]))), + ]) + + +def get_auth_token_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Authentication::GetAuthTokenResponse @0x14487d080 -- 1 member.""" + tok = sess.auth_code or ("OPENFUT-" + sess.session_key[:16]) + return OrderedDict([("AUTH", (STRING, tok))]) + + +def user_settings_response_fields() -> "OrderedDict": + """TODO(verify): Util::userSettingsLoad's response descriptor was not + reflected. Both independent clean-room emulators use a single `DATA` + string, and an unknown-tag payload is ignored rather than fatal, so an empty + DATA is the safe minimum -- the client falls back to defaults.""" + return OrderedDict([("DATA", (STRING, ""))]) + + +def get_lists_response_fields() -> "OrderedDict": + """TODO(verify): AssociationLists::getLists (25/6) response is 3P-only + (GetListsResponse{LMAP: list}). FIFA's list names are NOT + verified -- do not invent them. An EMPTY list is well-formed and means + 'this user has no association lists', which is true offline.""" + return OrderedDict([("LMAP", (LIST, (STRUCT, [])))]) + + +# ================================================================== dispatch + +def extract_service_name(fields) -> str: + """PreAuthRequest.CDAT.SVCN -- echo it back as INST.""" + try: + cdat = fields.get("CDAT") + if cdat and cdat[0] == STRUCT: + svcn = cdat[1].get("SVCN") + if svcn and svcn[0] == STRING and svcn[1]: + return svcn[1] + except Exception: + pass + return "fifa-2017-pc" + + +def find_nested_int(fields, tag: str): + """Depth-first search for an INT member `tag` anywhere in a decoded TDF.""" + if not isinstance(fields, dict): + return None + for k, (t, v) in fields.items(): + if k == tag and t == INT: + return v + if t == STRUCT: + r = find_nested_int(v, tag) + if r is not None: + return r + return None + + +def get_str(fields, tag: str, default: str = "") -> str: + try: + tv = fields.get(tag) + if tv and tv[0] == STRING: + return tv[1] + except Exception: + pass + return default + + +def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list: + """-> list of frames to send back, in order (may be empty).""" + comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"] + + # Transport-level PING frame (msgType 4) -> PING_REPLY (5), empty body. + if mtype == PING: + log(" -> transport PING, answering PING_REPLY (empty)") + return [reply_to(hdr, b"", msg_type=PING_REPLY)] + + if mtype not in (MESSAGE,): + log(" -> msgType %s is not a request; not answering" + % MSGTYPE_NAME.get(mtype, mtype)) + return [] + + # ---------------------------------------------------------------- Util + if comp == COMP_UTIL: + if cmd == CMD_PREAUTH: + sess.service_name = (extract_service_name(fields) + if fields is not None else "fifa-2017-pc") + loc = None + if fields is not None: + loc = find_nested_int(fields, "LANG") + if loc is None: + loc = find_nested_int(fields, "LOC") + if loc: + sess.account_locale = loc + log(" -- client locale 0x%08x captured for ALOC" % loc) + resp = preauth_response_fields(service_name=sess.service_name) + payload = encode_tdf(resp) + log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" + % (sess.service_name, len(payload), heat2.dump(resp))) + return [reply_to(hdr, payload)] + + if cmd == CMD_PING: + resp = ping_response_fields() + log(" -> PingResponse STIM=%d" % resp["STIM"][1]) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_FETCHCLIENTCONFIG: + cfid = get_str(fields or {}, "CFID", "") + resp = fetch_config_response_fields(cfid) + n = len(resp["CONF"][1][2]) + log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s" + % (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)")) + for k, v in resp["CONF"][1][2]: + log(" %-32s = %s" % (k, v)) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_POSTAUTH: + resp = post_auth_response_fields(sess) + log(" -> PostAuthResponse (TELE/TICK/UROP)") + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_SETCLIENTSTATE: + log(" -> setClientState: empty REPLY (no response TDF)") + return [reply_to(hdr, b"")] + + if cmd == CMD_SETCLIENTMETRICS: + log(" -> setClientMetrics: empty REPLY (no response TDF)") + return [reply_to(hdr, b"")] + + if cmd == CMD_USERSETTINGSLOAD: + log(" -> UserSettingsResponse (empty DATA; TODO verify descriptor)") + return [reply_to(hdr, encode_tdf(user_settings_response_fields()))] + + if cmd == CMD_USERSETTINGSSAVE: + log(" -> userSettingsSave: empty REPLY (accepted, discarded)") + return [reply_to(hdr, b"")] + + if cmd == 0x15: # fetchQosConfig -> proven-good QosConfigInfo body + log(" -> QosConfigInfo (fetchQosConfig)") + return [reply_to(hdr, encode_tdf(qos_config()))] + + # ------------------------------------------------------ Authentication + if comp == COMP_AUTH: + if cmd == CMD_LOGIN: + sess.auth_code = get_str(fields or {}, "AUTH", "") + sess.logged_in = True + sess.login_time = int(time.time()) + log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus " + "validation -- forged offline session)" % sess.auth_code) + resp = login_response_fields(sess) + payload = encode_tdf(resp) + log(" -> LoginResponse (%d bytes):\n%s" + % (len(payload), heat2.dump(resp))) + notifs = build_login_notifications(sess, sess.login_time) + out = [] + if NOTIFY_BEFORE_LOGIN_REPLY: + for name, fr in notifs: + log(" ~> NOTIFY 0x7802/0x%04x %s" % + (parse_fire2_header(fr)["command"], name)) + out.append(fr) + out.append(reply_to(hdr, payload)) + else: + out.append(reply_to(hdr, payload)) + for name, fr in notifs: + log(" ~> NOTIFY 0x7802/0x%04x %s" % + (parse_fire2_header(fr)["command"], name)) + out.append(fr) + return out + + if cmd in (CMD_TRUSTEDLOGIN, CMD_EXPRESSLOGIN): + # Same forged session; the request fields differ but we ignore them. + sess.logged_in = True + sess.login_time = int(time.time()) + log(" == Authentication::%s -> same forged LoginResponse" + % AUTH_CMDS.get(cmd, cmd)) + out = [reply_to(hdr, encode_tdf(login_response_fields(sess)))] + out += [fr for _, fr in build_login_notifications(sess, + sess.login_time)] + return out + + if cmd == CMD_LOGOUT: + # An empty REPLY was already correct on the wire (logout has NO + # request and NO response TDF -- no LogoutRequest/LogoutResponse + # exists in the client's type index). But receiving this at all + # means the client decided it had no credential BEFORE Blaze auth. + log(" !! FAILURE SIGNAL: Authentication::logout (1/0x46) -- the " + "client never sent login. LAYER 1 (Origin/LSX on " + "127.0.0.1:4216) is still returning offline / no auth code. " + "Fix lsx_responder.py or lsx_force_online.py FIRST.") + return [reply_to(hdr, b"")] + + if cmd in (CMD_LISTUSERENTITLEMENTS2, 0x20, 0x30, 0x27): + # 0x1D listUserEntitlements2 / 0x20 listEntitlements / + # 0x30 listPersonaEntitlements2 / 0x27 grantEntitlement2 -- all return + # the ONLINE_ACCESS entitlement so the client sees it however it asks. + log(" -> Entitlements{NLST:[%s / offer %s / ACTIVE]} (cmd 0x%02x)" + % (ENTITLEMENT_TAG, CONTENT_ID, cmd)) + return [reply_to(hdr, encode_tdf(entitlements_response_fields()))] + + if cmd == CMD_GETAUTHTOKEN: + resp = get_auth_token_response_fields(sess) + log(" -> GetAuthTokenResponse AUTH=%r" % resp["AUTH"][1]) + return [reply_to(hdr, encode_tdf(resp))] + + # ------------------------------------------------------- UserSessions + if comp == COMP_USERSESSIONS and cmd == CMD_UPDATENETWORKINFO: + log(" -> updateNetworkInfo: empty REPLY, then re-push " + "UserSessionExtendedDataUpdate") + return [ + reply_to(hdr, b""), + notification(COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, + encode_tdf(user_session_extended_data_update_fields())), + ] + + # --------------------------------------------------- AssociationLists + if comp == COMP_ASSOCLISTS and cmd == CMD_GETLISTS: + log(" -> GetListsResponse{LMAP: []} (TODO verify FIFA's list names)") + return [reply_to(hdr, encode_tdf(get_lists_response_fields()))] + + # ---------------------------------------------------------------- TODO + # Still unimplemented, in the order they are expected to show up: + # Util::fetchQosConfig (9/0x15) -> QosConfigInfo (see qos_config) + # Util::localizeStrings (9/4) -> echo the requested ids + # Messaging::fetchMessages (15/2) -> empty list + # Stats / GameReporting / GameManager -> FUT-mode specific, later + # Authentication::getTermsOfServiceContent (1/0xF6) and + # getPrivacyPolicyContent (1/0x2F) -> only reached if NTOS != 0 + # Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED + # (three clean-room sources disagree: header[14:16] vs metadata ERRC vs + # payload CNTX/ERRC) -- do not emit one until it is verified on the wire. + # ---------------------------------------------------------------------- + + if REPLY_EMPTY_TO_UNKNOWN: + log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not " + "hang (all fields fall back to client-side defaults)" + % rpc_name(comp, cmd, mtype)) + return [reply_to(hdr, b"")] + + log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd, mtype)) + return [] + + +# ============================================================= blaze server + +def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool: + """Fill `buf` to at least n bytes. False on clean EOF / short close.""" + while len(buf) < n: + try: + chunk = sock.recv(65536) + except socket.timeout: + return False + if not chunk: + return False + buf += chunk + return True + + +_frame_counter = [0] + + +def blaze_handle(raw: socket.socket, addr) -> None: + log("*** BLAZE CONNECT from %s ***" % (addr,)) + sess = Session() + log(" session key minted: %s" % sess.session_key) + buf = bytearray() + raw.settimeout(300) + try: + while True: + if not recv_exactly(raw, FIRE2_HDR, buf): + break + hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR])) + total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"] + if hdr["payload_len"] > 4 * 1024 * 1024: + log("BLAZE %s: absurd payload_len %d, dropping connection\n%s" + % (addr, hdr["payload_len"], hexdump(bytes(buf[:64])))) + break + if not recv_exactly(raw, total, buf): + log("BLAZE %s: EOF mid-frame (want %d, have %d)" + % (addr, total, len(buf))) + break + + frame = bytes(buf[:total]) + del buf[:total] + metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]] + payload = frame[FIRE2_HDR + hdr["metadata_len"]:] + + _frame_counter[0] += 1 + n = _frame_counter[0] + log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x " + "meta=%dB payload=%dB" + % (n, rpc_name(hdr["component"], hdr["command"], hdr["msg_type"]), + MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), + hdr["msg_num"], hdr["user_index"], hdr["options"], + hdr["metadata_len"], hdr["payload_len"])) + log("RX #%d HEX:\n%s" % (n, hexdump(frame))) + if metadata: + log("RX #%d METADATA:\n%s" % (n, hexdump(metadata))) + if DUMP_FRAMES: + try: + os.makedirs(RXDIR, exist_ok=True) + fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin" + % (n, hdr["component"], hdr["command"])) + with open(fn, "wb") as fh: + fh.write(frame) + log("RX #%d saved -> %s" % (n, fn)) + except Exception as e: + log("RX #%d save failed: %s" % (n, e)) + + fields = None + if payload: + try: + fields = decode_tdf(payload) + log("RX #%d TDF:\n%s" % (n, heat2.dump(fields))) + except Exception as e: + log("RX #%d TDF DECODE FAILED: %s" % (n, e)) + else: + log("RX #%d TDF: (empty payload)" % n) + + try: + outs = dispatch(hdr, fields, payload, sess) + except Exception as e: + log("RX #%d DISPATCH ERROR: %r" % (n, e)) + outs = [] + + for k, out in enumerate(outs): + raw.sendall(out) + ohdr = parse_fire2_header(out) + log("TX #%d.%d %s msgType=%s msgNum=%d %dB total (%d payload)" + % (n, k, + rpc_name(ohdr["component"], ohdr["command"], + ohdr["msg_type"]), + MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), + ohdr["msg_num"], len(out), ohdr["payload_len"])) + log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024))) + except ConnectionResetError: + log("BLAZE %s: connection reset by client" % (addr,)) + except Exception as e: + log("BLAZE %s ERR: %r" % (addr, e)) + finally: + try: + raw.close() + except Exception: + pass + log("BLAZE %s: closed" % (addr,)) + + +# ======================================================= redirector (TLS) + +def build_redirect_response() -> bytes: + # ServerInstanceInfo.address is a ServerAddress union -> Heat2 XML union is + #
... member="0" = ipAddress variant + # {hostname, ip(uint32 decimal), port(uint16)}. + body = ( + '\n' + '\n' + '\t
\n' + '\t\t\n' + f'\t\t\t{BLAZE_IP_STR}\n' + f'\t\t\t{BLAZE_IP_U32}\n' + f'\t\t\t{BLAZE_PORT}\n' + '\t\t\n' + '\t
\n' + '\t0\n' + '\t\n' + '\t0\n' + '
\n' + ) + b = body.encode() + hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n" + f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode() + return hdr + b + + +def make_tls_context(): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERT, KEY) + ctx.minimum_version = ssl.TLSVersion.TLSv1 + ctx.set_ciphers("ALL:@SECLEVEL=0") + return ctx + + +_ctx = [None] + + +def redir_handle(raw: socket.socket, addr) -> None: + try: + tls = _ctx[0].wrap_socket(raw, server_side=True) + except ssl.SSLError as e: + log("REDIR REJECTED %s: %s" % (addr, e)) + raw.close() + return + log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0])) + try: + tls.settimeout(8) + req = b"" + while b"\r\n\r\n" not in req: + c = tls.recv(4096) + if not c: + break + req += c + if b"content-length:" in req.lower(): + head, _, rest = req.partition(b"\r\n\r\n") + cl = int([l.split(b":")[1] for l in head.split(b"\r\n") + if l.lower().startswith(b"content-length")][0]) + while len(rest) < cl: + c = tls.recv(4096) + if not c: + break + rest += c + req = head + b"\r\n\r\n" + rest + line0 = req.split(b"\r\n", 1)[0].decode(errors="replace") + log("REDIR REQ %s: %s" % (addr, line0)) + resp = build_redirect_response() + tls.sendall(resp) + log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d" + % (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT)) + time.sleep(0.3) + tls.close() + except Exception as e: + log("REDIR ERR %s: %s" % (addr, e)) + + +# ================================================ Nucleus OAuth stub (plain) +# +# LoginStateMachineImpl (string cluster 0x14389fd50-0x14389fef8) builds +# "/connect/token", POSTs "grant_type=client_credentials" +# with a "NEXUS_S2S " authorization header, and scrapes '"access_token" : "' +# out of the reply body. We advertise nucleusConnect = this listener, so the +# client can never reach accounts.ea.com. Note the exact spacing in the JSON: +# the client searches for the literal '"access_token" : "'. + +def nucleus_handle(raw: socket.socket, addr) -> None: + try: + raw.settimeout(10) + req = b"" + while b"\r\n\r\n" not in req and len(req) < 65536: + c = raw.recv(4096) + if not c: + break + req += c + head, _, rest = req.partition(b"\r\n\r\n") + line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else "" + log("NUCLEUS REQ %s: %s" % (addr, line0)) + if head: + log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace")) + if rest: + log("NUCLEUS BODY: %r" % rest[:512]) + + token = "OPENFUT_" + "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(40)) + # Spacing matters: the client greps for the literal '"access_token" : "'. + body = ('{\n "access_token" : "%s",\n "token_type" : "Bearer",\n' + ' "expires_in" : 14400,\n "id_token" : "%s",\n' + ' "refresh_token" : "%s"\n}\n' + % (token, token, token)).encode() + out = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + b"Cache-Control: no-store\r\nContent-Length: " + + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + raw.sendall(out) + log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token)) + except Exception as e: + log("NUCLEUS ERR %s: %s" % (addr, e)) + finally: + try: + raw.close() + except Exception: + pass + + +# ================================================================== serve + +def serve(port: int, handler, name: str) -> None: + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((HOST, port)) + s.listen(16) + log("%s listening on %s:%d" % (name, HOST, port)) + while True: + c, a = s.accept() + threading.Thread(target=handler, args=(c, a), daemon=True).start() + + +# ================================================================== selftest + +def _check_roundtrip(label: str, fields) -> bytes: + payload = encode_tdf(fields) + back = decode_tdf(payload) + again = encode_tdf(back) + assert again == payload, "%s: re-encode differs" % label + assert list(back.keys()) == sorted(fields.keys(), key=heat2.tag_key), \ + "%s: tag order %r" % (label, list(back.keys())) + return payload + + +def _selftest() -> None: + print("=" * 72) + print("blaze_responder_v3 selftest") + print("=" * 72) + + sess = Session() + sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0" + sess.account_locale = 0x656E5553 + now = 1469000000 + + # ---- 1. preAuth still round-trips (regression guard vs v2) + pre = preauth_response_fields() + p = _check_roundtrip("PreAuthResponse", pre) + fr = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, p) + assert fr[13] == 0x20, fr[13] + assert list(decode_tdf(p).keys()) == [ + "ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST", "MAID", "MINR", + "NASP", "PILD", "PLAT", "QOSS", "RSRC", "SVER"] + print("[ok] PreAuthResponse %4d payload bytes" % len(p)) + + # ---- 2. fetchClientConfig per CFID + for cfid in ["BlazeSDK", "OSDK_CORE", "OSDK_CLIENT", "OSDK_NUCLEUS", + "OSDK_WEBOFFER", "OSDK_ABUSE_REPORTING", + "OSDK_XMS_ABUSE_REPORTING", "IdentityParams", "TOTALLY_UNKNOWN"]: + f = fetch_config_response_fields(cfid) + pb = _check_roundtrip("FetchConfigResponse/" + cfid, f) + back = decode_tdf(pb) + assert list(back.keys()) == ["CONF"], back.keys() + kt, vt, items = back["CONF"][1] + assert (kt, vt) == (STRING, STRING) + assert items == client_config_for(cfid), cfid + print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes" + % (cfid, len(items), len(pb))) + assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []" + assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \ + "unknown CFID must still carry a CONF field (empty map, not empty frame)" + + # ---- 3. LoginResponse + lr = login_response_fields(sess) + lp = _check_roundtrip("LoginResponse", lr) + back = decode_tdf(lp) + assert list(back.keys()) == ["ANON", "NTOS", "SESS", "SPAM", "UNDR"], back.keys() + assert "CNTX" not in back and "ERRC" not in back and "SKEY" not in back + s = back["SESS"][1] + assert list(s.keys()) == ["1CON", "BUID", "FRST", "KEY", "LLOG", "MAIL", + "PDTL", "UID"], list(s.keys()) + assert s["KEY"][1] == sess.session_key + assert s["BUID"][1] == USER_ID != 0 and s["UID"][1] == USER_ID + d = s["PDTL"][1] + assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \ + list(d.keys()) + assert d["PID"][1] == PERSONA_ID == 33068179 + assert d["DSNM"][1] == PERSONA_NAME == "CAGE" + assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0 + lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp) + lh = parse_fire2_header(lfr) + assert (lh["component"], lh["command"], lh["msg_type"]) == (1, 0x0A, REPLY) + assert lfr[13] == 0x20 + print("[ok] LoginResponse %4d payload bytes, hdr %s" + % (len(lp), lfr[:16].hex(" "))) + + # ---- 4. UserAuthenticated notification + ua = user_session_login_info_fields(sess, now) + up = _check_roundtrip("UserSessionLoginInfo", ua) + nb = decode_tdf(up) + assert list(nb.keys()) == ["1CON", "ALOC", "BUID", "DSNM", "FRST", "KEY", + "LAST", "LLOG", "MAIL", "NASP", "PID", "PLAT", + "UID", "USTP", "XREF"], list(nb.keys()) + assert nb["KEY"][1] == sess.session_key, "notif KEY must match SESS.KEY" + assert nb["PID"][1] == PERSONA_ID and nb["DSNM"][1] == PERSONA_NAME + assert nb["NASP"][1] == PERSONA_NAMESPACE + nfr = notification(COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, up) + nh = parse_fire2_header(nfr) + assert (nh["component"], nh["command"]) == (0x7802, 0x0008), nh + assert nh["msg_type"] == NOTIFICATION and nh["msg_num"] == 0 + assert nfr[13] == 0x40, nfr[13] + print("[ok] UserAuthenticated notif %4d payload bytes, hdr %s" + % (len(up), nfr[:16].hex(" "))) + + # ---- 5. the other two notifications + post-login RPCs + for label, f in [ + ("UserSessionExtendedDataUpdate", user_session_extended_data_update_fields()), + ("UserAdded (UserData)", user_data_fields()), + ("PostAuthResponse", post_auth_response_fields(sess)), + ("Entitlements", entitlements_response_fields()), + ("GetAuthTokenResponse", get_auth_token_response_fields(sess)), + ("UserSettingsResponse", user_settings_response_fields()), + ("GetListsResponse", get_lists_response_fields()), + ("PingResponse", ping_response_fields()), + ]: + b = _check_roundtrip(label, f) + print("[ok] %-30s %4d payload bytes" % (label, len(b))) + assert list(ping_response_fields().keys()) == ["STIM"], \ + "PingResponse must carry ONLY STIM (TIME is MEC's, not FIFA 17's)" + + # ---- 6. the full login burst, framed + frames = [fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)] + \ + [fr for _, fr in build_login_notifications(sess, now)] + blob = b"".join(frames) + seen, i = [], 0 + while i < len(blob): + h = parse_fire2_header(blob[i:i + 16]) + tot = 16 + h["metadata_len"] + h["payload_len"] + seen.append((h["component"], h["command"], h["msg_type"])) + decode_tdf(blob[i + 16 + h["metadata_len"]:i + tot]) + i += tot + assert seen == [(0x0001, 0x000A, REPLY), + (0x7802, 0x0008, NOTIFICATION), + (0x7802, 0x0001, NOTIFICATION), + (0x7802, 0x0002, NOTIFICATION)], seen + print("[ok] login burst re-framed: %d frames / %d bytes" + % (len(frames), len(blob))) + + print() + print("---- LoginResponse -------------------------------------------------") + print(heat2.dump(lr)) + print() + print("---- NOTIFY 0x7802/0x0008 UserAuthenticated ------------------------") + print(heat2.dump(ua)) + print() + print("ALL SELFTESTS PASSED") + + +# ================================================================== main + +if __name__ == "__main__": + if "--selftest" in sys.argv: + _selftest() + raise SystemExit(0) + + log("=== RESPONDER v3 START (redir %d / blaze %d%s) ===" + % (REDIR_PORT, BLAZE_PORT, + (" / nucleus %d" % NUCLEUS_PORT) if NUCLEUS_STUB_ENABLED else "")) + log(" persona %d / %r namespace %r entitlement %s (offer %s)" + % (PERSONA_ID, PERSONA_NAME, PERSONA_NAMESPACE, ENTITLEMENT_TAG, + CONTENT_ID)) + log(" REMINDER: layer 1 first -- start lsx_responder.py BEFORE FIFA 17, " + "or the client sends logout (1/0x46) instead of login (1/0x0A).") + _ctx[0] = make_tls_context() + threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"), + daemon=True).start() + if NUCLEUS_STUB_ENABLED: + threading.Thread(target=serve, + args=(NUCLEUS_PORT, nucleus_handle, "NUCLEUS"), + daemon=True).start() + serve(REDIR_PORT, redir_handle, "REDIR") diff --git a/fifa17-recon/tools/blaze_responder_v3_patched.py b/fifa17-recon/tools/blaze_responder_v3_patched.py new file mode 100644 index 0000000..6a2df6f --- /dev/null +++ b/fifa17-recon/tools/blaze_responder_v3_patched.py @@ -0,0 +1,1408 @@ +#!/usr/bin/env python3 +"""FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication. + +WHAT IS NEW vs v2 +----------------- + * Util::fetchClientConfig (9/1) answered for real, per-CFID, with a proper + FetchConfigResponse{CONF: map}. Unknown CFID -> EMPTY MAP + (a present-but-empty CONF), never an empty frame. + * Authentication (component 0x0001): + login (1/0x0A) -> forged offline LoginResponse (persona 33068179/CAGE) + logout (1/0x46) -> empty REPLY (a NORMAL pre-login step, see below) + getAuthToken (1/0x24), listUserEntitlements2 (1/0x1D) + * UserSessions (0x7802) NOTIFICATIONs pushed unsolicited: + 0x0008 UserAuthenticated (Blaze::UserSessionLoginInfo) + 0x0001 UserSessionExtendedDataUpdate + 0x0002 UserAdded (Blaze::UserData) + * Util::postAuth (9/8), setClientState (9/0x1C), userSettingsLoad (9/0x0A), + setClientMetrics (9/0x16), AssociationLists::getLists (25/6), + UserSessions::updateNetworkInfo (0x7802/0x14). + * Optional local Nucleus OAuth stub on 42131 serving POST /connect/token, with + nucleusConnect / nucleusConnectTrusted in the BlazeSDK config pointed at it. + * PingResponse now carries ONLY STIM (FIFA 17's PingResponse @0x144875560 has + exactly one member; v2's extra TIME was Mirror's-Edge-Catalyst's field). + +TWO-LAYER ORDERING -- READ THIS FIRST +------------------------------------- +This file is layer 2. Layer 1 is Origin/LSX on 127.0.0.1:4216. The client's +`origin.nav` gates FUT on `OriginIsOnlineTrue` and only then runs +`futBlazeLogin`; without a layer-1 auth code the client sends +`Authentication::logout` (1/0x46) instead of `login` and shows +"Unable to connect to the EA Servers ... log in to Origin in Online Mode". +Start `lsx_responder.py` (before FIFA) or apply `lsx_force_online.py` FIRST. +Receiving 1/0x46 here means layer 1 is still broken. + +CLEAN ROOM. Every TDF tag/type below comes from FIFA17.exe's own in-process TDF +reflection metadata that we walked in live memory, plus our own captured wire +bytes. Independent third-party clean-room BlazeSDK-15.x reimplementations were +consulted only to cross-check *structure*. No EA/FIFA leaked source was used. + +Run: python3 blaze_responder_v3.py (binds 42127 + 42130 [+ 42131]) +Selftest: python3 blaze_responder_v3.py --selftest +Log: /tmp/blaze_responder.log +Frames: /tmp/blaze_rx/ +""" + +from __future__ import annotations + +import binascii +import json +import os +import random +import socket +import ssl +import string +import struct +import sys +import threading +import time +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import heat2 # noqa: E402 +from heat2 import ( # noqa: E402 + INT, STRING, BLOB, STRUCT, LIST, MAP, encode_tdf, decode_tdf, +) + +# ================================================================== identity +# SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py. +# Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language). +# A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26), +# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND. + +PERSONA_ID = 33068179 +PERSONA_NAME = "CAGE" +USER_ID = 33068179 # blazeId / userId; same value keeps BUID==UID==PID +EXT_ID = 33068179 # XREF externalId +EMAIL = "cage@openfut.local" +PERSONA_NAMESPACE = "cem_ea_id" # must equal PreAuthResponse.NASP +CLIENT_PLATFORM = 4 # Blaze::ClientPlatformType -> pc +PERSONA_STATUS = 2 # PersonaStatus::Code -> ACTIVE (verified live: table 0x14487ad20, ACTIVE==2) +USER_SESSION_TYPE = 0 # Blaze::UserSessionType -> normal/console user +ACCOUNT_LOCALE_FALLBACK = 0x656E5553 # 'enUS'; overwritten by the client's own + # PreAuthRequest LANG/LOC when we see it. + +CONTENT_ID = "1027460" # FIFA 17 EA offer id (retail) +ENTITLEMENT_TAG = "ONLINE_ACCESS" # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe +ENTITLEMENT_GROUP = "FIFA17PC" + +TITLE_ID = "309111" +CLIENT_ID = "FIFA17-PC-SERVER-BLAZE" +PLATFORM = "pc" +SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" + +# ================================================================== config + +HOST = "127.0.0.1" +REDIR_PORT = 42127 +BLAZE_PORT = 42130 +NUCLEUS_PORT = 42131 +BLAZE_IP_STR = "127.0.0.1" +BLAZE_IP_U32 = (127 << 24) | 1 +LOG = "/tmp/blaze_responder.log" +RXDIR = "/tmp/blaze_rx" +HERE = os.path.dirname(os.path.abspath(__file__)) +CERT = os.path.join(HERE, "redir_cert.pem") +KEY = os.path.join(HERE, "redir_key.pem") + +# Serve a local OAuth stub and advertise it as nucleusConnect. The client's +# LoginStateMachineImpl builds "/connect/token", POSTs +# grant_type=client_credentials, and scrapes '"access_token" : "'. +NUCLEUS_STUB_ENABLED = True +EMIT_NUCLEUS_URLS = True +NUCLEUS_BASE = "http://%s:%d" % (HOST, NUCLEUS_PORT) + +# Any RPC we do not implement still gets an empty REPLY so the client's request +# never times out. Flip to False to find out what it truly blocks on. +REPLY_EMPTY_TO_UNKNOWN = True + +# Push the UserAuthenticated notification BEFORE writing the login reply +# (grid-blaze order) or after (pamplona order). Both are reported to work. +NOTIFY_BEFORE_LOGIN_REPLY = False + +DUMP_FRAMES = True + +_log_lock = threading.Lock() + + +def log(m: str) -> None: + line = "[%s] %s" % (time.strftime("%H:%M:%S"), m) + with _log_lock: + print(line, flush=True) + try: + with open(LOG, "a") as fh: + fh.write(line + "\n") + except Exception: + pass + + +def hexdump(b: bytes, limit: int = 512) -> str: + out = [] + for i in range(0, min(len(b), limit), 16): + chunk = b[i:i + 16] + txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk) + out.append(" %04x: %-47s %s" + % (i, binascii.hexlify(chunk, " ").decode(), txt)) + if len(b) > limit: + out.append(" ... (%d more bytes)" % (len(b) - limit)) + return "\n".join(out) + + +# ================================================================== Fire2 +# +# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame / +# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them): +# +# [0:4] u32 payload length +# [4:6] u16 metadata length +# [6:8] u16 component +# [8:10] u16 command (== notification id on NOTIFICATION) +# [10:13] u24 msgNum +# [13] u8 (msgType << 5) | (userIndex & 0x1F) +# [14] u8 options +# [15] u8 reserved +# wire = header(16) || metadata || payload +# +# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame). + +FIRE2_HDR = 16 + +MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6) +MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION", + 3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"} + +COMP_AUTH = 0x0001 +COMP_GAMEMANAGER = 0x0004 +COMP_REDIRECTOR = 0x0005 +COMP_STATS = 0x0007 +COMP_UTIL = 0x0009 +COMP_MESSAGING = 0x000F +COMP_ASSOCLISTS = 0x0019 +COMP_GAMEREPORTING = 0x001C +COMP_USERSESSIONS = 0x7802 + +# ---- Util (0x0009) command table, recovered from the binary's own +# getCommandName switch (jump table 0x141b17af4). +CMD_FETCHCLIENTCONFIG = 0x0001 +CMD_PING = 0x0002 +CMD_PREAUTH = 0x0007 +CMD_POSTAUTH = 0x0008 +CMD_USERSETTINGSLOAD = 0x000A +CMD_USERSETTINGSSAVE = 0x000B +CMD_SETCLIENTMETRICS = 0x0016 +CMD_SETCLIENTSTATE = 0x001C + +UTIL_CMDS = { + 0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData", + 0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer", + 0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad", + 0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll", + 0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser", + 0x14: "filterForProfanity", 0x15: "fetchQosConfig", + 0x16: "setClientMetrics", 0x17: "setConnectionState", + 0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing", + 0x1C: "setClientState", +} + +# ---- Authentication (0x0001) command table. Recovered by CALLING the client's +# own getCommandName (0x146e0d2a0) in-process over ids 1..320 -- the name +# pool is Denuvo-mutated and statically unrecoverable. Validated against +# Util (reproduced preAuth=7/ping=2/fetchClientConfig=1) and cross-checked +# against a static REST-binding struct (0x143896a80 -> trustedLogin=0x0B). +CMD_LOGIN = 0x000A +CMD_TRUSTEDLOGIN = 0x000B +CMD_LISTUSERENTITLEMENTS2 = 0x001D +CMD_GETAUTHTOKEN = 0x0024 +CMD_EXPRESSLOGIN = 0x003C +CMD_LOGOUT = 0x0046 # <-- the "we gave up" RPC, NOT a login +CMD_GETPERSONA = 0x005A +CMD_LISTPERSONAS = 0x0064 + +AUTH_CMDS = { + 0x0A: "login", 0x0B: "trustedLogin", 0x14: "updateAccount", + 0x15: "upgradeAccount", 0x1D: "listUserEntitlements2", 0x1E: "getAccount", + 0x1F: "grantEntitlement", 0x20: "listEntitlements", 0x22: "getUseCount", + 0x23: "decrementUseCount", 0x24: "getAuthToken", 0x26: "getPasswordRules", + 0x27: "grantEntitlement2", 0x2B: "modifyEntitlement2", 0x2C: "consumecode", + 0x2D: "passwordForgot", 0x2F: "getPrivacyPolicyContent", + 0x30: "listPersonaEntitlements2", 0x33: "checkAgeReq", 0x34: "getOptIn", + 0x35: "enableOptIn", 0x36: "disableOptIn", 0x3C: "expressLogin", + 0x46: "logout", 0x5A: "getPersona", 0x64: "listPersonas", + 0x65: "expressCreateAccount", 0xE6: "createWalUserSession", + 0xF1: "acceptLegalDocs", 0xF2: "getEmailOptInSettings", + 0xF6: "getTermsOfServiceContent", 0x104: "getOriginPersona", + 0x10E: "checkEmail", 0x118: "getPersonaNameSuggestions", 0x122: "guestLogin", +} + +# ---- UserSessions (0x7802). Commands and NOTIFICATIONS live in separate +# number spaces. Notification ids decoded statically from the client's +# own getNotificationName jump table at 0x141b03f70 (clean, unmutated). +NOTIFY_USER_EXTENDED_DATA_UPDATE = 0x0001 +NOTIFY_USER_ADDED = 0x0002 +NOTIFY_USER_REMOVED = 0x0003 +NOTIFY_USER_UPDATED = 0x0005 +NOTIFY_USER_AUTHENTICATED = 0x0008 +NOTIFY_USER_UNAUTHENTICATED = 0x0009 +NOTIFY_SERVER_DRAINING = 0x000C + +USERSESSIONS_NOTIFY_NAMES = { + 0x01: "UserSessionExtendedDataUpdate", 0x02: "UserAdded", + 0x03: "UserRemoved", 0x05: "UserUpdated", 0x08: "UserAuthenticated", + 0x09: "UserUnauthenticated", 0x0C: "ServerDraining", +} + +CMD_UPDATENETWORKINFO = 0x0014 # UserSessions command space +CMD_GETLISTS = 0x0006 # AssociationLists + +COMP_NAMES = { + COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager", + COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util", + COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists", + COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions", +} + + +def rpc_name(component: int, command: int, msg_type: int = MESSAGE) -> str: + comp = COMP_NAMES.get(component, "Component:0x%04x" % component) + if component == COMP_USERSESSIONS and msg_type == NOTIFICATION: + cmd = USERSESSIONS_NOTIFY_NAMES.get(command, "notify:0x%04x" % command) + return "%s::<%s>" % (comp, cmd) + if component == COMP_UTIL: + cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command) + elif component == COMP_AUTH: + cmd = AUTH_CMDS.get(command, "cmd:0x%04x" % command) + else: + cmd = "cmd:0x%04x" % command + return "%s::%s" % (comp, cmd) + + +def fire2(component: int, command: int, msg_num: int, msg_type: int, + payload: bytes = b"", metadata: bytes = b"", + user_index: int = 0, options: int = 0) -> bytes: + h = bytearray(16) + struct.pack_into(">I", h, 0, len(payload)) + struct.pack_into(">H", h, 4, len(metadata)) + struct.pack_into(">H", h, 6, component & 0xFFFF) + struct.pack_into(">H", h, 8, command & 0xFFFF) + h[10] = (msg_num >> 16) & 0xFF + h[11] = (msg_num >> 8) & 0xFF + h[12] = msg_num & 0xFF + h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F) + h[14] = options & 0xFF + h[15] = 0 + return bytes(h) + metadata + payload + + +def parse_fire2_header(buf: bytes) -> dict: + return dict( + payload_len=struct.unpack_from(">I", buf, 0)[0], + metadata_len=struct.unpack_from(">H", buf, 4)[0], + component=struct.unpack_from(">H", buf, 6)[0], + command=struct.unpack_from(">H", buf, 8)[0], + msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12], + msg_type=(buf[13] >> 5) & 0x07, + user_index=buf[13] & 0x1F, + options=buf[14], + reserved=buf[15], + ) + + +def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes: + """A Blaze reply echoes component/command/msgNum/userIndex verbatim and only + overwrites the msgType bits (byte[13] = 0x20 for REPLY + userIndex 0).""" + return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type, + payload, user_index=hdr["user_index"]) + + +def notification(component: int, notify_id: int, payload: bytes = b"", + user_index: int = 0) -> bytes: + """Unsolicited server push: msgType = NOTIFICATION (2) -> byte[13] = 0x40, + msgNum = 0 (notifications are not correlated to a request).""" + return fire2(component, notify_id, 0, NOTIFICATION, payload, + user_index=user_index) + + +# ================================================================== session + +class Session(object): + """Per-connection forged session state.""" + + def __init__(self): + self.session_key = make_session_key() + self.auth_code = "" # whatever LoginRequest.AUTH carried + self.account_locale = ACCOUNT_LOCALE_FALLBACK + self.service_name = "fifa-2017-pc" + self.logged_in = False + self.saw_logout = False + self.login_time = 0 + + +def make_session_key() -> str: + """Real Blaze session keys look like <16 hex>_<44 base64-ish chars>. + The client never validates it -- grid-blaze literally ships "0" -- but the + SAME string must appear in LoginResponse.SESS.KEY and in the + UserAuthenticated notification's KEY, so we mint it once per session.""" + alpha = string.ascii_letters + string.digits + "$*" + return ("%016x_" % random.getrandbits(64)) + \ + "".join(random.choice(alpha) for _ in range(44)) + + +# ============================================== Util::fetchClientConfig (9/1) +# +# Response type: Blaze::Util::FetchConfigResponse @0x1448752e0 -- a SINGLE +# member `CONF` : map. NOT double-nested: the extra nesting only +# exists inside PreAuthResponse, where CONF is itself a FetchConfigResponse +# whose own single member is also called CONF. Easy to get wrong. + +# Keys verified present as string literals in FIFA17.exe (owner in comment). +# Anything not in the binary is silently ignored, so the map is kept minimal. +# All time values are MICROSECONDS -- the client divides by 1000 to get ms. +def blazesdk_config() -> list: + cfg = [ + ("associationListSkipInitialSet", "1"), # 0x143b6eb88 AssocListAPI + ("autoReconnectEnabled", "1"), # 0x1438a0a68 ConnMgr + ("connIdleTimeout", "90000000"), # 0x1438a0a58 ConnMgr + ("defaultRequestTimeout", "30000000"), # 0x1438a0a40 ConnMgr + ("enableQosBandwidthTest", "false"), # 0x1438a0a08 clears bit1 + ("enableQosFirewallTest", "false"), # 0x1438a09f0 clears bit0 + ("maxReconnectAttempts", "5"), # 0x1438a0a80 ConnMgr + ("pingPeriod", "20000000"), # 0x1438a0a30 ConnMgr + ("userManagerMaxCachedUsers", "128"), # UserManager + ("voipHeadsetUpdateRate", "0"), # VoIP + ] + if EMIT_NUCLEUS_URLS: + # LoginStateMachineImpl (0x14389fd50-0x14389fef8) builds + # "/connect/token", POSTs grant_type=client_credentials, + # and scrapes '"access_token" : "' out of the reply. Point it at our own + # stub (see nucleus_handle) so it can never reach a real EA host. + cfg += [ + ("nucleusConnect", NUCLEUS_BASE), # 0x14389fef8 + ("nucleusConnectTrusted", NUCLEUS_BASE), # 0x14389fdf8 + ] + return sorted(cfg) + + +# The OSDK_* sections are NOT Blaze plumbing. They are FIFA's own OSDK +# (8.01.03.00-fifa.01) ResourceLoader tuning maps; every key falls back to a +# built-in default, which is why our empty replies did not by themselves kill +# the login. Answering them non-empty is cheap insurance and removes a +# variable. Key names are literals observed in FIFA17.exe. +OSDK_CORE = [ + ("OSDK_PRESENCE_DELAY", "5"), + ("OSDK_PRESENCE_POLL", "60"), + ("OSDK_ANTIGRIEFING_MAX_COUNT", "0"), + ("OSDK_ARENA_ENABLED", "0"), +] +OSDK_CLIENT = [ + ("OSDK_CLUBS_MAX_SEARCH_RESULT", "50"), + ("OSDK_CLUBS_LOAD_MEMBER_PAGE_SIZE", "25"), + ("OSDK_CLUBS_MAX_USERS_FOR_GAME", "22"), + ("OSDK_CLUBS_LEADERBOARD_CLUB_MAX", "100"), + ("OSDK_CLUBS_INCOME_SEARCH_MAX", "100"), +] +# OSDK_NUCLEUS is Nucleus *tuning* only -- no URL keys were found in it. The +# real Nucleus endpoints are BlazeSDK-level (nucleusConnect, above). +OSDK_NUCLEUS = [ + ("OSDK_NUCLEUS_ENABLED", "1"), + ("OSDK_NUCLEUS_POLL", "60"), + ("OSDK_NUCLEUS_RETRY_COUNT", "3"), + ("OSDK_NUCLEUS_TIMEOUT", "30"), +] +# Keep the online storefront and abuse-report web views switched OFF: with no +# EA web backend reachable, an enabled one is a hang waiting to happen. +OSDK_WEBOFFER = [ + ("OSDK_WEBOFFER_ENABLED", "0"), + ("OSDK_WEBOFFER_URL", ""), +] +OSDK_ABUSE_REPORTING = [ + ("OSDK_ABUSE_REPORTING_ENABLED", "0"), + ("OSDK_ABUSE_NUM_TYPES", "0"), +] +OSDK_TICKER = [ + ("OSDK_TICKER_ENABLED", "0"), +] +# Not requested by FIFA 17 in our capture, but both independent clean-room +# emulators answer it identically; harmless to have ready. +IDENTITY_PARAMS = [ + ("display", "console2/welcome"), + ("redirect_uri", "http://127.0.0.1/success"), +] + +CLIENT_CONFIGS = { + "BlazeSDK": None, # built dynamically, see below + "OSDK_CORE": OSDK_CORE, + "OSDK_CLIENT": OSDK_CLIENT, + "OSDK_NUCLEUS": OSDK_NUCLEUS, + "OSDK_WEBOFFER": OSDK_WEBOFFER, + "OSDK_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, + "OSDK_XMS_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, + "OSDK_TICKER": OSDK_TICKER, + "IdentityParams": IDENTITY_PARAMS, +} + + +def client_config_for(cfid: str) -> list: + """-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we + still wrap in a present CONF field -- never an empty frame).""" + if cfid == "BlazeSDK": + return blazesdk_config() + return sorted(CLIENT_CONFIGS.get(cfid) or []) + + +def fetch_config_response_fields(cfid: str) -> "OrderedDict": + """Blaze::Util::FetchConfigResponse -- single member CONF : map.""" + return OrderedDict([ + ("CONF", (MAP, (STRING, STRING, client_config_for(cfid)))), + ]) + + +# ================================================== Util::preAuth (9/7) reply + +COMPONENT_IDS = [ + COMP_AUTH, COMP_GAMEMANAGER, COMP_REDIRECTOR, COMP_STATS, COMP_UTIL, + COMP_MESSAGING, COMP_ASSOCLISTS, COMP_GAMEREPORTING, COMP_USERSESSIONS, +] + + +def qos_config() -> "OrderedDict": + """Blaze::QosConfigInfo -- 4 members per reflection (FIFA 17's descriptor + has NO SVID, unlike Mirror's Edge Catalyst).""" + return OrderedDict([ + ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo + ("PSA", (STRING, "127.0.0.1")), + ("PSP", (INT, 17502)), + ]))), + ("LNP", (INT, 10)), + ("LTPS", (MAP, (STRING, STRUCT, []))), + ("TIME", (INT, 5000000)), + ]) + + +def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict": + return OrderedDict([ + ("ASRC", (STRING, TITLE_ID)), # authenticationSource + ("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds + ("CLID", (STRING, CLIENT_ID)), # clientId + ("CONF", (STRUCT, fetch_config_response_fields("BlazeSDK"))), + ("ESRC", (STRING, TITLE_ID)), # entitlementSource + ("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN + ("MAID", (INT, 0)), # machineId + ("MINR", (INT, 0)), # underageSupported = false + ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace + ("PILD", (STRING, "")), # legalDocGameIdentifier + ("PLAT", (STRING, PLATFORM)), # platform + ("QOSS", (STRUCT, qos_config())), # qosSettings + ("RSRC", (STRING, TITLE_ID)), # registrationSource + ("SVER", (STRING, SERVER_VERSION)), # serverVersion + ]) + + +def ping_response_fields() -> "OrderedDict": + """Blaze::Util::PingResponse @0x144875560 has EXACTLY ONE member: STIM + (serverTime, uint32). v2 also sent TIME -- that is MEC's field, not + FIFA 17's. Dropped.""" + return OrderedDict([("STIM", (INT, int(time.time())))]) + + +# ==================================== Authentication::login (1/0x0A) -- FORGED +# +# Blaze::Authentication::LoginResponse @0x14487d170 -- EXACTLY 5 members. +# NOTE the divergence from both MEC emulators: they emit CNTX, ERRC and a +# top-level SKEY. FIFA 17's LoginResponse has NONE of those -- CNTX/ERRC are +# the Blaze *error metadata* block, and the session key lives at SESS.KEY. + +def persona_details_fields(now: int) -> "OrderedDict": + """Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members.""" + return OrderedDict([ + ("DSNM", (STRING, PERSONA_NAME)), # displayName MUST be "CAGE" + ("LAST", (INT, now)), # lastAuthenticated uint32 + ("PID", (INT, PERSONA_ID)), # personaId int64 MUST be 33068179 + ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform enum -> pc + ("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE + ("XREF", (INT, EXT_ID)), # extId uint64 + ]) + + +def user_login_info_fields(sess: Session, now: int) -> "OrderedDict": + """Blaze::Authentication::UserLoginInfo @0x14487cb00 -- 8 members. + ('1CON' packs to 0x11 which sorts BELOW 'A'=0x21, so it is first.)""" + return OrderedDict([ + ("1CON", (INT, 0)), # isFirstConsoleLogin = false + ("BUID", (INT, USER_ID)), # blazeUserId -- MUST be != 0 + ("FRST", (INT, 0)), # isFirstLogin = false + ("KEY", (STRING, sess.session_key)), # sessionKey -- MUST be non-empty + ("LLOG", (INT, now)), # lastLoginDateTime + ("MAIL", (STRING, EMAIL)), # email + ("PDTL", (STRUCT, persona_details_fields(now))), + ("UID", (INT, USER_ID)), # userId -- MUST be != 0 + ]) + + +def login_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Authentication::LoginResponse @0x14487d170 -- 5 members only.""" + now = int(time.time()) + return OrderedDict([ + ("ANON", (INT, 0)), # isAnonymous -- 1 would give a guest session + ("NTOS", (INT, 0)), # needsLegalDoc -- 1 diverts to the legal-doc flow + ("SESS", (STRUCT, user_login_info_fields(sess, now))), + ("SPAM", (INT, 1)), # isOfLegalContactAge + ("UNDR", (INT, 0)), # isUnderage -- 1 strips online features + ]) + + +# ============================ UserSessions notifications (component 0x7802) +# +# Authentication (0x0001) publishes NO notifications at all -- its +0x28 slot is +# getRestResourceInfo, not getNotificationName. The login-success notification +# lives on UserSessions, whose getNotificationName (0x146de19a0) is clean, +# unmutated code with a 12-entry jump table at 0x141b03f70: +# 1 UserSessionExtendedDataUpdate 2 UserAdded 3 UserRemoved +# 5 UserUpdated 8 UserAuthenticated 9 UserUnauthenticated +# 12 ServerDraining + +# CGID (connectionGroupObjectId) is an ObjectId triple. heat2's OBJID encoding +# is UNVERIFIED on the wire and a wrong encoding desynchronises the whole TDF +# parse, whereas an ABSENT member simply keeps its client-side default. So we +# omit it. Flip this once OBJID is confirmed against a real capture. +EMIT_OBJID_FIELDS = False + + +def user_session_login_info_fields(sess: Session, now: int) -> "OrderedDict": + """Blaze::UserSessionLoginInfo @0x14486f920 -- 16 members. This is a + SUPERSET of UserLoginInfo with the persona fields flattened in rather than + nested. KEY must be byte-identical to LoginResponse.SESS.KEY.""" + f = OrderedDict([ + ("1CON", (INT, 0)), # isFirstConsoleLogin + ("ALOC", (INT, sess.account_locale)), # accountLocale (echo client's) + ("BUID", (INT, USER_ID)), # blazeUserId + ("DSNM", (STRING, PERSONA_NAME)), # displayName + ("FRST", (INT, 0)), # isFirstLogin + ("KEY", (STRING, sess.session_key)), # sessionKey <- SAME string + ("LAST", (INT, now)), # lastAuthenticated + ("LLOG", (INT, now)), # lastLoginDateTime + ("MAIL", (STRING, EMAIL)), # email + ("NASP", (STRING, PERSONA_NAMESPACE)), # must match PreAuthResponse + ("PID", (INT, PERSONA_ID)), # personaId + ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform + ("UID", (INT, USER_ID)), # userId + ("USTP", (INT, USER_SESSION_TYPE)), # userSessionType + ("XREF", (INT, EXT_ID)), # extId + ]) + if EMIT_OBJID_FIELDS: + from heat2 import OBJID + f["CGID"] = (OBJID, (COMP_USERSESSIONS, 1, USER_ID)) + return f + + +def network_qos_data_fields() -> "OrderedDict": + """Blaze::Util::NetworkQosData @0x14486e680 -- 5 members. + NATT = NatType; 0 = OPEN, which is what we want offline.""" + return OrderedDict([ + ("BWHR", (INT, 0)), # bandwidthHostedRate + ("DBPS", (INT, 100000)), # downstream bits/s + ("NAHR", (INT, 0)), # natHostedRate + ("NATT", (INT, 0)), # NatType -> OPEN + ("UBPS", (INT, 100000)), # upstream bits/s + ]) + + +def user_session_extended_data_fields() -> "OrderedDict": + """Blaze::UserSessionExtendedData @0x144870390 -- 12 members. + + TWO FIFA-17-SPECIFIC DELTAS vs the MEC emulators: FIFA HAS `PSLM` + (latencyList) which they lack, and FIFA has `BPS` as a TOP-LEVEL string + member whereas they bury it inside the ADDR union. Follow FIFA's layout. + + ADDR (NetworkAddress union), CVAR (variable) and ULST (list) are + omitted: heat2's UNION/OBJID encodings are unverified and a bad one breaks + the whole parse, while an absent member just keeps its default.""" + return OrderedDict([ + ("BPS", (STRING, "openfut")), # bestPingSiteAlias + ("CTY", (STRING, "US")), # country + ("DMAP", (MAP, (INT, INT, []))), # dataMap map + ("HWFG", (INT, 0)), # hardwareFlags bitfield + ("ISP", (STRING, "OpenFUT")), # iSP + ("PSLM", (LIST, (INT, [0]))), # latencyList <- FIFA-only + ("QDAT", (STRUCT, network_qos_data_fields())), + ("TZ", (STRING, "")), # timeZone + ("UATT", (INT, 0)), # userInfoAttribute + ]) + + +def user_session_extended_data_update_fields() -> "OrderedDict": + """Blaze::UserSessionExtendedDataUpdate @0x1448703e0 -- 3 members.""" + return OrderedDict([ + ("DATA", (STRUCT, user_session_extended_data_fields())), + ("SUBS", (INT, 1)), # subscribed + ("USID", (INT, USER_ID)), # userId + ]) + + +def user_identification_fields() -> "OrderedDict": + """Blaze::UserIdentification @0x14486ebc0 -- 9 members.""" + return OrderedDict([ + ("AID", (INT, USER_ID)), # accountId + ("ALOC", (INT, ACCOUNT_LOCALE_FALLBACK)), # accountLocale + ("EXBB", (BLOB, b"")), # externalBlob + ("EXID", (INT, EXT_ID)), # externalId + ("ID", (INT, USER_ID)), # blazeId + ("NAME", (STRING, PERSONA_NAME)), # name + ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace + ("ORIG", (INT, PERSONA_ID)), # originPersonaId + ("PIDI", (INT, PERSONA_ID)), # pidId + ]) + + +def user_data_fields() -> "OrderedDict": + """Blaze::UserData @0x1448706b0 -- 3 members. Payload of UserAdded (2). + FLGS is a UserDataFlags bitfield; bit0 = online/authenticated.""" + return OrderedDict([ + ("EDAT", (STRUCT, user_session_extended_data_fields())), + ("FLGS", (INT, 3)), + ("USER", (STRUCT, user_identification_fields())), + ]) + + +def build_login_notifications(sess: Session, now: int) -> list: + """The push sequence the client waits on after a successful login.""" + return [ + ("UserAuthenticated", notification( + COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, + encode_tdf(user_session_login_info_fields(sess, now)))), + ("UserSessionExtendedDataUpdate", notification( + COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, + encode_tdf(user_session_extended_data_update_fields()))), + ("UserAdded", notification( + COMP_USERSESSIONS, NOTIFY_USER_ADDED, + encode_tdf(user_data_fields()))), + ] + + +# ================================================ post-login RPC bodies + +def post_auth_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Util::PostAuthResponse @0x144875810 -- TELE, TICK, UROP. + Telemetry/ticker are pointed at a dead local port on purpose: we want the + client to have a well-formed config and then fail to connect quietly rather + than resolve a real EA hostname.""" + tele = OrderedDict([ # GetTelemetryServerResponse (15) + ("ADRS", (STRING, "127.0.0.1")), + ("ANON", (INT, 0)), + ("DISA", (STRING, "")), + ("EDCT", (INT, 0)), + ("FILT", (STRING, "")), + ("LOC", (INT, sess.account_locale)), + ("MINR", (INT, 0)), + ("NOOK", (STRING, "")), + ("PORT", (INT, 9988)), + ("SDLY", (INT, 15000)), + ("SESS", (STRING, sess.session_key)), + ("SKEY", (STRING, "")), + ("SPCT", (INT, 75)), + ("STIM", (STRING, "")), + ("SVNM", (STRING, "telemetry-openfut")), + ]) + tick = OrderedDict([ # GetTickerServerResponse (3) + ("ADRS", (STRING, "127.0.0.1")), + ("PORT", (INT, 8999)), + ("SKEY", (STRING, "")), + ]) + urop = OrderedDict([ # UserOptions (2) + ("TMOP", (INT, 0)), # TelemetryOpt -> out/disabled + ("UID", (INT, USER_ID)), + ]) + return OrderedDict([ + ("TELE", (STRUCT, tele)), + ("TICK", (STRUCT, tick)), + ("UROP", (STRUCT, urop)), + ]) + + +def entitlement_fields(now: int) -> "OrderedDict": + """Blaze::Authentication::Entitlement @0x14487d490 -- 16 members. + The retail exe requires TAG='ONLINE_ACCESS' tied to offer 1027460, STAT + active, PID 33068179. Failure modes: AUTH_ERR_NO_SUCH_ENTITLEMENT (63), + AUTH_ERR_ENTITLEMENT_TAG_REQUIRED (74).""" + day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) + return OrderedDict([ + ("DEVI", (STRING, "")), # deviceUri + ("GDAY", (STRING, "2016-09-01T00:00:00Z")), # grantDate + ("GNAM", (STRING, ENTITLEMENT_GROUP)), # groupName + ("ID", (INT, 1)), # id + ("ISCO", (INT, 0)), # isConsumable + ("PID", (INT, PERSONA_ID)), # personaId + ("PJID", (STRING, CONTENT_ID)), # projectId (EA offer id) + ("PRCA", (INT, 2)), # productCatalog + ("PRID", (STRING, CONTENT_ID)), # productId + ("STAT", (INT, 1)), # EntitlementStatus -> ACTIVE (1, verified) + ("STRC", (INT, 0)), # statusReasonCode + ("TAG", (STRING, ENTITLEMENT_TAG)), # entitlementTag + ("TDAY", (STRING, "")), # terminationDate (never) + ("TYPE", (INT, 1)), # EntitlementType -> ONLINE_ACCESS (1, verified) + ("UCNT", (INT, 0)), # useCount + ("VER", (INT, 1)), # version + ]) + del day # (kept for readability of the date format above) + + +def entitlements_response_fields() -> "OrderedDict": + """Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST.""" + now = int(time.time()) + return OrderedDict([ + ("NLST", (LIST, (STRUCT, [entitlement_fields(now)]))), + ]) + + +def get_auth_token_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Authentication::GetAuthTokenResponse @0x14487d080 -- 1 member.""" + tok = sess.auth_code or ("OPENFUT-" + sess.session_key[:16]) + return OrderedDict([("AUTH", (STRING, tok))]) + + +def user_settings_response_fields() -> "OrderedDict": + """TODO(verify): Util::userSettingsLoad's response descriptor was not + reflected. Both independent clean-room emulators use a single `DATA` + string, and an unknown-tag payload is ignored rather than fatal, so an empty + DATA is the safe minimum -- the client falls back to defaults.""" + return OrderedDict([("DATA", (STRING, ""))]) + + +def get_lists_response_fields() -> "OrderedDict": + """TODO(verify): AssociationLists::getLists (25/6) response is 3P-only + (GetListsResponse{LMAP: list}). FIFA's list names are NOT + verified -- do not invent them. An EMPTY list is well-formed and means + 'this user has no association lists', which is true offline.""" + return OrderedDict([("LMAP", (LIST, (STRUCT, [])))]) + + +# ================================================================== dispatch + +def extract_service_name(fields) -> str: + """PreAuthRequest.CDAT.SVCN -- echo it back as INST.""" + try: + cdat = fields.get("CDAT") + if cdat and cdat[0] == STRUCT: + svcn = cdat[1].get("SVCN") + if svcn and svcn[0] == STRING and svcn[1]: + return svcn[1] + except Exception: + pass + return "fifa-2017-pc" + + +def find_nested_int(fields, tag: str): + """Depth-first search for an INT member `tag` anywhere in a decoded TDF.""" + if not isinstance(fields, dict): + return None + for k, (t, v) in fields.items(): + if k == tag and t == INT: + return v + if t == STRUCT: + r = find_nested_int(v, tag) + if r is not None: + return r + return None + + +def get_str(fields, tag: str, default: str = "") -> str: + try: + tv = fields.get(tag) + if tv and tv[0] == STRING: + return tv[1] + except Exception: + pass + return default + + +def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list: + """-> list of frames to send back, in order (may be empty).""" + comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"] + + # Transport-level PING frame (msgType 4) -> PING_REPLY (5), empty body. + if mtype == PING: + log(" -> transport PING, answering PING_REPLY (empty)") + return [reply_to(hdr, b"", msg_type=PING_REPLY)] + + if mtype not in (MESSAGE,): + log(" -> msgType %s is not a request; not answering" + % MSGTYPE_NAME.get(mtype, mtype)) + return [] + + # ---------------------------------------------------------------- Util + if comp == COMP_UTIL: + if cmd == CMD_PREAUTH: + sess.service_name = (extract_service_name(fields) + if fields is not None else "fifa-2017-pc") + loc = None + if fields is not None: + loc = find_nested_int(fields, "LANG") + if loc is None: + loc = find_nested_int(fields, "LOC") + if loc: + sess.account_locale = loc + log(" -- client locale 0x%08x captured for ALOC" % loc) + resp = preauth_response_fields(service_name=sess.service_name) + payload = encode_tdf(resp) + log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" + % (sess.service_name, len(payload), heat2.dump(resp))) + return [reply_to(hdr, payload)] + + if cmd == CMD_PING: + resp = ping_response_fields() + log(" -> PingResponse STIM=%d" % resp["STIM"][1]) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_FETCHCLIENTCONFIG: + cfid = get_str(fields or {}, "CFID", "") + resp = fetch_config_response_fields(cfid) + n = len(resp["CONF"][1][2]) + log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s" + % (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)")) + for k, v in resp["CONF"][1][2]: + log(" %-32s = %s" % (k, v)) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_POSTAUTH: + resp = post_auth_response_fields(sess) + log(" -> PostAuthResponse (TELE/TICK/UROP)") + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_SETCLIENTSTATE: + log(" -> setClientState: empty REPLY (no response TDF)") + return [reply_to(hdr, b"")] + + if cmd == CMD_SETCLIENTMETRICS: + log(" -> setClientMetrics: empty REPLY (no response TDF)") + return [reply_to(hdr, b"")] + + if cmd == CMD_USERSETTINGSLOAD: + log(" -> UserSettingsResponse (empty DATA; TODO verify descriptor)") + return [reply_to(hdr, encode_tdf(user_settings_response_fields()))] + + if cmd == CMD_USERSETTINGSSAVE: + log(" -> userSettingsSave: empty REPLY (accepted, discarded)") + return [reply_to(hdr, b"")] + + if cmd == 0x15: # fetchQosConfig -> proven-good QosConfigInfo body + log(" -> QosConfigInfo (fetchQosConfig)") + return [reply_to(hdr, encode_tdf(qos_config()))] + + # ------------------------------------------------------ Authentication + if comp == COMP_AUTH: + if cmd == CMD_LOGIN: + sess.auth_code = get_str(fields or {}, "AUTH", "") + sess.logged_in = True + sess.login_time = int(time.time()) + log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus " + "validation -- forged offline session)" % sess.auth_code) + resp = login_response_fields(sess) + payload = encode_tdf(resp) + log(" -> LoginResponse (%d bytes):\n%s" + % (len(payload), heat2.dump(resp))) + notifs = build_login_notifications(sess, sess.login_time) + out = [] + if NOTIFY_BEFORE_LOGIN_REPLY: + for name, fr in notifs: + log(" ~> NOTIFY 0x7802/0x%04x %s" % + (parse_fire2_header(fr)["command"], name)) + out.append(fr) + out.append(reply_to(hdr, payload)) + else: + out.append(reply_to(hdr, payload)) + for name, fr in notifs: + log(" ~> NOTIFY 0x7802/0x%04x %s" % + (parse_fire2_header(fr)["command"], name)) + out.append(fr) + return out + + if cmd in (CMD_TRUSTEDLOGIN, CMD_EXPRESSLOGIN): + # Same forged session; the request fields differ but we ignore them. + sess.logged_in = True + sess.login_time = int(time.time()) + log(" == Authentication::%s -> same forged LoginResponse" + % AUTH_CMDS.get(cmd, cmd)) + out = [reply_to(hdr, encode_tdf(login_response_fields(sess)))] + out += [fr for _, fr in build_login_notifications(sess, + sess.login_time)] + return out + + if cmd == CMD_LOGOUT: + # An empty REPLY was already correct on the wire (logout has NO + # request and NO response TDF -- no LogoutRequest/LogoutResponse + # exists in the client's type index). + # + # CORRECTION (2026-07-30): logout here is NOT proof of failure. + # The OSDK login state ids recovered from the registration block + # at 0x147159154-0x147159680 are: + # CheckUser 100, Isp 200, RecheckUser 300, + # LoadIspAccountInfo 310, Connect 400, *Logout 500*, + # VersionCheck 700, PCLogin 800, LoginComplete 1000, + # VerifyAccount 1300, UpgradeAccount 1350 + # LoginStateLogout sits BETWEEN Connect and VersionCheck: it is the + # normal "drop any stale Blaze session before authenticating" + # step. Seeing 1/0x46 immediately after the six fetchClientConfig + # replies is therefore expected. What matters is whether + # login (1/0x0A) EVER arrives afterwards. + sess.saw_logout = True + if sess.logged_in: + log(" -- Authentication::logout (1/0x46) after a successful " + "login: normal teardown.") + else: + log(" -- Authentication::logout (1/0x46) with no prior login. " + "This is LoginStateLogout (id 500), a normal pre-login " + "step. If no login (1/0x0A) follows within ~60s, LAYER 1 " + "(Origin/LSX on 127.0.0.1:4216) never flipped " + "OriginMgr.m_isLoggedIn -- i.e. the pushed " + " " + "frame is missing. Run lsx_responder_v2.py.") + return [reply_to(hdr, b"")] + + if cmd in (CMD_LISTUSERENTITLEMENTS2, 0x20, 0x30, 0x27): + # 0x1D listUserEntitlements2 / 0x20 listEntitlements / + # 0x30 listPersonaEntitlements2 / 0x27 grantEntitlement2 -- all return + # the ONLINE_ACCESS entitlement so the client sees it however it asks. + log(" -> Entitlements{NLST:[%s / offer %s / ACTIVE]} (cmd 0x%02x)" + % (ENTITLEMENT_TAG, CONTENT_ID, cmd)) + return [reply_to(hdr, encode_tdf(entitlements_response_fields()))] + + if cmd == CMD_GETAUTHTOKEN: + resp = get_auth_token_response_fields(sess) + log(" -> GetAuthTokenResponse AUTH=%r" % resp["AUTH"][1]) + return [reply_to(hdr, encode_tdf(resp))] + + # ------------------------------------------------------- UserSessions + if comp == COMP_USERSESSIONS and cmd == CMD_UPDATENETWORKINFO: + log(" -> updateNetworkInfo: empty REPLY, then re-push " + "UserSessionExtendedDataUpdate") + return [ + reply_to(hdr, b""), + notification(COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, + encode_tdf(user_session_extended_data_update_fields())), + ] + + # --------------------------------------------------- AssociationLists + if comp == COMP_ASSOCLISTS and cmd == CMD_GETLISTS: + log(" -> GetListsResponse{LMAP: []} (TODO verify FIFA's list names)") + return [reply_to(hdr, encode_tdf(get_lists_response_fields()))] + + # ---------------------------------------------------------------- TODO + # Still unimplemented, in the order they are expected to show up: + # Util::fetchQosConfig (9/0x15) -> QosConfigInfo (see qos_config) + # Util::localizeStrings (9/4) -> echo the requested ids + # Messaging::fetchMessages (15/2) -> empty list + # Stats / GameReporting / GameManager -> FUT-mode specific, later + # Authentication::getTermsOfServiceContent (1/0xF6) and + # getPrivacyPolicyContent (1/0x2F) -> only reached if NTOS != 0 + # Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED + # (three clean-room sources disagree: header[14:16] vs metadata ERRC vs + # payload CNTX/ERRC) -- do not emit one until it is verified on the wire. + # ---------------------------------------------------------------------- + + if REPLY_EMPTY_TO_UNKNOWN: + log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not " + "hang (all fields fall back to client-side defaults)" + % rpc_name(comp, cmd, mtype)) + return [reply_to(hdr, b"")] + + log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd, mtype)) + return [] + + +# ============================================================= blaze server + +def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool: + """Fill `buf` to at least n bytes. False on clean EOF / short close.""" + while len(buf) < n: + try: + chunk = sock.recv(65536) + except socket.timeout: + return False + if not chunk: + return False + buf += chunk + return True + + +_frame_counter = [0] + + +def blaze_handle(raw: socket.socket, addr) -> None: + log("*** BLAZE CONNECT from %s ***" % (addr,)) + sess = Session() + log(" session key minted: %s" % sess.session_key) + buf = bytearray() + raw.settimeout(300) + try: + while True: + if not recv_exactly(raw, FIRE2_HDR, buf): + break + hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR])) + total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"] + if hdr["payload_len"] > 4 * 1024 * 1024: + log("BLAZE %s: absurd payload_len %d, dropping connection\n%s" + % (addr, hdr["payload_len"], hexdump(bytes(buf[:64])))) + break + if not recv_exactly(raw, total, buf): + log("BLAZE %s: EOF mid-frame (want %d, have %d)" + % (addr, total, len(buf))) + break + + frame = bytes(buf[:total]) + del buf[:total] + metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]] + payload = frame[FIRE2_HDR + hdr["metadata_len"]:] + + _frame_counter[0] += 1 + n = _frame_counter[0] + log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x " + "meta=%dB payload=%dB" + % (n, rpc_name(hdr["component"], hdr["command"], hdr["msg_type"]), + MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), + hdr["msg_num"], hdr["user_index"], hdr["options"], + hdr["metadata_len"], hdr["payload_len"])) + log("RX #%d HEX:\n%s" % (n, hexdump(frame))) + if metadata: + log("RX #%d METADATA:\n%s" % (n, hexdump(metadata))) + if DUMP_FRAMES: + try: + os.makedirs(RXDIR, exist_ok=True) + fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin" + % (n, hdr["component"], hdr["command"])) + with open(fn, "wb") as fh: + fh.write(frame) + log("RX #%d saved -> %s" % (n, fn)) + except Exception as e: + log("RX #%d save failed: %s" % (n, e)) + + fields = None + if payload: + try: + fields = decode_tdf(payload) + log("RX #%d TDF:\n%s" % (n, heat2.dump(fields))) + except Exception as e: + log("RX #%d TDF DECODE FAILED: %s" % (n, e)) + else: + log("RX #%d TDF: (empty payload)" % n) + + try: + outs = dispatch(hdr, fields, payload, sess) + except Exception as e: + log("RX #%d DISPATCH ERROR: %r" % (n, e)) + outs = [] + + for k, out in enumerate(outs): + raw.sendall(out) + ohdr = parse_fire2_header(out) + log("TX #%d.%d %s msgType=%s msgNum=%d %dB total (%d payload)" + % (n, k, + rpc_name(ohdr["component"], ohdr["command"], + ohdr["msg_type"]), + MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), + ohdr["msg_num"], len(out), ohdr["payload_len"])) + log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024))) + except ConnectionResetError: + log("BLAZE %s: connection reset by client" % (addr,)) + except Exception as e: + log("BLAZE %s ERR: %r" % (addr, e)) + finally: + try: + raw.close() + except Exception: + pass + log("BLAZE %s: closed" % (addr,)) + + +# ======================================================= redirector (TLS) + +def build_redirect_response() -> bytes: + # ServerInstanceInfo.address is a ServerAddress union -> Heat2 XML union is + #
... member="0" = ipAddress variant + # {hostname, ip(uint32 decimal), port(uint16)}. + body = ( + '\n' + '\n' + '\t
\n' + '\t\t\n' + f'\t\t\t{BLAZE_IP_STR}\n' + f'\t\t\t{BLAZE_IP_U32}\n' + f'\t\t\t{BLAZE_PORT}\n' + '\t\t\n' + '\t
\n' + '\t0\n' + '\t\n' + '\t0\n' + '
\n' + ) + b = body.encode() + hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n" + f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode() + return hdr + b + + +def make_tls_context(): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERT, KEY) + ctx.minimum_version = ssl.TLSVersion.TLSv1 + ctx.set_ciphers("ALL:@SECLEVEL=0") + return ctx + + +_ctx = [None] + + +def redir_handle(raw: socket.socket, addr) -> None: + try: + tls = _ctx[0].wrap_socket(raw, server_side=True) + except ssl.SSLError as e: + log("REDIR REJECTED %s: %s" % (addr, e)) + raw.close() + return + log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0])) + try: + tls.settimeout(8) + req = b"" + while b"\r\n\r\n" not in req: + c = tls.recv(4096) + if not c: + break + req += c + if b"content-length:" in req.lower(): + head, _, rest = req.partition(b"\r\n\r\n") + cl = int([l.split(b":")[1] for l in head.split(b"\r\n") + if l.lower().startswith(b"content-length")][0]) + while len(rest) < cl: + c = tls.recv(4096) + if not c: + break + rest += c + req = head + b"\r\n\r\n" + rest + line0 = req.split(b"\r\n", 1)[0].decode(errors="replace") + log("REDIR REQ %s: %s" % (addr, line0)) + resp = build_redirect_response() + tls.sendall(resp) + log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d" + % (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT)) + time.sleep(0.3) + tls.close() + except Exception as e: + log("REDIR ERR %s: %s" % (addr, e)) + + +# ================================================ Nucleus OAuth stub (plain) +# +# LoginStateMachineImpl (string cluster 0x14389fd50-0x14389fef8) builds +# "/connect/token", POSTs "grant_type=client_credentials" +# with a "NEXUS_S2S " authorization header, and scrapes '"access_token" : "' +# out of the reply body. We advertise nucleusConnect = this listener, so the +# client can never reach accounts.ea.com. Note the exact spacing in the JSON: +# the client searches for the literal '"access_token" : "'. + +def nucleus_handle(raw: socket.socket, addr) -> None: + try: + raw.settimeout(10) + req = b"" + while b"\r\n\r\n" not in req and len(req) < 65536: + c = raw.recv(4096) + if not c: + break + req += c + head, _, rest = req.partition(b"\r\n\r\n") + line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else "" + log("NUCLEUS REQ %s: %s" % (addr, line0)) + if head: + log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace")) + if rest: + log("NUCLEUS BODY: %r" % rest[:512]) + + token = "OPENFUT_" + "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(40)) + # Spacing matters: the client greps for the literal '"access_token" : "'. + body = ('{\n "access_token" : "%s",\n "token_type" : "Bearer",\n' + ' "expires_in" : 14400,\n "id_token" : "%s",\n' + ' "refresh_token" : "%s"\n}\n' + % (token, token, token)).encode() + out = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + b"Cache-Control: no-store\r\nContent-Length: " + + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + raw.sendall(out) + log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token)) + except Exception as e: + log("NUCLEUS ERR %s: %s" % (addr, e)) + finally: + try: + raw.close() + except Exception: + pass + + +# ================================================================== serve + +def serve(port: int, handler, name: str) -> None: + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((HOST, port)) + s.listen(16) + log("%s listening on %s:%d" % (name, HOST, port)) + while True: + c, a = s.accept() + threading.Thread(target=handler, args=(c, a), daemon=True).start() + + +# ================================================================== selftest + +def _check_roundtrip(label: str, fields) -> bytes: + payload = encode_tdf(fields) + back = decode_tdf(payload) + again = encode_tdf(back) + assert again == payload, "%s: re-encode differs" % label + assert list(back.keys()) == sorted(fields.keys(), key=heat2.tag_key), \ + "%s: tag order %r" % (label, list(back.keys())) + return payload + + +def _selftest() -> None: + print("=" * 72) + print("blaze_responder_v3 selftest") + print("=" * 72) + + sess = Session() + sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0" + sess.account_locale = 0x656E5553 + now = 1469000000 + + # ---- 1. preAuth still round-trips (regression guard vs v2) + pre = preauth_response_fields() + p = _check_roundtrip("PreAuthResponse", pre) + fr = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, p) + assert fr[13] == 0x20, fr[13] + assert list(decode_tdf(p).keys()) == [ + "ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST", "MAID", "MINR", + "NASP", "PILD", "PLAT", "QOSS", "RSRC", "SVER"] + print("[ok] PreAuthResponse %4d payload bytes" % len(p)) + + # ---- 2. fetchClientConfig per CFID + for cfid in ["BlazeSDK", "OSDK_CORE", "OSDK_CLIENT", "OSDK_NUCLEUS", + "OSDK_WEBOFFER", "OSDK_ABUSE_REPORTING", + "OSDK_XMS_ABUSE_REPORTING", "IdentityParams", "TOTALLY_UNKNOWN"]: + f = fetch_config_response_fields(cfid) + pb = _check_roundtrip("FetchConfigResponse/" + cfid, f) + back = decode_tdf(pb) + assert list(back.keys()) == ["CONF"], back.keys() + kt, vt, items = back["CONF"][1] + assert (kt, vt) == (STRING, STRING) + assert items == client_config_for(cfid), cfid + print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes" + % (cfid, len(items), len(pb))) + assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []" + assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \ + "unknown CFID must still carry a CONF field (empty map, not empty frame)" + + # ---- 3. LoginResponse + lr = login_response_fields(sess) + lp = _check_roundtrip("LoginResponse", lr) + back = decode_tdf(lp) + assert list(back.keys()) == ["ANON", "NTOS", "SESS", "SPAM", "UNDR"], back.keys() + assert "CNTX" not in back and "ERRC" not in back and "SKEY" not in back + s = back["SESS"][1] + assert list(s.keys()) == ["1CON", "BUID", "FRST", "KEY", "LLOG", "MAIL", + "PDTL", "UID"], list(s.keys()) + assert s["KEY"][1] == sess.session_key + assert s["BUID"][1] == USER_ID != 0 and s["UID"][1] == USER_ID + d = s["PDTL"][1] + assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \ + list(d.keys()) + assert d["PID"][1] == PERSONA_ID == 33068179 + assert d["DSNM"][1] == PERSONA_NAME == "CAGE" + assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0 + lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp) + lh = parse_fire2_header(lfr) + assert (lh["component"], lh["command"], lh["msg_type"]) == (1, 0x0A, REPLY) + assert lfr[13] == 0x20 + print("[ok] LoginResponse %4d payload bytes, hdr %s" + % (len(lp), lfr[:16].hex(" "))) + + # ---- 4. UserAuthenticated notification + ua = user_session_login_info_fields(sess, now) + up = _check_roundtrip("UserSessionLoginInfo", ua) + nb = decode_tdf(up) + assert list(nb.keys()) == ["1CON", "ALOC", "BUID", "DSNM", "FRST", "KEY", + "LAST", "LLOG", "MAIL", "NASP", "PID", "PLAT", + "UID", "USTP", "XREF"], list(nb.keys()) + assert nb["KEY"][1] == sess.session_key, "notif KEY must match SESS.KEY" + assert nb["PID"][1] == PERSONA_ID and nb["DSNM"][1] == PERSONA_NAME + assert nb["NASP"][1] == PERSONA_NAMESPACE + nfr = notification(COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, up) + nh = parse_fire2_header(nfr) + assert (nh["component"], nh["command"]) == (0x7802, 0x0008), nh + assert nh["msg_type"] == NOTIFICATION and nh["msg_num"] == 0 + assert nfr[13] == 0x40, nfr[13] + print("[ok] UserAuthenticated notif %4d payload bytes, hdr %s" + % (len(up), nfr[:16].hex(" "))) + + # ---- 5. the other two notifications + post-login RPCs + for label, f in [ + ("UserSessionExtendedDataUpdate", user_session_extended_data_update_fields()), + ("UserAdded (UserData)", user_data_fields()), + ("PostAuthResponse", post_auth_response_fields(sess)), + ("Entitlements", entitlements_response_fields()), + ("GetAuthTokenResponse", get_auth_token_response_fields(sess)), + ("UserSettingsResponse", user_settings_response_fields()), + ("GetListsResponse", get_lists_response_fields()), + ("PingResponse", ping_response_fields()), + ]: + b = _check_roundtrip(label, f) + print("[ok] %-30s %4d payload bytes" % (label, len(b))) + assert list(ping_response_fields().keys()) == ["STIM"], \ + "PingResponse must carry ONLY STIM (TIME is MEC's, not FIFA 17's)" + + # ---- 6. the full login burst, framed + frames = [fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)] + \ + [fr for _, fr in build_login_notifications(sess, now)] + blob = b"".join(frames) + seen, i = [], 0 + while i < len(blob): + h = parse_fire2_header(blob[i:i + 16]) + tot = 16 + h["metadata_len"] + h["payload_len"] + seen.append((h["component"], h["command"], h["msg_type"])) + decode_tdf(blob[i + 16 + h["metadata_len"]:i + tot]) + i += tot + assert seen == [(0x0001, 0x000A, REPLY), + (0x7802, 0x0008, NOTIFICATION), + (0x7802, 0x0001, NOTIFICATION), + (0x7802, 0x0002, NOTIFICATION)], seen + print("[ok] login burst re-framed: %d frames / %d bytes" + % (len(frames), len(blob))) + + print() + print("---- LoginResponse -------------------------------------------------") + print(heat2.dump(lr)) + print() + print("---- NOTIFY 0x7802/0x0008 UserAuthenticated ------------------------") + print(heat2.dump(ua)) + print() + print("ALL SELFTESTS PASSED") + + +# ================================================================== main + +if __name__ == "__main__": + if "--selftest" in sys.argv: + _selftest() + raise SystemExit(0) + + log("=== RESPONDER v3 START (redir %d / blaze %d%s) ===" + % (REDIR_PORT, BLAZE_PORT, + (" / nucleus %d" % NUCLEUS_PORT) if NUCLEUS_STUB_ENABLED else "")) + log(" persona %d / %r namespace %r entitlement %s (offer %s)" + % (PERSONA_ID, PERSONA_NAME, PERSONA_NAMESPACE, ENTITLEMENT_TAG, + CONTENT_ID)) + log(" REMINDER: layer 1 first -- start lsx_responder.py BEFORE FIFA 17, " + "or the client sends logout (1/0x46) instead of login (1/0x0A).") + _ctx[0] = make_tls_context() + threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"), + daemon=True).start() + if NUCLEUS_STUB_ENABLED: + threading.Thread(target=serve, + args=(NUCLEUS_PORT, nucleus_handle, "NUCLEUS"), + daemon=True).start() + serve(REDIR_PORT, redir_handle, "REDIR") diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py index 593557f..b7c1b61 100644 --- a/fifa17-recon/tools/blaze_responder_v3b.py +++ b/fifa17-recon/tools/blaze_responder_v3b.py @@ -575,6 +575,9 @@ FUT_RS4_CONFIG = ( [("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES] + [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS_BOOT] + [("FUT_RS4_BASE_URL", UTAS_BASE)] + # NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any + # response -- proven inert (wf_96b6c0c5): they are JSON field names that route + # to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md. ) diff --git a/fifa17-recon/tools/capture_lsx.py b/fifa17-recon/tools/capture_lsx.py new file mode 100644 index 0000000..ad08db8 --- /dev/null +++ b/fifa17-recon/tools/capture_lsx.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Capture-first LSX diagnostic for FIFA 17 on 127.0.0.1:4216. + +Goal: learn the REAL handshake the game speaks, losing nothing to a crash. +- Detects who sends first (server-initiates vs client-initiates). +- Sends our best-guess Challenge, then captures the client's ChallengeResponse + (its key + exact XML format) -- FLUSHED TO DISK BEFORE we send anything risky. +- Then runs the full instrumented handshake (H, session key, encrypted loop), + logging every computed value + every frame both directions. +So even if the game crashes on our ChallengeAccepted, the key capture is saved. +""" +import socket, time, threading, os, sys +import lsx_responder as L # reuse crypto + build_reply; importing does NOT run main + +LOG = "/tmp/lsx_capture.log" +RAWDIR = "/tmp/lsx_raw" +os.makedirs(RAWDIR, exist_ok=True) +_n = 0 + +def log(m): + line = "[%s] %s" % (time.strftime("%H:%M:%S"), m) + print(line, flush=True) + with open(LOG, "a") as f: + f.write(line + "\n"); f.flush(); os.fsync(f.fileno()) + +def dump(tag, b): + log("%s : %dB" % (tag, len(b))) + for i in range(0, min(len(b), 512), 16): + c = b[i:i+16] + hx = " ".join("%02x" % x for x in c) + asc = "".join(chr(x) if 32 <= x < 127 else "." for x in c) + log(" %04x: %-47s %s" % (i, hx, asc)) + try: + p = "%s/%s_%d.bin" % (RAWDIR, tag.replace(" ", "_").replace("#", "").replace(":", ""), int(time.time()*1000) % 1000000) + open(p, "wb").write(b) + except Exception: + pass + +def serve(conn, addr): + global _n; _n += 1; n = _n + log("================= CONN #%d from %s =================" % (n, addr)) + # Phase 0 -- does the CLIENT speak first? (would mean lsx_responder has the + # initiator backwards, a prime crash suspect) + conn.settimeout(3.0) + try: + pre = conn.recv(8192) + if pre: + dump("C%d PRE (client spoke FIRST)" % n, pre) + log("C%d: !!! client initiates -- our server-sends-Challenge model is WRONG" % n) + except socket.timeout: + log("C%d: silent 3s -> server initiates (send Challenge)" % n) + pre = b"" + except Exception as e: + log("C%d pre-recv err: %s" % (n, e)); pre = b"" + + # Phase 1 -- send our best-guess plaintext Challenge, capture the reply. + chal = ('' + '' % (L.CHALLENGE_KEY, L.BUILD, L.VERSION)) + try: + conn.sendall(chal.encode() + b"\0") + log("C%d >> Challenge sent (%dB): %s" % (n, len(chal)+1, chal)) + except Exception as e: + log("C%d send-Challenge err: %s" % (n, e)); return + + try: + rep = conn.recv(8192) + except Exception as e: + log("C%d recv-after-Challenge err: %s" % (n, e)); return + if not rep: + log("C%d: client closed after our Challenge (no ChallengeResponse) -- " + "Challenge format likely rejected" % n); return + dump("C%d CHALLENGE-RESPONSE (client)" % n, rep) # <-- THE KEY CAPTURE, already flushed + + # Phase 2 -- log what WE would compute (do NOT let a crypto error abort logging) + txt = rep.split(b"\0")[0].decode(errors="replace") + log("C%d client-reply text: %s" % (n, txt)) + import re + mk = re.search(r'key="([^"]*)"', txt) + mr = re.search(r'response="([^"]*)"', txt) + client_key = mk.group(1) if mk else L.CHALLENGE_KEY + client_resp = mr.group(1) if mr else None + log("C%d parsed: client_key=%r client_response=%r" % (n, client_key, client_resp)) + try: + our_h = L.challenge_response(client_key) + log("C%d our computed H (ChallengeAccepted.response) = %s" % (n, our_h)) + if client_resp: + log("C%d MATCH client_response==our_H ? %s" % (n, client_resp == our_h)) + skey = L.derive_session_key(our_h) + log("C%d derived session_key = %s" % (n, skey.hex())) + except Exception as e: + log("C%d crypto compute err: %s" % (n, e)); our_h = None; skey = None + + # Phase 3 -- OPTIONAL: send ChallengeAccepted + run the encrypted loop. + # Guarded by env so the first run can stay capture-only (no risky send). + if os.environ.get("LSX_FULL") == "1" and our_h and skey: + try: + conn.sendall(L.resp(1, 'ChallengeAccepted response="%s"' % our_h, "EALS").encode() + b"\0") + log("C%d >> ChallengeAccepted sent" % n) + except Exception as e: + log("C%d send-Accepted err: %s" % (n, e)); return + while True: + try: + data = conn.recv(65536) + except Exception as e: + log("C%d recv-loop err: %s" % (n, e)); break + if not data: + log("C%d: client closed" % n); break + dump("C%d ENC-IN" % n, data) + for chunk in filter(None, data.split(b"\0")): + try: + xml = L.lsx_decrypt(chunk + b"\0", skey) + log("C%d << decrypted: %s" % (n, xml)) + except Exception as e: + log("C%d decrypt fail: %s (raw %s)" % (n, e, chunk[:40])); continue + mm = L.REQ_RE.search(xml) + if mm: + reply = L.build_reply(mm.group(1), mm.group(2), + dict(L.ATTR_RE.findall(mm.group(3)))) + try: + conn.sendall(L.lsx_encrypt(reply, skey)) + log("C%d >> %s" % (n, reply)) + except Exception as e: + log("C%d send-reply err: %s" % (n, e)); break + else: + log("C%d: capture-only (set LSX_FULL=1 to attempt full handshake). Holding 10s." % n) + time.sleep(10) + try: conn.close() + except Exception: pass + +def main(): + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("127.0.0.1", 4216)); s.listen(8) + log("=== capture_lsx listening on 127.0.0.1:4216 (LSX_FULL=%s) ===" % + os.environ.get("LSX_FULL", "0")) + while True: + c, a = s.accept() + threading.Thread(target=serve, args=(c, a), daemon=True).start() + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/decode_fire2.py b/fifa17-recon/tools/decode_fire2.py new file mode 100644 index 0000000..1a8f6b4 --- /dev/null +++ b/fifa17-recon/tools/decode_fire2.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Decode a captured Blaze Fire2 frame: 16-byte header + Heat2 TDF payload. +Clean-room: parses the wire bytes of our own client's traffic.""" +import sys, struct + +def decode_tag(b): + # Heat2 tag: 3 bytes -> 4 chars, each 6-bit; 0 -> ' ' (trimmed). char = v ? v+0x20 : ' ' + a,b1,c = b[0],b[1],b[2] + v=[ (a>>2)&0x3f, ((a&0x3)<<4)|((b1>>4)&0xf), ((b1&0xf)<<2)|((c>>6)&0x3), c&0x3f ] + return ''.join(chr(x+0x20) if x else ' ' for x in v).rstrip() + +TYPES={0x00:'int',0x01:'string',0x02:'blob',0x03:'struct',0x04:'list', + 0x05:'map',0x06:'union',0x07:'intlist',0x08:'objtype',0x09:'objid',0x0a:'float'} + +def read_varint(buf,i): + # Heat2 varint: 7 bits/byte, high bit = continue; first byte only 6 data bits (bit6=continue) + b=buf[i]; i+=1 + val=b&0x3f + if b&0x80: + shift=6 + while True: + b=buf[i]; i+=1 + val|=(b&0x7f)<end: + print(f"{pad}[trailing {buf[i:end].hex()}]"); break + tag=decode_tag(buf[i:i+3]); typ=buf[i+3]; i+=4 + tn=TYPES.get(typ,f'0x{typ:02x}') + if typ==0x00: # int varint + v,i=read_varint(buf,i); print(f"{pad}{tag} (int) = {v}") + elif typ==0x01: # string: varint len + bytes (incl null) + ln,i=read_varint(buf,i); s=buf[i:i+ln]; i+=ln + print(f"{pad}{tag} (str) = {s.rstrip(bytes([0])).decode(errors='replace')!r}") + elif typ==0x02: # blob + ln,i=read_varint(buf,i); print(f"{pad}{tag} (blob[{ln}]) = {buf[i:i+ln].hex()}"); i+=ln + elif typ==0x03: # struct: nested until 0x00 terminator + print(f"{pad}{tag} (struct) {{") + i=walk(buf,depth+1,i,end) # walk handles 0x00 term + print(f"{pad}}}") + else: + # unknown/complex: dump remainder briefly and stop this level + print(f"{pad}{tag} ({tn}) {buf[i:min(i+24,end)].hex()}") + # best-effort: skip nothing, bail to avoid misparse + return end + if iI',data[0:4])[0] + comp=struct.unpack('>H',data[6:8])[0] + cmd=struct.unpack('>H',data[8:10])[0] + err=struct.unpack('>H',data[10:12])[0] + mtyp=data[12] + print(f"== {sys.argv[1]} ==") + print(f"Fire2 header: len={ln} component=0x{comp:04x} command=0x{cmd:04x} error=0x{err:04x} msgtype=0x{mtyp:02x}") + print(f"payload ({len(data)-16} bytes):") + walk(data[16:]) + +main() diff --git a/fifa17-recon/tools/dump_login_code.py b/fifa17-recon/tools/dump_login_code.py new file mode 100755 index 0000000..0e0f05a --- /dev/null +++ b/fifa17-recon/tools/dump_login_code.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Dump the DECRYPTED FIFA17 login machinery from live /proc/PID/mem. + +FIFA17.exe's .data-region code is packed/encrypted on disk (objdump of the file is +garbage); the real instructions only exist decrypted in memory at runtime. This grabs +generous windows around the known login-path VAs (from prior live recon) plus the +resolved OriginMgr / session objects, then disassembles each window at its true VA so a +follow-up reversing pass works on real code. + +Run while FIFA17 is running (ptrace_scope=0). Output -> ./login_dump/ + manifest.txt. +""" +import glob, os, subprocess, struct, sys + +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "login_dump") +os.makedirs(OUT, exist_ok=True) + +# (name, VA, bytes_before, bytes_after) — code windows around the login machinery. +CODE = [ + ("dispatch_case2", 0x146f1e080, 0x120, 0x180), # event dispatcher; case-2 sets m_isLoggedIn + ("event_matcher", 0x147102880, 0x40, 0x400), # sender/element matcher + ("login_parser", 0x147138660, 0x40, 0x400), # element parser (reads IsLoggedIn) + ("loginstate_pclogin", 0x1471b58e0, 0x40, 0x600), # LoginStatePCLogin entry + ("txt_not_login_ebisu", 0x1471b5b00, 0x40, 0x400), # TXT_NOT_LOGIN_TO_EBISU write site(s) + ("pclogin_callsite", 0x1471b6780, 0x60, 0x120), # session-object call: ff 50 60 (vtbl+0x60) +] + +# Data pointers to resolve (name, ptr_VA, deref_chain_offsets, dump_span). +# We read *[ptr_VA], then optionally add offsets, then dump `span` bytes there. +DATA = [ + ("originmgr", 0x1448acf50, [], 0x80), # OriginMgr; m_isLoggedIn @+0x13, loginError @+0x14 + ("online_flags", 0x1448a3ac0, [], 0x40), # "internet reachable" byte lives here + ("auth_block", 0x1448a3b20, [0x4e98], 0x60), # auth slots +0x08/+0x10 + ("session_obj", 0x144b86bf8, [], 0x80), # LoginStatePCLogin session object (vtbl @+0) +] + +def find_pid(): + for d in glob.glob('/proc/[0-9]*'): + try: + if open(d + '/comm').read().strip() == 'FIFA17.exe': + return int(os.path.basename(d)) + except Exception: + pass + return None + +def read(f, va, n): + f.seek(va); return f.read(n) + +def rd_u64(f, va): + b = read(f, va, 8) + return struct.unpack(' {os.path.basename(p)}[.asm]" + print(line); man.write(line + "\n") + man.write("\n") + for name, ptr, chain, span in DATA: + base = rd_u64(f, ptr) + addr = base + trail = f"*[{ptr:#x}]={base:#x}" + for off in chain: + nxt = rd_u64(f, addr + off) if off and base else base + # for a single deref-with-offset we dump AT base+off, not deref again: + addr = base + target = base + (chain[0] if chain else 0) + data = read(f, target, span) if base else b"" + p = os.path.join(OUT, f"{name}_{target:x}.bin") + open(p, "wb").write(data) + # hex preview + hexp = " ".join("%02x" % x for x in data[:0x40]) + line = f"DATA {name:22s} {trail} dump@{target:#x} ({len(data)}B) -> {os.path.basename(p)}\n first64: {hexp}" + print(line); man.write(line + "\n") + man.close() + print("\nWrote dumps + manifest to", OUT) + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/fifadrive.sh b/fifa17-recon/tools/fifadrive.sh new file mode 100644 index 0000000..a8b882f --- /dev/null +++ b/fifa17-recon/tools/fifadrive.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# ============================================================================ +# fifadrive.sh — drive & observe FIFA 17's menu from a shell (OpenFUT recon) +# +# FIFA 17 runs under Proton as an XWayland client. XWayland accepts X11 +# synthetic input (XTEST, via xdotool) and per-window capture (XGetImage, via +# ImageMagick `import`). So we can navigate the menus and screenshot the result +# entirely headless — no manual relaunch/navigation per test. +# +# ./fifadrive.sh wid print the FIFA window id (empty if not up) +# ./fifadrive.sh focus raise + focus the FIFA window +# ./fifadrive.sh shot [name] capture FIFA window -> screens/.png (print path) +# ./fifadrive.sh key focus, then send key(s) e.g. key Right Right Return +# ./fifadrive.sh hold press-and-hold a key for (menus that need a beat) +# ./fifadrive.sh type focus, then type literal text (e.g. security answer) +# ./fifadrive.sh launch (re)launch FIFA via ~/Desktop/launch-fifa17.sh +# ./fifadrive.sh wait poll until the FIFA window appears (after launch) +# +# XTEST injects to the FOCUSED window, so every input auto-focuses FIFA first. +# The higher-level "read the screenshot and decide the next key" step is done by +# the operator (Claude Reads the PNG) — this script only provides the primitives. +# ============================================================================ +set -uo pipefail +export DISPLAY="${DISPLAY:-:0}" +HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +SCREENS="${FIFADRIVE_SCREENS:-$HERE/../.screens}" +TITLE='^FIFA 17$' +LAUNCH="$HOME/Desktop/launch-fifa17.sh" + +wid() { xdotool search --name "$TITLE" 2>/dev/null | head -1; } + +need_wid() { + local w; w=$(wid) + [ -n "$w" ] || { echo "!! FIFA window not found (is the game running?)" >&2; exit 3; } + printf '%s' "$w" +} + +focus() { + local w; w=$(need_wid) + xdotool windowactivate --sync "$w" 2>/dev/null + # tiny settle so the compositor finishes the focus switch before we inject + perl -e 'select(undef,undef,undef,0.20)' 2>/dev/null || sleep 1 +} + +shot() { + local w name out; w=$(need_wid); name="${1:-shot}" + mkdir -p "$SCREENS" + out="$SCREENS/${name}.png" + import -window "$w" "$out" 2>/dev/null || { echo "!! capture failed" >&2; exit 4; } + echo "$out" +} + +case "${1:-}" in + wid) wid; echo ;; + focus) focus; echo "focused: $(xdotool getactivewindow getwindowname 2>/dev/null)" ;; + shot) shift; shot "${1:-shot}" ;; + key) shift; focus; xdotool key --clearmodifiers "$@"; echo "sent: $*" ;; + hold) shift; focus; k="$1"; ms="${2:-300}" + xdotool keydown --clearmodifiers "$k"; perl -e "select(undef,undef,undef,$ms/1000)" 2>/dev/null || sleep 1 + xdotool keyup --clearmodifiers "$k"; echo "held: $k ${ms}ms" ;; + type) shift; focus; xdotool type --clearmodifiers -- "$*"; echo "typed: $*" ;; + launch) [ -x "$LAUNCH" ] || { echo "!! no launch script at $LAUNCH" >&2; exit 5; } + setsid bash -c "exec '$LAUNCH'" /tmp/fifa_launch.log 2>&1 & disown + echo "launched (log: /tmp/fifa_launch.log)" ;; + wait) shift; secs="${1:-60}"; i=0 + while [ $i -lt "$secs" ]; do [ -n "$(wid)" ] && { echo "FIFA window up (${i}s)"; exit 0; }; sleep 1; i=$((i+1)); done + echo "!! FIFA window did not appear in ${secs}s" >&2; exit 6 ;; + *) sed -n '2,32p' "$0"; exit 1 ;; +esac diff --git a/fifa17-recon/tools/force_login_flag.py b/fifa17-recon/tools/force_login_flag.py new file mode 100644 index 0000000..5529ea0 --- /dev/null +++ b/fifa17-recon/tools/force_login_flag.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Continuously PIN OriginMgr.m_isLoggedIn = 1 (and clear loginError) from the +earliest moment OriginMgr exists, for the whole FIFA17 session. + +Rationale (2026-07-31 live finding): setting the flag AFTER boot does nothing -- +FIFA decides logged-out during its boot Blaze handshake (sends Authentication:: +logout 1/0x46, never login 1/0x0A) and never re-auths. This pins the flag to 1 +FROM BOOT so it is already set when FIFA does that handshake. Run BEFORE launching +FIFA; it auto-attaches to each FIFA17.exe and re-pins fast enough to win the race. + +Needs ptrace_scope=0 (already set by root_arm.sh). Idempotent, harmless. +""" +import glob, os, struct, time + +ORIGINMGR_PP = 0x1448acf50 # *(void**)0x1448acf50 -> OriginMgr +OFF_LOGGEDIN = 0x13 # OriginMgr.m_isLoggedIn (u8) +OFF_LOGINERR = 0x14 # OriginMgr.loginError (u32) + +def find_pid(): + for d in glob.glob('/proc/[0-9]*'): + try: + if open(d + '/comm').read().strip() == 'FIFA17.exe': + return int(os.path.basename(d)) + except Exception: + pass + return None + +def main(): + print("[pin] waiting for FIFA17.exe (pin m_isLoggedIn=1 from boot)...", flush=True) + last_pid = None + first_pin = False + while True: + pid = find_pid() + if not pid: + last_pid = None; first_pin = False; time.sleep(0.2); continue + if pid != last_pid: + print(f"[pin] FIFA17.exe pid={pid}", flush=True); last_pid = pid; first_pin = False + try: + with open(f"/proc/{pid}/mem", "r+b") as f: + f.seek(ORIGINMGR_PP); om = struct.unpack(' m_isLoggedIn PINNED=1 " + f"(was {cur[0] if cur else '?'})", flush=True) + first_pin = True + except Exception: + pass + time.sleep(0.02) # 50 Hz: fast enough to win the boot race + hold it + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/forge_node.py b/fifa17-recon/tools/forge_node.py new file mode 100644 index 0000000..6676743 --- /dev/null +++ b/fifa17-recon/tools/forge_node.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Forge a FifaOnline::FirstPartyAuthCodeFutureImpl node and enqueue it so DoTick +(@0x146f199c0) fires GetAuthCode over LSX. Clean-room; from our own RE (ENQUEUE_PLAN.md, +adversarially verified). ptrace_scope=0 required. LSX responder MUST be answering +GetAuthCode first (the auth call is synchronous with a 15s timeout). + +Two gates (both live-verified 0): the retriever queue slot AND OriginSDK[+0x3a0] default-user. +This sets BOTH. Treat the first run as a PROBE: setting the default user flips ~15 other +GetDefaultUser consumers; `--revert` restores the originals. + +Usage: python3 forge_node.py # forge + enqueue + python3 forge_node.py --revert # restore SDK default-user + clear the slot +""" +import sys, struct, glob, os, json + +ONLINEMGR_PP = 0x1448a3b20 # *-> OnlineManager ; retriever = +0x4e98 +RETR_OFF = 0x4e98 +GUARD = 0x1448a3ac3 # enqueue guard byte (must be 1) +SDK_PP = 0x144b7c7a0 # *-> OriginSDK +SDK_DEFUSER = 0x3a0 # default-user slot (BLOCKER) -- set +0x3a0 AND +0x3a8 +SDK_DEREF = 0x3b0 # deref'd unconditionally downstream; must stay non-null +VPTR = 0x1438f5d58 # node primary vtable (AddRef/Release/dtor/GetStatus/GetResult) +VPTR2 = 0x1438f5d90 # node secondary vtable -- MUST be this, never 0 (Release calls [this+8]->[0]) +NODE_VA = 0x14300a380 # validated zero/unreferenced scratch (ENQUEUE_PLAN §4) -- re-checked below +CLIENTID = b"FIFA17PC" # only proven constraint: non-empty +SAVE = "/tmp/forge_node_orig.json" + +def pid(): + for d in glob.glob('/proc/[0-9]*'): + try: + if open(d+'/comm').read().strip()=='FIFA17.exe': return int(d.split('/')[-1]) + except Exception: pass + raise SystemExit("FIFA17.exe not running") + +def build_node(): + b = bytearray(0xF0) + struct.pack_into(' survives DoTick's Release, never freed + b[0x18:0x18+len(CLIENTID)] = CLIENTID # inline clientId, NUL-terminated + return bytes(b) + +def main(): + p = pid(); mp = f"/proc/{p}/mem" + f = open(mp, "r+b") + def rq(va): f.seek(va); return struct.unpack(' {orig.get('defuser',0):#x}/{orig.get('defuser8',0):#x}, slot -> {orig.get('slot',0):#x}") + return + + # --- preconditions (verify, do not assume) --- + assert rd(GUARD,1)[0] == 1, "guard byte != 1" + assert rq(sdk+SDK_DEREF) != 0, "SDK+0x3b0 is NULL (would fault downstream) -- abort" + assert rq(slot) == 0, f"queue slot already non-zero ({rq(slot):#x}) -- abort" + scratch = rd(NODE_VA, 0xF0) + assert all(x==0 for x in scratch), "scratch NODE_VA not zero -- abort" + assert rq(VPTR) == 0x147e8f160, "node vtable[0] mismatch -- wrong build?" + + # save originals for --revert + json.dump({"defuser": rq(sdk+SDK_DEFUSER), "defuser8": rq(sdk+0x3a8), "slot": rq(slot)}, open(SAVE,"w")) + + # 1) forge the node into scratch (BEFORE anything is armed) + wr(NODE_VA, build_node()) + assert rd(NODE_VA,0xF0) == build_node(), "node write-back mismatch" + print(f"[+] node forged @ {NODE_VA:#x} clientId={CLIENTID.decode()} refcount=2") + + # 2) gate 2: set the Origin default user (both slots, like the real SDK) + wr(sdk+SDK_DEFUSER, struct.pack(' {sdk:#x} (default user)") + + # 3) gate 1 (the TRIGGER, set last): enqueue the node + wr(slot, struct.pack(' {NODE_VA:#x} *** ENQUEUED ***") + print(" Watch /tmp/lsx.log for . Then node+0xE8 -> 1,") + print(" node+0xE0=200 = error (read node+0x58 msg). --revert to undo.") + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/fut_flow.md b/fifa17-recon/tools/fut_flow.md new file mode 100644 index 0000000..1fcf2cd --- /dev/null +++ b/fifa17-recon/tools/fut_flow.md @@ -0,0 +1,349 @@ +# FIFA 17 — FUT online-login flow graph + auth state machine (2026-07-30) + +**Target:** FIFA17.exe, ImageBase `0x140000000`, Wine flat map. Live PID 3362053 (read-only +`/proc/pid/mem`) until it exited mid-session; the rest is from captured dumps +(`navregion.bin`, `loginreg.asm`) and the on-disk `.srdata` (plaintext in the file; `.text` +is packer-encrypted on disk, so no further static disassembly is possible without a live PID). + +**Clean-room:** everything below comes from our own `FIFA17.exe` (live memory + its own +plaintext `.srdata`), our own nav JSON as loaded by that binary, and our own captured +LSX/Blaze traffic. No leak material. + +--- + +## 0. Answer in one paragraph + +`origin.nav` **passes** and the flow **does** reach `startFutBlazeLogin`. `onlineLoginFlow.nav` +turns out to be a pure UI shell — it contains **no login logic at all**; it only emits +`sendScreenEvent ["OnlineLogin","0"]` and then waits for the C++ to fire `loginSuccess` / +`loginFail` / `evt_onlineLoginFailurePopup`. The real state machine is the **OSDK +`LoginController` / `LoginStateMachineImpl`** (OSDK `8.01.03.00-fifa.01`) inside FIFA17.exe. +That machine ran `LoginStateConnect` (Blaze connect + `Util::preAuth` — observed on the wire) +and `LoginStateLoadConfig` (`Util::fetchClientConfig` ×6 — observed), **but our Blaze +responder answers all six `fetchClientConfig` calls with an EMPTY payload.** The next step is +an *account-info* step that needs values from that config (`blazeSdkClientId`, +`blazeSdkClientSecret`, `blazeServerClientId`, `identityRedirectUri`). With an empty config it +fails **locally, emitting zero network traffic** (no further LSX verb, no HTTP to the Nucleus +stub, no further Blaze RPC), raises the OSDK event `EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE` +("Unable to retrieve account information. Please try again."), and short-circuits to +`LoginStateLogout` → `Authentication::logout` (1/0x46) → disconnect. **`GetAuthCode` and +`Authentication::login` (1/0x0A) live downstream in `LoginStatePCLogin` and are therefore +never reached.** The prerequisite is not a pushed LSX event and not a persona/entitlement +check — it is **a populated Blaze client configuration**. + +--- + +## 1. The full node path (recovered verbatim from live nav JSON) + +Nav JSON is resident in one heap region (`0xba5b0000-0xbc620000` this run, dumped to +`scratchpad/navregion.bin`). 152 `.nav` files are referenced; all are resident. Frostbite path +of the login flow: `data/ui/nav/online/onlineLoginFlow.nav`. + +``` +mainMenu + └─ launchFUTFlow external /online/origin.nav + outputs: OriginIsOnlineTrue -> startFutBlazeLogin + quit -> mainMenu + └─ futBlazeLogin external /online/onlineLoginFlow.nav + inputs: startFutBlazeLogin -> startLoginWithoutMultiplayerCheck + outputs: loginSuccess -> CheckFUTRosters + loginFail -> mainMenu + └─ CheckFUTRosters external /checkFUTRostersFlow.nav + outputs: advance -> postFUTBlazeLogin + back -> mainMenu + └─ postFUTBlazeLogin onEnter: invoke evt_sign_out_flow_ready, evt_invite_flow_not_ready + transitions: advanceRequest -> futFlow + └─ futFlow external /fut/futFlow.nav +``` + +### 1a. `origin.nav` — complete (2 nodes). This gate is PASSING now. + +```json +{ "name":"origin", "states":[ + { "name":"preCheckCoopOrigin", + "onEnter":[ ["sendAction",["checkOriginConnected"]] ], + "transitions":[ {"event":"OriginIsOnline","targets":["OriginIsOnlineTrue"]}, + {"event":"OriginIsOffline","targets":["OriginOfflinePopup"]} ] }, + { "name":"OriginOfflinePopup", + "onEnter":[ ["loadView",["popup","OriginOfflinePopup", + "TXT_HUB_SCREEN_ORIGIN_ONLINE_CHECK|OK|popupYes"]] ], + "transitions":[ {"event":"popupYes","targets":["quit"]} ] } ] } +``` +FeFlow action ids: `checkOriginConnected` = 0x27e1, `OriginIsOnline` = 0x27e2, +`OriginIsOffline` = 0x27e3. Backing predicate `g_originOnline @0x1443337f8`, written only by +the LSX `GetInternetConnectedState` callback `0x146f1e6b0`, which also broadcasts +`FE::FIFA::OriginOnlineEvent`. + +### 1b. `onlineLoginFlow.nav` — complete, and it contains NO login logic + +``` +onlineLoginFlow + onEnter: loadViewModel OnlineLoginViewModel + onExit : unloadViewModel OnlineLoginViewModel + (outer transitions, i.e. events the C++ can raise at any time) + onlineLoginToEaPopup -> onlineLoginToEaPopup + onlineBootLoginToEaPopup -> onlineBootLoginToEaPopup + evt_onlineAlertPopup -> onlineAlertLoginPopup + evt_onlineBootLoginFailurePopup -> onlineFailureLoginPopup + evt_onlineLoginFailurePopup -> onlineFailureLoginPopup <<< OUR PATH + evt_online_disconnected -> processLoginFailure + loginIdle / membershipCheck / firstPartyCommerceCheck + processBootLoginFailure / processLoginFailure + states: + startLoginWithMultiplayerCheck onEnter: sendScreenEvent ["OnlineLogin","1"] + startLoginWithoutMultiplayerCheck onEnter: sendScreenEvent ["OnlineLogin","0"] <<< ENTRY + in_startSilentSignIn -> skipSilentSignInCheck (conditionAardvark SKIP_SILENT_SIGN_IN) + true -> loginFail + false -> executeSilentSignIn (sendScreenEvent SilentSignIn) + onlineLoginToEaPopup onEnter: sendAction onlineLoginPopupShow + onExit : onlineLoginPopupHide "LOGIN_POPUP" + cancelLogin -> cancelLoginToEA (sendScreenEvent CancelLoginToEA) + onlineBootLoginToEaPopup (same, boot variant) + onlineAlertLoginPopup onEnter: onlineLoginPopupShow / onExit: hide "ALERT_POPUP" + processLoginAlert (sendScreenEvent ProcessLoginAlert) + onlineFailureLoginPopup onEnter: sendAction onlineLoginPopupShow <<< THE POPUP + onExit : onlineLoginPopupHide "ALERT_POPUP" + processLoginFailure -> sendScreenEvent ProcessLoginFailure + processBootLoginFailure -> sendScreenEvent ProcessBootLoginFailure + loginIdle (empty) + membershipCheck popup MembershipCheckPopup / TXT_CHECKING_MEMBERSHIP_LEVEL + firstPartyCommerceCheck popup FirstPartyCommerceCheckPopup + TrialWelcome -> TrialWelcomeCheck (sendAction trialCheck welcomeScreenCheck) + evt_goTrialWelcomeScreen -> TrialWelcomeScreen -> advance -> loginSuccess + evt_skipTrialWelcomeScreen-> loginSuccess + transitions: loginSuccess -> TrialWelcomeCheck ; loginFail -> loginFail(output) +``` + +**Key structural finding:** every node here is a popup or a `sendScreenEvent`. There is no +`GetAuthCode` node, no persona node, no entitlement node. The nav delegates 100 % of the login +to the C++ via one screen event, and only reacts to C++-raised events. So the question +"why no GetAuthCode" cannot be answered in the flow graph — it is answered in the OSDK login +state machine (§2). + +### 1c. `checkFUTRostersFlow.nav` (downstream, never reached) + +`LoadFUTDatabase` (condition `loadFUTDatabase`) → `LoadFUTSquad` (`AutoLoadFUTSquad`) → +`CheckFUTRosterUpdateXML` (`isFUTRosterXMLAvailable`) → `CheckFUTSquadBinFile` → … ; +failure → `FailFUTRosterXMLDownloadPopup` (`FUT_SQUAD_DOWNLOAD_FAIL`) / `unloadFUTDatabaseOnFail`. +`onEnter` loads `FIFAFutLoginViewModel`. + +--- + +## 2. The real state machine: OSDK `LoginController` + +Build tag in the binary: `E:/p4/fifafb/rl/empatch/TnT/Code/fifa/gamemodes/extern/OSDK/ +8.01.03.00-fifa.01/source/...` + +### 2a. States recovered (name string, vtable, registered id) + +Each `LoginState*` class has an 8-byte `GetStateName()` stub (`lea rax,[str]; ret`) in the +block `0x14719b360-0x14719b4e8`; the stub sits at **vtable+0x08**, which pins vtable→name. +The registration function `0x147158e00-0x1471597xx` (dumped: `scratchpad/loginreg.asm`) does +`alloc → set vtable → set name → map-insert(machine, obj, id)`. + +| id | state | vtable | name str | +|---|---|---|---| +| 50 | (unnamed, vt `0x14395bb90`) | `0x14395bb90` | — | +| 100 | `LoginStateCheckUser` | `0x14395bc30` | `0x14395c850` | +| 200 | `LoginStateIsp` | `0x14395bc78` | `0x14395bd08` | +| 300 | `LoginStateRecheckUser` | `0x14395bc30` | `0x14395c868` | +| **310** | **`LoginStateLoadIspAccountInfo`** | `0x14395bd18` | `0x14395bd60` | +| **400** | **`LoginStateConnect`** | `0x14395bd80` | `0x14395be08` | +| 500 | `LoginStateLogout` | `0x14395be80` | `0x14395bf28` | +| **700** | **`LoginStateVersionCheck`** | `0x14395bf40` | `0x14395bf88` | +| **800** | **`LoginStatePCLogin`** | `0x14395c180` | `0x14395c398` | +| 1000 | `LoginStateLoginComplete` | `0x14395c580` | `0x14395c5c8` | +| 1300 | `LoginStateVerifyAccount` | `0x14395c3b0` | `0x14395c4c8` | +| 1350 | `LoginStateUpgradeAccount` | `0x14395c4e0` | `0x14395c560` | +| — | `LoginStateLogin` (generic, non-PC) | `0x14395bfa0` | `0x14395c170` | +| — | `LoginStateLoadConfig` | `0x14395be20` | `0x14395be68` | +| — | `LoginStateShowMaintenance` / `LoginStateUnsuspend` / `LoginStateWebOffer` | `0x14395c5e0` / … | `0x14395bc10` / `0x14395c640` / `0x14395d2c0` | + +Ctors: `0x1471610xx` = `LoginStateLogin`, `0x147161250` = `LoginStatePCLogin`, +`0x147161340` = `LoginStateVerifyAccount`, `0x147165ea0` = `LoginStateLoadConfig`. +State-machine map global: `0x144b86c08`; insert helper `0x14717c4f0(machine, state, id, 1)`. +NOTE: ids are an enum, **not** strictly a sequence (500 = Logout is a failure/teardown state). + +### 2b. `LoginStateLoadIspAccountInfo` (id 310) — what it actually is + +Its event handler is `0x1471b4b40` (vtable+0x20; vtable+0x10 = `0x1472243c0` = `[this+0x20]=0` +i.e. sub-state reset). It switches on a 4-way sub-state `[this+0x20]` and, in sub-states 0 and +1, does `GetComponent('cnnc')` on the OSDK singleton `0x144b86bf8` (`call [vt+0x60]`), then +`[vt+0x38]`, then `[vt+0xb0]` → returns an int country code, and tests it against `0` and +`0x5a5a` (= ASCII `"ZZ"`, the unknown-country sentinel). It also reads a 2-char country string +from `[0x144b86bf8 + 0xb0]` and validates `'A'..'Z'`/`'a'..'z'`. +**So "Isp account info" here = the ISP/connection-derived country/geo (feeds `SetPingSiteLatency` +/ ping-site selection), not the EA account.** That is consistent with it running *before* +`LoginStateConnect` (id 310 < 400) and with it having succeeded this run (Blaze connect +happened, `Country="US"` is served by our LSX `GetProfile`). + +### 2c. The account-info fetch + its two failure exits (binary-pinned) + +* `0x14727dc00` — **starts** the fetch. It resolves a component/service + (`call [r8+0x60]`, obfuscated fourcc arg), then: + * success: `call [rax+0x40]` (kick async op) → store handle via `0x147173690` into `[this+0x1e0]`; + * **null service → immediately dispatches `EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE` + (`0x1439840b8`) at `0x14727dcbe`, with no network I/O at all.** +* `0x14727dd70` — the **completion callback** `(this, status, data)`: + * `status == 0`: copies 4 bytes from `data[0..3]` into `[this+0xa0..0xa3]`, then dispatches + `EVENT_LOGIN_FETCH_ACCOUNT_INFO_SUCCESS` (`0x1439840e0`, lea @ `0x14727de34`); + * `status != 0`: dispatches `EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE` (lea @ `0x14727de7b`). + * Dispatcher: `[[0x144b8f498]] + 0x8`, event-source string `0x14354b5f0`. +* Related adaptor-level events also present: `EVENT_ACCOUNT_FETCH_INFO_SUCCESS/FAILURE` + (`0x143983648` / `0x143983670`), adaptor actions `FetchAccountInfo` (`0x1439623c0`), + `UpdateAccountInfo` (`0x1439623d8`), `GetAccountInfo` (`0x14398aac8`), + `GetNucleusAccountInfo` (`0x14398a920`), `OSDK_NucleusAdaptor` (`0x14398b038`). + +### 2d. The `GetAuthCode` chain (only reachable from `LoginStatePCLogin`) + +``` +FifaOnline::FirstPartyAuthTokenRetriever::DoTick 0x146f199c0 + walks pending list [this+8]; per entry: + 0x1470da6d0 OriginGetDefaultUser/singleton getter + 0x1470db3c0 OriginRequestAuthCodeSync (log str 0x143936158) + gate 0x1470e2840 "Origin SDK available?" -> false: return 0xa0010000 + else 0x1470e3560 get SDK + 0x1470e67f0 Origin::OriginSDK::RequestAuthCodeSync (0x143937d68) + -> LSX -> + status != 0 -> "[%s] Origin Error(%d)" 0x1438f5e18 (lea @0x146f19ab2) + len==0 || ptr==0 -> "[%s] Invalid authcode" 0x1438f5e00 (lea @0x146f19a85) + ok -> build FifaOnline::FirstPartyAuthCodeFutureImpl (0x1438f5d98, lea @0x146f19a39), + store [entry+0xd8], set [entry+0xe8]=1 +``` +`DoTick` only does anything if the pending list `[this+8]` is non-empty — i.e. only if some +upstream state actually *requested* a first-party token. Nothing requested one this run +(`/tmp/openfut_authcode.txt` empty, no `GetAuthCode` in `/tmp/lsx.log`). + +Auth-code consumer parameters, all present in `.rdata`: +`client_id=` `&client_secret=` `&scope=` `&redirect_uri=` `&code=` `&grant_type=` +`authorization_code` `connect/token` (`0x456f58-0x457010` file offsets), scope literal +`signin basic.identity basic.persona basic.domaindata offline`, redirect +`http://127.0.0.1/login_successful.html` (`0x143b04870`), `Nucleus::gNucleusBaseUrl` / +`gNucleusClientSideRedirectUri` (`0x143b8b718` / `0x143b8b778`). + +--- + +## 3. Where it dead-ends, and on what it waits + +### Observed reality this run + +| layer | evidence | +|---|---| +| LSX | `/tmp/lsx.log` ends at id 19 (`GetGameInfo UPTODATE -> "true"`). **No further LSX request of any kind.** No `GetAuthCode`. | +| Nucleus/HTTP | zero requests to the `nucleusConnect` stub on `127.0.0.1:42131`; no dial to accounts/gateway/nucleus.ea.com. | +| Blaze | connect → `Util::preAuth` (9/0x07) → `Util::ping` → `Util::fetchClientConfig` (9/0x01) ×6 for `OSDK_CORE`, `OSDK_CLIENT`, `OSDK_NUCLEUS`, `OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_XMS_ABUSE_REPORTING` — **all answered EMPTY by `blaze_responder_v3.py`** → `Authentication` 1/0x46 = **`logout`**, empty payload → socket closed → 3-second transport-PING reconnect loop (`/tmp/blaze_responder.log`, still looping at the time of writing). **No `Authentication::login` (1/0x0A) ever.** | +| UI | popup text `"Unable to retrieve account information. Please try again."` resident at `0xb79ad020` / `0x78f5380` / `0xbc81ba70`, UI-side id string `KEY_2002` adjacent at `0xb79ace58`/`0xb79ad068`. | + +### Mapping that onto the state machine + +`LoginStateConnect` (400) ran and **succeeded** (preAuth on the wire). `LoginStateLoadConfig` +ran and **completed** (six replies received) but with **empty config maps**. The very next +step is the account-info step, and it failed **without producing a single byte of network +traffic on any of the three transports**. A timeout or a rejected request would have produced +traffic. A local precondition failure would not — and that is exactly the shape of the +`0x14727dc00` early-out (`service == null → EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE`, no I/O). +The state machine then entered `LoginStateLogout` (500), which is what emits the observed +`Authentication::logout`. + +### The prerequisite, precisely + +**`startFutBlazeLogin` does require a prerequisite before `GetAuthCode`, and it is the Blaze +client configuration delivered by `Util::fetchClientConfig` — specifically the identity block.** +The four config keys the client needs are literals in `.rdata`, adjacent, right after +`QueryEbisuCallback`: + +| key | file offset | what it feeds | +|---|---|---| +| `blazeServerClientId` | 0x458c90 | Blaze-side identity | +| `blazeSdkClientId` | 0x458ca8 | the `ClientId` attribute of the LSX `` request (attribute name confirmed in the LSX attribute pool at `0x14394e098`) | +| `blazeSdkClientSecret` | 0x458cc0 | `&client_secret=` in the Nucleus `connect/token` exchange | +| `identityRedirectUri` | 0x458cd8 | `&redirect_uri=` (pairs with `http://127.0.0.1/login_successful.html`) | + +With `fetchClientConfig` returning empty, none of these exist, so: +1. the account-info/identity service has nothing to initialise from → fails locally → + `EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE` → **"Unable to retrieve account information."**; +2. the `FirstPartyAuthTokenRetriever` pending list is never fed → `DoTick` never calls + `OriginRequestAuthCodeSync` → **LSX `GetAuthCode` is never sent**; +3. `LoginRequest.AUTH` (`Blaze::Authentication::LoginRequest` @ `0x14487ca10`, members + `AUTH`/`EXTB`/`EXTI`) can never be filled → `LoginStatePCLogin` (800) is skipped → + **`Authentication::login` (1/0x0A) is never sent**; +4. `LoginStateLogout` (500) tears the session down → the observed `1/0x46`. + +### Verdict on the three prior hypotheses + +* **(a) a pushed LSX `` "user logged in"** — **not supported.** Origin is already + `connected="1"`, the flow demonstrably got past `origin.nav` into Blaze, and the client + stopped asking LSX anything at all after `UPTODATE`. A pushed event is not what it is + waiting on. (`IsLoggedIn` does exist in the LSX attribute pool at `0x14394e0f0` and + `Origin::EventHandler::HandleMessage` at `0x14393f900`, so a + `Login`/`IsLoggedIn` LSX event *is* implementable — but nothing here indicates it is the gate.) +* **(b) an account-info step fails first and aborts before `GetAuthCode`** — **CONFIRMED**, + and its missing input is identified: the Blaze client config. +* **(c) `origin.nav` / `OriginOnlineEvent` not firing** — **REFUTED.** `origin.nav` is a + two-node flow, and everything downstream of `OriginIsOnlineTrue` (Blaze connect, preAuth, + fetchClientConfig) demonstrably ran. + +--- + +## 4. Recommended next action (single change, testable) + +Stop returning empty `Util::fetchClientConfig` (9/0x01) replies in +`fifa17-recon/tools/blaze_responder_v3.py`. Return a populated `CONF` string→string map per +CFID, minimally: + +* `OSDK_NUCLEUS`: `blazeSdkClientId`, `blazeSdkClientSecret`, `blazeServerClientId`, + `identityRedirectUri` (= `http://127.0.0.1/login_successful.html`), plus the + `NUCLEUS_*_URL` set (`NUCLEUS_CREATE_URL`, `NUCLEUS_ADDED_URL`, `NUCLEUS_INCOMPLETE_URL`, + `NUCLEUS_CREATE_INFO_URL`, `NUCLEUS_DUPACCT_INFO_URL`, `NUCLEUS_DEACTIVATED_INFO_URL`) + pointed at our own stub. +* `OSDK_CORE`: at minimum `SV_ENABLE_SERVER_VERSIONING=0` (else `LoginStateVersionCheck` + (700) can trip "Client/server version mismatch! Client is at version (%08d)…", + `0x14395d1d0`), plus `netres`. +* `OSDK_CLIENT`, `OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_XMS_ABUSE_REPORTING`: + non-empty but can stay minimal. (`OSDK_TICKER` is a seventh CFID the client knows about.) + +**Success signals, in order:** (1) the client stops sending `1/0x46` right after the six +config fetches; (2) `/tmp/lsx.log` shows a `GetAuthCode` request carrying `ClientId=` and +`/tmp/openfut_authcode.txt` becomes non-empty; (3) Blaze receives `Authentication::login` +(1/0x0A) with `AUTH=`; (4) after our `LoginResponse` (5 members +ANON/NTOS/SESS/SPAM/UNDR, `0x14487d170`) plus `UserSessions` notifications `UserAdded` (0x02), +`UserSessionExtendedDataUpdate` (0x01), `UserAuthenticated` (0x08), the nav advances +`loginSuccess → TrialWelcomeCheck → CheckFUTRosters → postFUTBlazeLogin → futFlow`. + +Keep identity consistent everywhere: PersonaId/UserId `33068179`, Persona `CAGE`, `en_US`, +contentId `1027460`, entitlement `ONLINE_ACCESS`. + +--- + +## 5. Open / not pinned (be honest) + +* The exact **emitting state** for the popup was not binary-pinned: the live PID exited before + I could disassemble the `OnlineLoginViewModel` message-index → loc-key mapping + (`0x147c3c050` online branch → `0x147d92a80(this, idx)` with idx 0x21/0x23/0x24/0x25 chosen + by `[vm+0x158]`, then `0x147c4bfd0` / `0x147c4ca50` / `0x147c4a4b0`). Attribution of the + string to `EVENT_LOGIN_FETCH_ACCOUNT_INFO_FAILURE` is by (i) exact semantic match, (ii) the + zero-network-traffic failure shape matching `0x14727dc00`'s early-out, (iii) elimination of + every other observed step. The alternative emitter is the adaptor-level + `EVENT_ACCOUNT_FETCH_INFO_FAILURE` (`0x143983670`) — same root cause either way. +* Whether `blazeSdkClientId` & co. arrive in `OSDK_NUCLEUS` vs `OSDK_CORE` is an inference from + the CFID set + key naming; the fastest resolution is empirical (put them in *both* and watch + which one the client consumes). +* The obfuscated fourcc component ids in `0x14727dc00` / `0x14727dd70` / `0x1471b4b40` + (`'cnnc'` = 0x636E6E63 for the ISP/connection component, and 0x6E756D67 for the one used by + the account-info starter) were reconstructed from `mov r8d/edx,K; lea …,[r+C]` pairs and are + worth re-checking on a live PID. +* `LoginStateLogin` (generic) vs `LoginStatePCLogin` (800): on PC the machine is expected to + use `PCLogin`; not re-verified at runtime. + +## 6. Artifacts produced + +| file | contents | +|---|---| +| `scratchpad/navscan.py` | live-memory nav/JSON keyword + blob scanner | +| `scratchpad/xr.py` | numpy rip-rel + absolute xref finder (FIFA17 module range) | +| `scratchpad/dis.sh` | dump live VA range + objdump at correct VA | +| `scratchpad/navregion.bin` | 34 MB heap region containing every loaded `.nav` (this run) | +| `scratchpad/navseg_origin_login.txt` | `onlineLoginFlow.nav` + `origin.nav` as text | +| `scratchpad/nav_root_fut.txt` | root.nav FUT chain (`launchFUTFlow` … `futFlow`) | +| `scratchpad/nav_checkfutrosters.txt` | `checkFUTRostersFlow.nav` | +| `scratchpad/loginreg.asm` | disassembly of the LoginState registration function | diff --git a/fifa17-recon/tools/fut_seed.py b/fifa17-recon/tools/fut_seed.py new file mode 100644 index 0000000..6256d95 --- /dev/null +++ b/fifa17-recon/tools/fut_seed.py @@ -0,0 +1,159 @@ +# --------------------------------------------------------------------------- +# OpenFUT / FIFA 17 UTAS -- forged squad ladder (clean-room). Imported by +# utas_server.py. Derived from CardsDLL_Win64_retail.dll (PE base 0x180000000). +# +# The prior full 11-player squad HUNG FIFA. Workflow wf_0bc80ab3 (5 agents, +# adversarially verified) proved: the deserializer PARSES our JSON fine; the +# freeze is POST-PARSE, at the per-item finalize resolve 0x180141176 (call +# singleton 0x18011a830 -> [r9+0xa08]) that fires for EVERY parsed item object +# (manager, players[].itemData, actives[]). Whether that resolve BLOCKS offline +# on an unresolvable item is UNVERIFIED -> we bisect it empirically with a ladder. +# +# The ladder (select via env FUT_SQUAD_STEP, default "s1"). Each step is one +# small change so a single FIFA relaunch isolates one variable: +# s1 zero-resolve: players are bare {index,kitNumber}, no itemData, manager=[] +# -> item deser NEVER entered, resolve fires 0 times. Tests envelope+HTTP +# framing only. Reaches hub => hang IS item-resolve. Freezes => framing. +# s1b players[0] gets an EMPTY itemData {id:0,dream:false} (still no real asset) +# -> resolve fires once on id 0. Freezes => id-0 resolve itself blocks +# offline. Reaches hub => id-0 is fine, the asset value is what matters. +# s2v0 players[0] = ONE real item, resourceId==assetId (version byte 0x00); +# club serves the same item. Renders => version 0 is correct. +# s2v1 same but version byte 0x01 (resourceId = 0x01<<24|assetId). +# s3v0 full XI with the winning version byte (default 0x00); club in lockstep. +# s3v1 full XI, version 0x01. +# resourceId decompose 0x180166ca0 CONFIRMED: assetId = resourceId & 0xffffff, +# high byte = version. Version byte value is the open question s2v0/s2v1 answer. +# --------------------------------------------------------------------------- +import os + +PERSONA_ID = 33068179 +ITEM_ID_BASE = 100000000 + +# Real FIFA17 assetIds read earlier from the live InGameDB. assetId 41 (Iniesta) +# was flagged by the verifier as possibly having NO InGameDB definition -> it is +# DROPPED from the XI until a live test confirms a replacement. (asset, rating, pos, kit) +REAL_XI = [ + (20801, 94, "LW", 7), # Ronaldo -- STEP-2 uses this one + (158023, 93, "RW", 10), # Messi + (200389, 87, "GK", 1), # Oblak + (183907, 90, "CB", 5), # Boateng + (155862, 89, "CB", 4), # Ramos + (197445, 87, "LB", 2), # Alaba + (189332, 86, "LB", 3), # Alba + (182521, 88, "CM", 8), # Kroos + (183277, 88, "LM", 11), # Hazard + (176580, 92, "ST", 9), # Suarez + # (41, 88, "CM", ..) DROPPED: verifier says no InGameDB def -> stall risk +] + + +def player_item(asset, rating, pos, version=0x00, nation=38, team=243, league=53, + attrs=(90, 93, 82, 91, 33, 80)): + """FULL item -- in case the card system needs more than the minimal set to + PLACE + render a real player (the minimal item rendered generic + rating 0). + Defaults are Ronaldo (Portugal 38 / Real Madrid 243 / La Liga 53).""" + rid = (version << 24) | asset + return { + "id": ITEM_ID_BASE + (asset & 0xffffff) + 1, # unique, != 0 + "resourceId": rid, + "assetId": asset, + "cardassetid": asset, + "definitionId": rid, # some FUT APIs key on definitionId + "cardsubtypeid": 0, # 0..3 => PLAYER + "itemType": "player", + "rareflag": 1, + "rating": rating, + "preferredPosition": pos, # STRING enum "GK"/"CB"/... + "nation": nation, + "teamid": team, + "leagueId": league, + "playStyle": 250, + "attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)], + "itemState": "free", + "owners": 1, + "untradeable": True, + "contract": 7, + "fitness": 99, + "loans": 0, + "discardValue": 0, + "statsList": [], + "lifetimeStats": [], + } + + +def _base_squad(): + """Envelope shared by every step: valid formation/custom/kicktakers, but + players are bare {index,kitNumber} (index is the direct hash bucket key, + MUST be unique 0..22) and manager empty -> zero item-deser calls by default.""" + return { + "id": 0, + "personaId": PERSONA_ID, # must equal logged-in persona (0x18014659c) + "squadName": "OpenFUT", + "formation": "f442", + "squadType": "REGULAR_SQUAD", + "chemistry": 100, + "starRating": 5, + "captain": 0, + "changed": 0, + "manager": [], + "actives": [], + "custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0," + "50,50,0,50,40,65,0,65,50,50,1]", + "players": [{"index": i, "kitNumber": 0} for i in range(23)], + "kicktakers": [{"index": i, "id": 0, "dream": False} for i in range(5)], + } + + +def _put_item(squad, index, itemdata, kit=0): + squad["players"][index] = {"index": index, "itemData": itemdata, "kitNumber": kit} + + +def make_squad(step="s1"): + """Return (squad, club) for a ladder step. club is {"itemData":[...]}.""" + step = step.lower() + squad = _base_squad() + club_items = [] + + if step == "s0": + # ABSOLUTE minimal squad: no custom (only field re-parsed by a sub-reader, + # 0x1801c8270 -- prime suspect for the reader EOF-spin), empty players & + # kicktakers arrays. Reaches hub => envelope OK, spin is custom/players/ + # kicktakers -> add back one at a time. Freezes => any squad object spins. + squad.pop("custom", None) + squad["players"] = [] + squad["kicktakers"] = [] + + elif step == "s1": + pass # bare players, empty club + + elif step == "s1b": + _put_item(squad, 0, {"id": 0, "dream": False}) # one empty item, no asset + + elif step in ("s2v0", "s2v1"): + version = 0x00 if step.endswith("v0") else 0x01 + asset, rating, pos, kit = REAL_XI[0] # Ronaldo + it = player_item(asset, rating, pos, version) + _put_item(squad, 0, it, kit) + squad["captain"] = it["id"] + club_items = [it] + + elif step in ("s3v0", "s3v1"): + version = 0x00 if step.endswith("v0") else 0x01 + for slot, (asset, rating, pos, kit) in enumerate(REAL_XI): + it = player_item(asset, rating, pos, version) + _put_item(squad, slot, it, kit) + club_items.append(it) + squad["captain"] = club_items[0]["id"] + + else: + raise ValueError("unknown FUT_SQUAD_STEP=%r (s1|s1b|s2v0|s2v1|s3v0|s3v1)" % step) + + return squad, {"itemData": club_items} + + +# Selected at import time from the environment (default s1 = the zero-resolve test). +STEP = os.environ.get("FUT_SQUAD_STEP", "s1") +SQUAD, CLUB = make_squad(STEP) +# Back-compat exports for utas_server. +USER_LIST = {"user": []} diff --git a/fifa17-recon/tools/login_dump/manifest.txt b/fifa17-recon/tools/login_dump/manifest.txt new file mode 100644 index 0000000..9031663 --- /dev/null +++ b/fifa17-recon/tools/login_dump/manifest.txt @@ -0,0 +1,17 @@ +FIFA17 login-machinery dump pid=10766 + +CODE dispatch_case2 window 0x146f1df60..0x146f1e200 (672B) -> dispatch_case2_146f1df60.bin[.asm] +CODE event_matcher window 0x147102840..0x147102c80 (1088B) -> event_matcher_147102840.bin[.asm] +CODE login_parser window 0x147138620..0x147138a60 (1088B) -> login_parser_147138620.bin[.asm] +CODE loginstate_pclogin window 0x1471b58a0..0x1471b5ee0 (1600B) -> loginstate_pclogin_1471b58a0.bin[.asm] +CODE txt_not_login_ebisu window 0x1471b5ac0..0x1471b5f00 (1088B) -> txt_not_login_ebisu_1471b5ac0.bin[.asm] +CODE pclogin_callsite window 0x1471b6720..0x1471b68a0 (384B) -> pclogin_callsite_1471b6720.bin[.asm] + +DATA originmgr *[0x1448acf50]=0x2bf64840 dump@0x2bf64840 (128B) -> originmgr_2bf64840.bin + first64: 38 5d 93 43 01 00 00 00 60 8a ff 44 01 00 00 00 01 00 00 00 00 11 bd 0b 18 13 01 2c 00 00 00 00 00 00 00 00 00 00 00 00 20 13 01 2c 00 00 00 00 28 13 01 2c 00 00 00 00 08 13 01 2c 00 00 00 00 +DATA online_flags *[0x1448a3ac0]=0x1000001 dump@0x1000001 (64B) -> online_flags_1000001.bin + first64: ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff c8 a7 fd ff 7f 31 f3 ff 7a 2a 43 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +DATA auth_block *[0x1448a3b20]=0x43dc3e70 dump@0x43dc8d08 (96B) -> auth_block_43dc8d08.bin + first64: 50 5d 8f 43 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 07 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 00 00 00 +DATA session_obj *[0x144b86bf8]=0x43c46c70 dump@0x43c46c70 (128B) -> session_obj_43c46c70.bin + first64: 68 91 95 43 01 00 00 00 00 55 ba 07 00 00 00 00 20 00 00 00 00 00 00 00 48 51 c4 43 00 00 00 00 80 93 95 43 01 00 00 00 98 93 95 43 01 00 00 00 00 00 00 00 ff ff ff ff 00 00 00 00 00 00 00 00 diff --git a/fifa17-recon/tools/lsx_force_online.py b/fifa17-recon/tools/lsx_force_online.py new file mode 100644 index 0000000..2f58ecb --- /dev/null +++ b/fifa17-recon/tools/lsx_force_online.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +""" +lsx_force_online.py -- force FIFA 17's Origin/LSX layer to report ONLINE. + +THIS IS LAYER 1. It must succeed before ANY Blaze work (blaze_responder_v3.py) +is reachable. The client's own flow graph gates FUT behind Origin: + + {"name":"launchFUTFlow","file":"/online/origin.nav", + "outputs":{"OriginIsOnlineTrue":"startFutBlazeLogin","quit":"mainMenu"}} + +so while Origin says offline the client shows +"Unable to connect to the EA Servers ... log in to Origin in Online Mode" +(TXT_ORIGIN_OFFLINE_POPUP_TEXT) and sends Authentication::logout (1/0x46) to +Blaze instead of login (1/0x0A). + +PREFERRED FIX IS NOT THIS FILE. Prefer `lsx_responder.py`: bind 127.0.0.1:4216 +BEFORE launching FIFA 17. The Steampunks stub's socket setup +(sub_0x6ffffc932130) does bind -> listen -> accept with NO SO_REUSEADDR and, on +bind failure, branches to 0x6ffffc932245 -> freeaddrinfo/closesocket/WSACleanup/ +return 1 -- i.e. it stands down CLEANLY and the game's OriginSDK connects to us. +That is a real request-driven LSX server and can also answer GetAuthCode, +GetProfile and QueryEntitlements, which no memory patch can synthesise. + +USE THIS FILE when the game is ALREADY RUNNING and you only want to flip the +online verdict (e.g. to confirm `OriginIsOnlineTrue` fires at all). + +------------------------------------------------------------------------------ +PATCH (A) -- the emu's response template. DEFAULT. +------------------------------------------------------------------------------ + Region : stp-origin_emu.dll unpacked image, 0x6ffffc931000-0x6ffffc93d000, + already mapped rwxp (no mprotect needed). + Template: 0x6ffffc9353b0 + + VA : 0x6ffffc9353f4 (the '0' inside connected="0") + BEFORE : 30 ('0') + AFTER : 31 ('1') + The format string is re-read on every use, so the patch applies to every + future emission -- but the stub is a BLIND FIXED-SCRIPT REPLAYER (18 canned + responses in a fixed order, then ErrorSuccess forever, loop 0x6ffffc932dd3). + Template 17 is the InternetConnectedState one. If the game has already + passed step 17, the stub is parked in the ErrorSuccess loop and will never + emit this template again -- the patch then does nothing, and Q-to-reconnect + does NOT help. Patch BEFORE the game boots past the Origin probe, or use + patch (B) / lsx_responder.py. + +------------------------------------------------------------------------------ +PATCH (B) -- g_originOnline, the parsed verdict itself. --flag / --hold +------------------------------------------------------------------------------ + Module : FIFA17.exe, mapped flat at 0x140000000 under Wine/Proton. + VA : 0x1443337f8 (g_originOnline, one byte) + BEFORE : 00 (or stale garbage -- see caveat below) + AFTER : 01 + Sole reader : 0x146f38aa9 movzx eax, BYTE PTR [0x1443337f8] (the popup / + OriginIsOnline predicate; a bare global read, no refresh) + Sole writer : 0x146f1e6d9 mov BYTE PTR [0x1443337f8], al + -- the LSX GetInternetConnectedState callback (0x146f1e6b0), + which also broadcasts FE::FIFA::OriginOnlineEvent. + + CAVEAT (measured live): this byte currently reads 0x01 already, yet the flow + still fails. The stub answered the FIRST GetInternetConnectedState (id 17) + with a well-formed connected="0" but answered the LATER polls (ids 19-22) + with a type-mismatched generic ErrorSuccess, so the SDK found no `connected` + attribute and stored stale garbage. Therefore: a 1 in this byte is NOT + sufficient on its own -- the FE::FIFA::OriginOnlineEvent broadcast that the + writer performs is what actually drives origin.nav. --hold keeps the byte at + 1 so it cannot be clobbered, but only a real LSX reply (lsx_responder.py) + makes the callback run and fire the event. Treat (B) as diagnostic. + +------------------------------------------------------------------------------ +CLEAN ROOM: every address above was recovered by static + dynamic analysis of +binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in our own running +process). Nothing derives from the 2021 EA/FIFA leak. + +USAGE + python3 lsx_force_online.py # apply (A); idempotent + python3 lsx_force_online.py --flag # apply (A) + (B) once + python3 lsx_force_online.py --hold # (A) + rewrite (B) every 0.5s + python3 lsx_force_online.py --status # read both, change nothing + python3 lsx_force_online.py --restore # put the saved originals back + python3 lsx_force_online.py --watch # wait for FIFA17.exe, then apply + +Requires /proc/PID/mem write access (kernel.yama.ptrace_scope=0, or run as the +same user with ptrace_scope=1 which is already known to work here). +""" + +import glob +import os +import sys +import time + +# ------------------------------------------------------------------ patches + +# (A) stp-origin_emu.dll InternetConnectedState template. +EMU_TEMPLATE_VA = 0x6FFFFC9353B0 +EMU_PATCH_VA = 0x6FFFFC9353F4 +EMU_BEFORE = b"0" # 0x30 +EMU_AFTER = b"1" # 0x31 +EMU_TEMPLATE_HEAD = b' (patch_va, template_va) or (None, None).""" + want = EMU_TEMPLATE_HEAD + try: + head = rd(pid, EMU_TEMPLATE_VA, len(want)) + if head == want: + return EMU_PATCH_VA, EMU_TEMPLATE_VA + except Exception: + pass + + log("template not at 0x%x -- rescanning writable+executable maps" + % EMU_TEMPLATE_VA) + delta = EMU_PATCH_VA - EMU_TEMPLATE_VA # +0x44 + try: + maps = open("/proc/%d/maps" % pid).read().splitlines() + except Exception as e: + log("cannot read maps: %s" % e) + return None, None + for line in maps: + try: + rng, perms = line.split()[0], line.split()[1] + if "w" not in perms or "r" not in perms: + continue + lo, hi = (int(x, 16) for x in rng.split("-")) + if hi - lo > 64 * 1024 * 1024: + continue + blob = rd(pid, lo, hi - lo) + except Exception: + continue + off = blob.find(want) + while off != -1: + tva = lo + off + pva = tva + delta + try: + if rd(pid, pva, 1) in (EMU_BEFORE, EMU_AFTER): + log("template relocated: 0x%x (patch byte 0x%x)" % (tva, pva)) + return pva, tva + except Exception: + pass + off = blob.find(want, off + 1) + return None, None + + +# ------------------------------------------------------------------ actions + +def show_template(pid, tva): + try: + raw = rd(pid, tva, 96).split(b"\x00")[0] + log(" template @0x%x: %s" % (tva, raw.decode("ascii", "replace"))) + except Exception: + pass + + +def apply_emu(pid): + pva, tva = locate_emu_patch(pid) + if pva is None: + log("PATCH (A): template NOT FOUND -- is stp-origin_emu loaded? " + "(is the game past the Origin probe already?)") + return False + cur = rd(pid, pva, 1) + if cur == EMU_AFTER: + log("PATCH (A): already applied at 0x%x (connected=\"1\")" % pva) + show_template(pid, tva) + return True + if cur != EMU_BEFORE: + log("PATCH (A): UNEXPECTED byte %s at 0x%x (want %s) -- refusing" + % (cur.hex(), pva, EMU_BEFORE.hex())) + return False + backup(pva, cur) + wr(pid, pva, EMU_AFTER) + now = rd(pid, pva, 1) + log("PATCH (A): 0x%x %s -> %s %s" + % (pva, cur.hex(), now.hex(), "OK" if now == EMU_AFTER else "FAILED")) + show_template(pid, tva) + return now == EMU_AFTER + + +def apply_flag(pid): + cur = rd(pid, FLAG_VA, 1) + if cur == FLAG_AFTER: + log("PATCH (B): g_originOnline @0x%x already 0x01" % FLAG_VA) + return True + backup(FLAG_VA, cur) + wr(pid, FLAG_VA, FLAG_AFTER) + now = rd(pid, FLAG_VA, 1) + log("PATCH (B): g_originOnline @0x%x %s -> %s %s" + % (FLAG_VA, cur.hex(), now.hex(), "OK" if now == FLAG_AFTER else "FAILED")) + return now == FLAG_AFTER + + +def status(pid): + pva, tva = locate_emu_patch(pid) + if pva is None: + log("STATUS (A): template not found in this process") + else: + b = rd(pid, pva, 1) + log("STATUS (A): 0x%x = %s -> connected=\"%s\"%s" + % (pva, b.hex(), b.decode("ascii", "replace"), + " [PATCHED]" if b == EMU_AFTER else "")) + show_template(pid, tva) + b = rd(pid, FLAG_VA, 1) + log("STATUS (B): g_originOnline @0x%x = %s (%s)" + % (FLAG_VA, b.hex(), + "online" if b == b"\x01" else "offline/garbage")) + log("NOTE: a 1 in (B) is NOT proof of success -- see the CAVEAT in this " + "file's docstring. Only a well-formed LSX connected=\"1\" reply makes " + "the callback broadcast FE::FIFA::OriginOnlineEvent, which is what " + "origin.nav actually consumes.") + + +def restore(pid): + if not os.path.isdir(BACKUP_DIR): + log("RESTORE: nothing saved in %s" % BACKUP_DIR) + return + for fn in sorted(os.listdir(BACKUP_DIR)): + if not fn.startswith("orig_"): + continue + va = int(fn[5:].split(".")[0], 16) + orig = open(os.path.join(BACKUP_DIR, fn), "rb").read() + try: + wr(pid, va, orig) + log("RESTORE: 0x%x <- %s (now %s)" + % (va, orig.hex(), rd(pid, va, len(orig)).hex())) + except Exception as e: + log("RESTORE: 0x%x FAILED: %s" % (va, e)) + + +# ------------------------------------------------------------------ main + +def main(): + argv = sys.argv[1:] + want_flag = "--flag" in argv or "--hold" in argv + hold = "--hold" in argv + + if "--watch" in argv: + log("=== WATCH: waiting for FIFA17.exe ===") + seen = set() + while True: + pid = find_pid() + if pid and pid not in seen: + try: + if apply_emu(pid): + if want_flag: + apply_flag(pid) + seen.add(pid) + except Exception as e: + log("pid %d not ready yet (%s)" % (pid, e)) + time.sleep(1) + + pid = find_pid() + if not pid: + raise SystemExit("FIFA17.exe not running (use --watch to wait for it)") + log("=== lsx_force_online pid=%d ===" % pid) + + if "--status" in argv: + status(pid) + return + if "--restore" in argv: + restore(pid) + return + + apply_emu(pid) + if want_flag: + apply_flag(pid) + if hold: + log("HOLD: rewriting g_originOnline every 0.5 s (Ctrl-C to stop)") + try: + while True: + try: + if rd(pid, FLAG_VA, 1) != FLAG_AFTER: + wr(pid, FLAG_VA, FLAG_AFTER) + log("HOLD: g_originOnline was clobbered, reset to 0x01") + except Exception as e: + log("HOLD: process gone (%s)" % e) + break + time.sleep(0.5) + except KeyboardInterrupt: + log("HOLD: stopped") + + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/lsx_responder.py b/fifa17-recon/tools/lsx_responder.py new file mode 100644 index 0000000..eed59d2 --- /dev/null +++ b/fifa17-recon/tools/lsx_responder.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +OpenFUT clean-room LSX responder for FIFA 17 (replaces the Steampunks stp-origin_emu +in-process stub on 127.0.0.1:4216). + +PROVENANCE / CLEAN-ROOM: every constant and algorithm here was recovered by static + +dynamic analysis of binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in +our own running process). Nothing is derived from the 2021 EA/FIFA leak. + +WIRE PROTOCOL (reversed from stp-origin_emu.dll @ base 0x6ffffc930000): + transport : TCP 127.0.0.1:4216, each message is a NUL-terminated byte string + (send length == strlen(msg)+1). + handshake : server sends IN PLAINTEXT. + client replies (plaintext) with response="..." and key="..." attrs. + server replies where + H = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED)) + K_FIXED = 000102030405060708090a0b0c0d0e0f (emu .rdata 0x935038) + session : every later message is + hex_lower( AES128_ECB_encrypt( pkcs7_pad16( xml ) ) ) + under SESSION_KEY, which both sides derive from H (see derive_session_key). + Incoming messages are hex-decoded, decrypted, pad-stripped. + +USAGE: bind this BEFORE launching FIFA 17. The stub's bind() then fails and its + server thread returns cleanly (it has no SO_REUSEADDR and no retry), so the + game's OriginSDK connects to us instead. +""" +import socket, sys, re, os, threading +from Crypto.Cipher import AES + +# ---------------------------------------------------------------- identity +# SHARED CONSTANTS -- must stay byte-identical to stp-origin_emu.ini [Globals] +# AND to the same block at the top of blaze_responder_v3.py. A mismatch between +# what LSX reports here and what Blaze returns in LoginResponse.SESS.PDTL is +# exactly what raises AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_ +# PERSONA / AUTH_ERR_PERSONA_NOT_FOUND. +PERSONA_ID = 33068179 +PERSONA_NAME = "CAGE" +USER_ID = 33068179 +CONTENT_ID = "1027460" # FIFA 17 EA offer id +ENTITLEMENT_TAG = "ONLINE_ACCESS" + +# TWO-LAYER ORDERING: this file is LAYER 1. It must be listening on +# 127.0.0.1:4216 BEFORE FIFA 17 starts. Only once GetInternetConnectedState +# answers connected="1" does origin.nav take the OriginIsOnlineTrue exit into +# futBlazeLogin; only then does the client call GetAuthCode and put the result +# in Blaze LoginRequest.AUTH (1/0x0A). Until then it sends +# Authentication::logout (1/0x46) and blaze_responder_v3.py can do nothing. +# The auth code we hand out is echoed to AUTHCODE_FILE purely so the Blaze log +# can be correlated; blaze_responder_v3 accepts whatever AUTH arrives and never +# validates it against Nucleus. +AUTHCODE_FILE = "/tmp/openfut_authcode.txt" + +# Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038 +K_FIXED = bytes(range(16)) # 000102030405060708090a0b0c0d0e0f + +# Emu's own advertised challenge (any 32 hex chars work; the client echoes it back) +CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32" +BUILD = "release" +VERSION = "10,4,13,6637" + + +# ---------------------------------------------------------------- crypto +def msvcr_rand(seed): + """MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61).""" + s = seed & 0xFFFFFFFF + while True: + s = (s * 214013 + 2531011) & 0xFFFFFFFF + yield (s >> 16) & 0x7FFF + + +def derive_session_key(resp_hex: str) -> bytes: + """Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101). + + srand(7); r0 = rand() -> r0 == 61 + bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap) + srand(bx + r0) + key[i] = (uint8_t)rand() for i in 0..15 + """ + r0 = next(msvcr_rand(7)) # == 61 + bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF + g = msvcr_rand((bx + r0) & 0xFFFFFFFF) + return bytes(next(g) & 0xFF for _ in range(16)) + + +def challenge_response(client_key_ascii: str) -> str: + """H = hex(AES128-ECB(K_FIXED, PKCS7pad16(clientKeyAscii))). + + VERIFIED against a captured client ChallengeResponse (2026-07-30): the 32-char + ASCII key is PKCS7-padded to 48 bytes (3 AES blocks, 96 hex), NOT zero-padded + to 32. With server challenge key '2b8ee7fa...' this reproduces the client's + response '00b9c8af...216684899' exactly.""" + b = client_key_ascii.encode() + pad = 16 - (len(b) % 16) # 32 -> +16 full block -> 48 bytes + b += bytes([pad]) * pad + return AES.new(K_FIXED, AES.MODE_ECB).encrypt(b).hex() + + +def lsx_encrypt(xml: str, key: bytes) -> bytes: + """pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated.""" + b = xml.encode() + pad = 16 - (len(b) % 16) # emu always pads (pad==16 when aligned) + b += bytes([pad]) * pad + return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0" + + +def lsx_decrypt(data: bytes, key: bytes) -> str: + h = data.split(b"\0")[0].strip() + raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode())) + pad = raw[-1] + if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]): + raw = raw[:-pad] + return raw.split(b"\0")[0].decode(errors="replace") + + +# ---------------------------------------------------------------- responses +def resp(mid, body, sender=""): + return f'<{body}/>' + + +def build_reply(mid, req_name, attrs): + """Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script).""" + if req_name == "GetInternetConnectedState": + # THE ONLINE GATE. Stub hardcoded connected="0" -> "log in to Origin". + return resp(mid, 'InternetConnectedState connected="1"') + + if req_name == "GetAuthCode": + # Stub never implemented this at all. Element name is (confirmed + # in FIFA17.exe element table @0x143937ae0). Emit both plausible value attrs; + # the client reads the one it knows and ignores the other. + code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24) + try: + with open(AUTHCODE_FILE, "w") as fh: + fh.write(code) + except Exception: + pass + print(f"[lsx] *** GetAuthCode issued: {code} -- this is what should " + f"arrive as Blaze LoginRequest.AUTH (1/0x0A) ***") + return resp(mid, f'AuthCode Code="{code}" Return="{code}"', sender="EbisuSDK") + + if req_name == "QueryEntitlements": + item = (f'') + return (f'' + f'{item}' + f'') + + if req_name == "GetProfile": + return resp(mid, + f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" ' + f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" ' + f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" ' + f'CommerceCurrency="USD"', sender="EbisuSDK") + + if req_name == "GetGameInfo": + gi = attrs.get("GameInfoId") + if gi == "LANGUAGES": + return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,' + 'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,' + 'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"') + if gi == "UPTODATE": + # "is the title up to date?" -- MUST be true or the client shows + # "Your title version is outdated" and blocks all online features. + return resp(mid, 'GetGameInfoResponse GameInfo="true"') + # FREETRIAL etc. -> false (retail, not a trial) + return resp(mid, 'GetGameInfoResponse GameInfo="false"') + + if req_name == "GetSetting": + sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE (ENVIRONMENT/LANGUAGE) + if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"): + return resp(mid, 'GetSettingResponse Setting="production"') + if sid == "LANGUAGE": + return resp(mid, 'GetSettingResponse Setting="en_US"') + return resp(mid, 'GetSettingResponse Setting="false"') + + if req_name == "GetConfig": + return resp(mid, 'GetConfigResponse Config="false"', sender="EbisuSDK") + + if req_name == "IsProgressiveInstallationAvailable": + return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" ' + 'Available="false"') + + return resp(mid, 'ErrorSuccess Code="0" Description=""') + + +REQ_RE = re.compile(r']*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>') +ATTR_RE = re.compile(r'(\w+)="([^"]*)"') + + +def serve(conn): + # 1. plaintext Challenge + chal = (f'') + conn.sendall(chal.encode() + b"\0") + + # 2. plaintext ChallengeResponse from client + data = conn.recv(4096) + m = re.search(r'key="([^"]*)"', data.decode(errors="replace")) + client_key = m.group(1) if m else CHALLENGE_KEY + h = challenge_response(client_key) + key = derive_session_key(h) + print(f"[lsx] client key={client_key} response={h[:16]}... session_key={key.hex()}") + + # 3. plaintext ChallengeAccepted + conn.sendall(resp(1, f'ChallengeAccepted response="{h}"', "EALS").encode() + b"\0") + + # 4. encrypted request/response loop + while True: + data = conn.recv(65536) + if not data: + break + for chunk in filter(None, data.split(b"\0")): + try: + xml = lsx_decrypt(chunk + b"\0", key) + except Exception as e: + print("[lsx] decrypt fail:", e) + continue + mm = REQ_RE.search(xml) + if not mm: + print("[lsx] <<", xml) + continue + mid, name, rest = mm.group(1), mm.group(2), mm.group(3) + attrs = dict(ATTR_RE.findall(rest)) + reply = build_reply(mid, name, attrs) + print(f"[lsx] << id={mid} {name} {attrs}\n[lsx] >> {reply}") + conn.sendall(lsx_encrypt(reply, key)) + + +def main(): + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("127.0.0.1", 4216)) + s.listen(8) + print("[lsx] listening on 127.0.0.1:4216 (start FIFA 17 now)") + while True: + c, a = s.accept() + print("[lsx] connection from", a) + threading.Thread(target=serve, args=(c,), daemon=True).start() + + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/memtool.py b/fifa17-recon/tools/memtool.py new file mode 100644 index 0000000..942e441 --- /dev/null +++ b/fifa17-recon/tools/memtool.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Live FIFA17 /proc/mem reader + patcher. +Usage: + memtool.py read [nbytes] + memtool.py patch # saves original to /tmp/orig_.bin + memtool.py restore +""" +import sys, os, glob + +def find_pid(): + for d in glob.glob('/proc/[0-9]*'): + try: + if open(d+'/comm').read().strip() == 'FIFA17.exe': + return int(d.split('/')[-1]) + except Exception: + pass + raise SystemExit("FIFA17.exe not found") + +def main(): + cmd = sys.argv[1] + va = int(sys.argv[2], 16) + pid = find_pid() + path = f'/proc/{pid}/mem' + if cmd == 'read': + n = int(sys.argv[3]) if len(sys.argv) > 3 else 16 + with open(path, 'rb') as f: + f.seek(va); data = f.read(n) + print(f"pid={pid} va={va:#x} : " + data.hex()) + elif cmd == 'patch': + patch = bytes.fromhex(sys.argv[3]) + with open(path, 'rb') as f: + f.seek(va); orig = f.read(len(patch)) + open(f'/tmp/orig_{va:x}.bin', 'wb').write(orig) + with open(path, 'r+b') as f: + f.seek(va); f.write(patch) + f.seek(va); check = f.read(len(patch)) + print(f"pid={pid} va={va:#x} orig={orig.hex()} -> now={check.hex()}") + elif cmd == 'restore': + orig = open(f'/tmp/orig_{va:x}.bin', 'rb').read() + with open(path, 'r+b') as f: + f.seek(va); f.write(orig) + f.seek(va); check = f.read(len(orig)) + print(f"pid={pid} va={va:#x} restored={check.hex()}") + +main() diff --git a/fifa17-recon/tools/origin_login_probe.py b/fifa17-recon/tools/origin_login_probe.py new file mode 100644 index 0000000..49f2bbb --- /dev/null +++ b/fifa17-recon/tools/origin_login_probe.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Live read-only probe: did the pushed LSX event actually land? + +READ-ONLY. Never writes to the game. Safe to run against the live FIFA17.exe +while the main session drives it. + +Watches, once per second: + + OriginMgr.m_isLoggedIn = *(u8)( *[0x1448acf50] + 0x13 ) + Set to 1 by the Origin event dispatcher @0x146f1e060 case 2 (verified: + `cmp DWORD PTR [r9],1` -> `mov BYTE PTR [rcx+0x13],1`), which is reached + only from a server-pushed . 0 -> 1 means the push was dispatched. + + CAVEAT (verify: will-it-reach-login): this byte is a PROXY, not the + decisive consumer. It is written on the SAME dispatcher line that then + falls into the FE re-broadcast loop @0x146f1e116 -> callback 0x147350e30 + (packs the event tagged 0xdea12004 and republishes on FIFA's FE event + bus). Nothing downstream READS +0x13; the FE broadcast is what actually + propagates. So treat 0 -> 1 as "the frame was accepted", and confirm + real propagation with a gdb breakpoint on 0x147350e30 (bytes 48 83 ec 58). + Also note LoginStatePCLogin's own gate is a DIFFERENT object + ([0x144b86bf8]->vtbl+0x60), so this flag flipping does not guarantee + GetAuthCode fires. + + OriginMgr.m_loginError = *(u32)( *[0x1448acf50] + 0x14 ) + Cleared to 0 by the same code path. + + origin "online" byte = *(u8)[0x1448a3ac0] + INIT-SET, NOT DIAGNOSTIC: OriginMgr::Initialize writes this to 1 + unconditionally @0x146f340e1 (`mov BYTE PTR [rip+...],0x1`), so it does + NOT reflect GetInternetConnectedState. Shown for reference only; do not + read it as a live state field. + + FirstPartyAuthTokenRetriever slots + retriever = *[0x1448a3b20] + 0x4e98 ; slots at +0x08 and +0x10 + DoTick @0x146f199c0 walks these two; if both stay 0 no auth-code + request was ever enqueued and RequestAuthCodeSync @0x1470db3c0 is + never called. Non-zero here = GetAuthCode is imminent. + +Usage: python3 origin_login_probe.py [seconds] +""" +import glob +import struct +import sys +import time + +ORIGIN_MGR_PP = 0x1448acf50 # -> OriginMgr* +ORIGIN_ONLINE_BYTE = 0x1448a3ac0 # FIFA's separate "origin online" flag +SDK_PP = 0x1448a3b20 # -> OriginSDK*, retriever at +0x4e98 +RETRIEVER_OFF = 0x4e98 + + +def find_pid(): + for d in glob.glob("/proc/[0-9]*"): + try: + if open(d + "/comm").read().strip() == "FIFA17.exe": + return int(d.rsplit("/", 1)[-1]) + except OSError: + pass + return None + + +class Mem(object): + def __init__(self, pid): + self.f = open("/proc/%d/mem" % pid, "rb") + + def rd(self, va, n): + try: + self.f.seek(va) + b = self.f.read(n) + return b if b and len(b) == n else None + except OSError: + return None + + def u8(self, va): + b = self.rd(va, 1) + return None if b is None else b[0] + + def u32(self, va): + b = self.rd(va, 4) + return None if b is None else struct.unpack(" 1 else 1e9 + pid = find_pid() + if pid is None: + print("no FIFA17.exe running") + return 1 + print("pid", pid) + m = Mem(pid) + t0 = time.time() + last = None + while time.time() - t0 < limit: + mgr = m.u64(ORIGIN_MGR_PP) + logged = m.u8(mgr + 0x13) if mgr else None + err = m.u32(mgr + 0x14) if mgr else None + online = m.u8(ORIGIN_ONLINE_BYTE) + sdk = m.u64(SDK_PP) + r = (sdk + RETRIEVER_OFF) if sdk else None + s1 = m.u64(r + 0x08) if r else None + s2 = m.u64(r + 0x10) if r else None + row = (logged, err, online, s1, s2) + if row != last: + print("[%s] OriginMgr=%s m_isLoggedIn=%s loginError=%s " + "onlineByte=%s(init-set) | authSlots=%s,%s" + % (time.strftime("%H:%M:%S"), hx(mgr), logged, hx(err), + online, hx(s1), hx(s2))) + # Fire on any non-1 -> 1 transition (including the very first sample + # where OriginMgr was still null and last[0] was None), so a + # None -> 1 flip is not silently missed. + if last is not None and last[0] != 1 and logged == 1: + print(" *** m_isLoggedIn -> 1 : the pushed event was " + "DISPATCHED (proxy signal; confirm FE re-broadcast at " + "0x147350e30). Watch for LSX GetAuthCode next. ***") + if last is not None and not (last[3] or last[4]) and (s1 or s2): + print(" *** auth-code request ENQUEUED into " + "FirstPartyAuthTokenRetriever. ***") + last = row + time.sleep(1.0) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/origin_loginstate.md b/fifa17-recon/tools/origin_loginstate.md new file mode 100644 index 0000000..561d0f8 --- /dev/null +++ b/fifa17-recon/tools/origin_loginstate.md @@ -0,0 +1,290 @@ +# 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 `` 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 + +``` + +`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 + +``` + +Optional, keeps the profile consistent: + +```xml + +``` + +**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 `` 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::HandleMessage` @`0x14710c7d0`, matcher @`0x147102800`): + +``` +root element must be "LSX" +enter element (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 / / ... (hardcoded per handler) +then parse that element's attributes +``` + +So the wire form for every event is: + +```xml + +``` + +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` = the `` 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 = ; rax = GetServiceName(sdk, edx) ; 0x1470e4870 -> [sdk+0x3b0] + 0x20*edx +call (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. `` 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 `` (`0x147139e6e`) and +``. `` 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 ``. + +### 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 ``. +* `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 `). +* 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 `` 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 = '' +ONLINE_EVENT = '' +PROFILE_EVT = '' +``` + +Trigger points (after sending our normal ``): +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 `` 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. diff --git a/fifa17-recon/tools/origin_nucleus.md b/fifa17-recon/tools/origin_nucleus.md new file mode 100644 index 0000000..a53ac41 --- /dev/null +++ b/fifa17-recon/tools/origin_nucleus.md @@ -0,0 +1,294 @@ +# 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,