fifa17-recon: offline FUT squad-shell working + full card-system RE

Milestone: FIFA 17 Ultimate Team boots end-to-end on our offline backend
past every EA gate into the hub and a live Squads editor (correct 4-4-2,
5-star squad, no freezes).

Key findings this session:
- userMassInfo MUST stay {} (any content desyncs the massinfo parser
  0x180174630 -> tokenizer busy-loop freeze). Deliver the squad via
  GET /squad/0 (fetched on Squads-tab entry) instead.
- Player cards render generic because the card view-model (0x1800d7920)
  reads identity/rating/face from a resolved record at item+0x10, filled
  by a lookup (0x18011cca0) in the FUT item-definition std::map at
  CardsDb+0x160c0 -- which is EMPTY offline -> default blank record.
- Version advertising (itemDbVersion/checkServerDbVersion) is proven inert
  (JSON fields routed to the skip handler). Owned items don't auto-trigger
  a definition fetch. In-place map overwrite is dead (map stays empty).
- Definition-serving endpoints (item/resource, defid, item?idList) built +
  ready; the fetch trigger lives in the packed FIFA17.exe.

New: docs/CARD_SYSTEM.md (findings + ordered next-steps plan for real
player cards: patch-POC, dbdata extractor, drive FIFA17.exe fetch, or
live-memory store injection). Plus tools: fut_seed.py (squad ladder +
definition serving), fifadrive.sh, vgamepad.py, and the login-RE toolset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
This commit is contained in:
funman300
2026-08-01 20:24:30 -07:00
parent edab23f04a
commit 6ddd5e9d47
36 changed files with 9421 additions and 5 deletions
+114
View File
@@ -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=<ids>`, 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.
+340
View File
@@ -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,<id>` +
`lea rdx,<name>` + `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,<name>; 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<lsx::GetAuthCodeT,…>` 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).
+544
View File
@@ -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 `<GetAuthCode ClientId="…"/>` 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 `<Login>`
Our responder is request-driven only and never pushes. The client's OriginSDK *does* register
event handlers. Complete inventory of `Origin::EventHandler<lsx::…T, X>::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 `<Event sender="EALS"><Challenge …/></Event>` 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 `<Login>` 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<Entitlement> <- 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 <credential>
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 = <Origin auth code>`.
### 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=<code> ❌ 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* `<Login>` / `<OnlineStatusEvent>` event before it considers a user authenticated. | `Origin::EventHandler<lsx::LoginT, unsigned int>` 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 `<LSX><Event sender="EbisuSDK"><Login userid="33068179" IsLoggedIn="1"/></Event></LSX>` (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:<port>/`** — see §6.4.
### 6.2 LSX `GetAuthCode` — be ready the instant it is asked
```xml
<LSX><Response id="N" sender="EbisuSDK">
<AuthCode Code="QUXbLm3…opaque…" Return="QUXbLm3…opaque…"/>
</Response></LSX>
```
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
<LSX><Response id="N" sender="EbisuSDK"><QueryEntitlementsResponse>
<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" ResourceId="1027460" OfferId="1027460"
GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>
</QueryEntitlementsResponse></Response></LSX>
```
### 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":<epoch>,"assetId":<id>}],
"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 `<Login>` 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<lsx::LoginT,unsigned int>::HandleMessage` | `0x14393f900` |
| `Origin::EventHandler<lsx::OnlineStatusEventT,bool>::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`.
+401
View File
@@ -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/<pid>/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 `0x14389d6xx0x14389d928` 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 AZ 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<Entitlement>`.
`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<int64,uint32>`), `HWFG` hardwareFlags, `ISP` iSP(str), **`PSLM` latencyList(`list<int32>`)**, `QDAT` qosData, `TZ` timeZone(str), `UATT` userInfoAttribute(u64), `ULST` blazeObjectIdList(`list<ObjectId>`).
> 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<string,string>`**. (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 `0x14389fd500x14389fef8` 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 **`<nucleusConnect>/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: "<token>"}
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<AssociationList> }`; 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<string,string>}`) 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 <base>/connect/token` returning `{"access_token" : "<anything>"}`.
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 <va>` / `index <pat>` / `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`
@@ -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/<pid>/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 <pid> -ex 'p ((char*(*)(long,long,int))0x146e0d2a0)(0,0,<id>)'
```
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 1320 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/<now>` 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<int64,uint32>
HWFG hardwareFlags Blaze::HardwareFlags (bitfield)
ISP iSP string
PSLM latencyList list<int32>
QDAT qosData Blaze::Util::NetworkQosData
TZ timeZone string
UATT userInfoAttribute uint64
ULST blazeObjectIdList list<ObjectId>
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::PersonaDetails>
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>
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<string, map<string,string>>
(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.
+274
View File
@@ -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 <LSX><Response id="%d" sender=""><InternetConnectedState connected="0"/></Response></LSX>
0x6ffffc9351d0 <LSX><Response id="%d" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>
0x6ffffc9352b0 <LSX><Response id="%d" sender="EbisuSDK"><GetProfileResponse IsSubscriber="true"
PersonaId="%llu" AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US"
UserId="%llu" Persona="%s" IsUnderAge="false" CommerceCurrency="USD"/></Response></LSX>
0x6ffffc935470 ...GetConfigResponse Config="false"...
0x6ffffc935050/0x935170/0x935530 ...GetSettingResponse Setting="%s" / "false" / "production"...
0x6ffffc9350b0/0x9354d0 ...GetGameInfoResponse GameInfo="<locales>" / "false"...
0x6ffffc935230 ...IsProgressiveInstallationAvailableResponse ItemId="" Available="false"...
0x6ffffc935410 <LSX><Response id="%d" sender="EALS"><ChallengeAccepted response="%s"/></Response></LSX>
0x6ffffc935590 <LSX><Event sender="EALS"><Challenge key="2b8ee..." build="release" version="10,4,13,6637"/></Event></LSX>
```
`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 1922) 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 1922) → 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` → `<InternetConnectedState connected="1"/>` — **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 |
+72
View File
@@ -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('<Q', f.read(8))[0]
f.seek(ORIGINMGR_PP); om = struct.unpack('<Q', f.read(8))[0]
snap = None
if ab:
f.seek(ab + SLOT_OFF); snap = f.read(SPAN)
flag = None
if om:
f.seek(om + 0x13); flag = f.read(1)[0]
except Exception:
time.sleep(0.05); continue
if snap is not None and snap != last:
hx = " ".join(f"{b:02x}" for b in snap)
log(f"AUTH-REGION CHANGE @[{ab+SLOT_OFF:#x}]:")
log(f" {hx}")
# decode the two 8-byte slots the retriever cares about
s08 = struct.unpack('<Q', snap[0x08:0x10])[0]
s10 = struct.unpack('<Q', snap[0x10:0x18])[0]
log(f" +0x08={s08:#x} +0x10={s10:#x} (nonzero = auth request enqueued!)")
last = snap
if flag is not None and flag != last_flag:
log(f"m_isLoggedIn -> {flag}")
last_flag = flag
time.sleep(0.01)
if __name__ == "__main__":
main()
+113
View File
@@ -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
<serverinstanceinfo> 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 = <address member="N"><valu>...</valu></address>.
# member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)}.
body=(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<serverinstanceinfo>\n'
'\t<address member="0">\n'
'\t\t<valu>\n'
f'\t\t\t<hostname>{BLAZE_IP_STR}</hostname>\n'
f'\t\t\t<ip>{BLAZE_IP_U32}</ip>\n'
f'\t\t\t<port>{BLAZE_PORT}</port>\n'
'\t\t</valu>\n'
'\t</address>\n'
'\t<secure>0</secure>\n'
'\t<trialservicename></trialservicename>\n'
'\t<defaultdnsaddress>0</defaultdnsaddress>\n'
'</serverinstanceinfo>\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)<cl:
c=tls.recv(4096)
if not c: break
rest+=c
req=hdr+b"\r\n\r\n"+rest
line0=req.split(b"\r\n",1)[0].decode(errors="replace")
log(f"REDIR REQ {addr}: {line0}")
resp=build_response()
tls.sendall(resp)
log(f"REDIR SENT {addr} {len(resp)}B serverinstanceinfo -> {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")
+575
View File
@@ -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 <serverinstanceinfo> 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_<comp>_<cmd>_<n>.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<str,str>} carrying `display` and
# `redirect_uri`; this drives the Nucleus web login overlay.
# 2. Authentication::login (1/0x0A) with AUTH=<nucleus auth code>
# -> 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 = <address member="N"><valu>...
# member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)}
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<serverinstanceinfo>\n'
'\t<address member="0">\n'
'\t\t<valu>\n'
f'\t\t\t<hostname>{BLAZE_IP_STR}</hostname>\n'
f'\t\t\t<ip>{BLAZE_IP_U32}</ip>\n'
f'\t\t\t<port>{BLAZE_PORT}</port>\n'
'\t\t</valu>\n'
'\t</address>\n'
'\t<secure>0</secure>\n'
'\t<trialservicename></trialservicename>\n'
'\t<defaultdnsaddress>0</defaultdnsaddress>\n'
'</serverinstanceinfo>\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")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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.
)
+141
View File
@@ -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 = ('<LSX><Event sender="EALS"><Challenge key="%s" build="%s" version="%s"/>'
'</Event></LSX>' % (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()
+67
View File
@@ -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)<<shift; shift+=7
if not (b&0x80): break
return val,i
def walk(buf, depth=0, i=0, end=None):
if end is None: end=len(buf)
pad=' '*depth
while i < end:
if i+4>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}) <complex; raw from here> {buf[i:min(i+24,end)].hex()}")
# best-effort: skip nothing, bail to avoid misparse
return end
if i<end and buf[i]==0x00: # struct terminator
i+=1; return i
return i
def main():
data=open(sys.argv[1],'rb').read()
ln=struct.unpack('>I',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()
+96
View File
@@ -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), # <Login> 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('<Q', b)[0] if len(b) == 8 else 0
def disasm(path, va):
asm = path + ".asm"
with open(asm, "w") as out:
subprocess.run(["objdump", "-D", "-b", "binary", "-m", "i386:x86-64",
"-M", "intel", "--adjust-vma=%#x" % va, path],
stdout=out, stderr=subprocess.DEVNULL)
return asm
def main():
pid = find_pid()
if not pid:
print("FIFA17.exe not running — launch it first."); sys.exit(1)
man = open(os.path.join(OUT, "manifest.txt"), "w")
man.write("FIFA17 login-machinery dump pid=%d\n\n" % pid)
with open(f"/proc/{pid}/mem", "rb") as f:
for name, va, before, after in CODE:
start = va - before
data = read(f, start, before + after)
p = os.path.join(OUT, f"{name}_{start:x}.bin")
open(p, "wb").write(data)
disasm(p, start)
line = f"CODE {name:22s} window {start:#x}..{start+len(data):#x} ({len(data)}B) -> {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()
+69
View File
@@ -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/<name>.png (print path)
# ./fifadrive.sh key <keys...> focus, then send key(s) e.g. key Right Right Return
# ./fifadrive.sh hold <key> <ms> press-and-hold a key for <ms> (menus that need a beat)
# ./fifadrive.sh type <text...> focus, then type literal text (e.g. security answer)
# ./fifadrive.sh launch (re)launch FIFA via ~/Desktop/launch-fifa17.sh
# ./fifadrive.sh wait <secs> 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'" </dev/null >/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
+55
View File
@@ -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('<Q', f.read(8))[0]
if om:
f.seek(om + OFF_LOGGEDIN); cur = f.read(1)
if cur != b'\x01':
f.seek(om + OFF_LOGGEDIN); f.write(b'\x01')
f.seek(om + OFF_LOGINERR); f.write(b'\x00\x00\x00\x00')
if not first_pin:
print(f"[pin] OriginMgr={om:#x} -> 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()
+92
View File
@@ -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('<Q', b, 0x00, VPTR)
struct.pack_into('<Q', b, 0x08, VPTR2)
struct.pack_into('<I', b, 0x10, 2) # refcount=2 -> 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('<Q', f.read(8))[0]
def rd(va,n): f.seek(va); return f.read(n)
def wr(va,b): f.seek(va); f.write(b)
onlinemgr = rq(ONLINEMGR_PP); retr = onlinemgr + RETR_OFF
sdk = rq(SDK_PP)
slot = retr + 0x08
if "--revert" in sys.argv:
orig = json.load(open(SAVE)) if os.path.exists(SAVE) else {}
wr(sdk+SDK_DEFUSER, struct.pack('<Q', orig.get("defuser",0)))
wr(sdk+0x3a8, struct.pack('<Q', orig.get("defuser8",0)))
wr(slot, struct.pack('<Q', orig.get("slot",0)))
print(f"[revert] SDK+0x3a0/0x3a8 -> {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('<Q', sdk))
wr(sdk+0x3a8, struct.pack('<Q', sdk))
assert rq(sdk+SDK_DEFUSER)==sdk and rq(sdk+0x3a8)==sdk, "defuser write-back mismatch"
print(f"[+] OriginSDK[+0x3a0]/[+0x3a8] set -> {sdk:#x} (default user)")
# 3) gate 1 (the TRIGGER, set last): enqueue the node
wr(slot, struct.pack('<Q', NODE_VA))
assert rq(slot)==NODE_VA, "slot write-back mismatch"
print(f"[+] retriever+0x8 ({slot:#x}) -> {NODE_VA:#x} *** ENQUEUED ***")
print(" Watch /tmp/lsx.log for <GetAuthCode ClientId=\"FIFA17PC\">. Then node+0xE8 -> 1,")
print(" node+0xE0=200 = error (read node+0x58 msg). --revert to undo.")
if __name__ == "__main__":
main()
+349
View File
@@ -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 <GetAuthCode ClientId="..."/> -> <AuthCode .../>
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 `<GetAuthCode ClientId="…"/>` 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 `<Event>` "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<lsx::LoginT,unsigned int>::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=<our auth code>`; (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 |
+159
View File
@@ -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": []}
@@ -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
+341
View File
@@ -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
<LSX><Response id="%d" sender=""><InternetConnectedState
connected="0"/></Response></LSX>
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'<LSX><Response id="%d" sender=""><InternetConnectedState'
EMU_REGION = (0x6FFFFC931000, 0x6FFFFC93D000) # rwxp unpacked image
# (B) FIFA17.exe g_originOnline.
FLAG_VA = 0x1443337F8
FLAG_AFTER = b"\x01"
BACKUP_DIR = "/tmp/lsx_force_online"
LOGFILE = "/tmp/lsx_force_online.log"
def log(m):
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
print(line, flush=True)
try:
with open(LOGFILE, "a") as fh:
fh.write(line + "\n")
except Exception:
pass
# ------------------------------------------------------------------ process
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 Exception:
pass
return None
def rd(pid, va, n):
with open("/proc/%d/mem" % pid, "rb") as f:
f.seek(va)
return f.read(n)
def wr(pid, va, b):
with open("/proc/%d/mem" % pid, "r+b") as f:
f.seek(va)
f.write(b)
def backup(va, orig):
os.makedirs(BACKUP_DIR, exist_ok=True)
p = os.path.join(BACKUP_DIR, "orig_%x.bin" % va)
if not os.path.exists(p): # never overwrite the true original
with open(p, "wb") as fh:
fh.write(orig)
return p
# ------------------------------------------------------ template relocation
#
# The unpacked emu image address has been stable at 0x6ffffc931000 across our
# runs, but it is a runtime mapping -- do not trust it blindly. Verify the
# template is where we expect; if not, rescan the rwxp regions for it and
# recompute the patch offset from the template head.
def locate_emu_patch(pid):
"""-> (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()
+242
View File
@@ -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 <Challenge key="..."> IN PLAINTEXT.
client replies (plaintext) with response="..." and key="..." attrs.
server replies <ChallengeAccepted response="H"> 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'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
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 <AuthCode> (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'<OriginItem ItemId="{ENTITLEMENT_TAG}" EntitlementId="1" '
f'ResourceId="{CONTENT_ID}" OfferId="{CONTENT_ID}" '
f'GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>')
return (f'<LSX><Response id="{mid}" sender="EbisuSDK">'
f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
f'</Response></LSX>')
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'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
def serve(conn):
# 1. plaintext Challenge
chal = (f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
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()
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Live FIFA17 /proc/mem reader + patcher.
Usage:
memtool.py read <va_hex> [nbytes]
memtool.py patch <va_hex> <hexbytes> # saves original to /tmp/orig_<va>.bin
memtool.py restore <va_hex>
"""
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()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Live read-only probe: did the pushed LSX <Login> 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 <Event sender="LOGIN_EVENT"><Login
IsLoggedIn="true"/>. 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("<I", b)[0]
def u64(self, va):
b = self.rd(va, 8)
return None if b is None else struct.unpack("<Q", b)[0]
def hx(v):
return "??" if v is None else ("%#x" % v)
def main():
limit = float(sys.argv[1]) if len(sys.argv) > 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 <Login> 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())
+290
View File
@@ -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 `<Login>` event. Our request-only responder never sends it,
so FIFA's Origin manager has had `m_isLoggedIn = false` for the entire session — independently of
`GetInternetConnectedState connected="1"`.**
Add this **pushed** message to `lsx_responder.py` (encrypted like any other post-handshake
message: `hex(AES128-ECB(session_key, pkcs7(xml)))` + `\0`):
```xml
<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>
```
`sender="LOGIN_EVENT"` is **mandatory and exact** (proof below). `IsLoggedIn` is parsed as a
bool by literal comparison against `"false"` — anything that is not the exact string `false`
means true, so `"true"` or `"1"` both work; `"false"` / missing attribute means logged out.
Strongly recommended companion (FIFA also subscribes to this one, and it is likewise
push-only — never requestable):
```xml
<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>
```
Optional, keeps the profile consistent:
```xml
<LSX><Event sender="PROFILE_EVENT"><ProfileEvent Changed="0" UserId="33068179"/></Event></LSX>
```
**When to send:** any time after the client's first post-handshake request (the event handler
array is built inside `Origin::OriginSDK::Initialize`, which completes before the client issues
`GetConfig`). Practical recipe: push `<Login>` right after we answer the first `GetProfile`
(id=3), and push it again after answering `GetInternetConnectedState` (id=16/18) and after
`GetGameInfo UPTODATE`. Re-sending is harmless — the handler is idempotent (it just sets a
flag and broadcasts).
---
## Evidence chain
### 1. The SDK's LSX event surface (28 push-only messages)
`Origin::OriginSDK::RegisterEventCallback` (`0x14710e710`) indexes a fixed array of
pre-constructed handlers at `sdk + 0x278 + 8*eventEnum` (29 slots — `lea ebp,[rdx+0x1d]` in the
unregister-all path).
Every handler's matcher has the identical shape (e.g. `Origin::EventHandler<lsx::LoginT,unsigned
int>::HandleMessage` @`0x14710c7d0`, matcher @`0x147102800`):
```
root element must be "LSX"
enter element <Event> (name comes from handler, always "Event")
optional attr id="..." (HasAttribute check — may be omitted)
REQUIRED attr sender="..." (must be PRESENT and strcmp-equal to handler->sender)
enter element <Login> / <OnlineStatusEvent> / ... (hardcoded per handler)
then parse that element's attributes
```
So the wire form for every event is:
```xml
<LSX><Event sender="<SERVICE>"><ElementName attr="..."/></Event></LSX>
```
Full set of inner element names the SDK listens for (extracted from the 28 matchers in
`0x147101000..0x14710a000`):
```
AchievementSets BlockListUpdated BroadcastEvent ChatMessageEvent
ChunkStatus CoreContentUpdated CurrentUserPresenceEvent
GameMessageEvent GetPresenceResponse GroupEnterEvent GroupEvent
GroupInviteEvent GroupLeaveEvent IGOEvent IGOUnavailable
Login MinimizeRequest MultiplayerInvite MultiplayerInvitePending
OnlineStatusEvent PresenceVisibilityEvent ProfileEvent PurchaseEvent
QueryEntitlementsResponse QueryFriendsResponse RestoreRequest UserInvitedEvent
VoipStatusEvent
```
(plus the separate `LSXEvent<lsx::ChallengeT>` = the `<Challenge>` handshake event we already
send.)
### 2. `sender` value = the SDK's service-name enum. Login ⇒ `LOGIN_EVENT`
`0x14710df7f` is the SDK's "create all event handlers" routine. For each handler it does:
```
edx = <service enum>; rax = GetServiceName(sdk, edx) ; 0x1470e4870 -> [sdk+0x3b0] + 0x20*edx
call <MakeHandlerN>(sdk, rax /*sender string*/, r8 = sdk+0x278 /*array*/)
```
`GetServiceName`'s runtime table is initialised from the static string-pointer table at
**`0x144341420`**:
```
0=SDK 1=PROFILE 2=PRESENCE 3=FRIENDS 4=COMMERCE 5=RECENTPLAYER 6=IGO 7=MISC 8=LOGIN
9=UTILITY 10=XMPP 11=CHAT 12=IGO_EVENT 13=EALS_EVENTS 14=LOGIN_EVENT 15=INVITE_EVENT
16=PROFILE_EVENT 17=PRESENCE_EVENT 18=FRIENDS_EVENT 19=COMMERCE_EVENT 20=CHAT_EVENT
21=DOWNLOAD_EVENT 22=PERMISSION 23=RESOURCES 24=BLOCKED_USERS 25=BLOCKED_USER_EVENT
26=GET_USERID 27=ONLINE_STATUS_EVENT 28=ACHIEVEMENT 29=ACHIEVEMENT_EVENT 30=BROADCAST_EVENT
31=PROGRESSIVE_INSTALLATION 32=PROGRESSIVE_INSTALLATION_EVENT 33=CONTENT
```
The handler's `sender` string lands at `handler+0x48`, which is `this+0x10` for the
`IEventHandler` sub-object (secondary vtable at object+0x38) — exactly what
`HandleMessage` reads (`mov rbx,[rcx+0x10]`) and strcmp's the `sender` attribute against.
Resolved handler table (service enum → array slot → OriginEventT enum → element):
| OriginEventT | array slot | service enum | `sender` string | element |
|---|---|---|---|---|
| 0 | +0x00 | 0x0c | `IGO_EVENT` | `IGOEvent` |
| 1 | +0x08 | 0x0f | `INVITE_EVENT` | `MultiplayerInvite` |
| **2** | **+0x10** | **0x0e** | **`LOGIN_EVENT`** | **`Login`** |
| 3 | +0x18 | 0x10 | `PROFILE_EVENT` | `ProfileEvent` |
| 4 | +0x20 | 0x11 | `PRESENCE_EVENT` | `GetPresenceResponse` |
| 5 | +0x28 | 0x12 | `FRIENDS_EVENT` | `QueryFriendsResponse` |
| 6 | +0x30 | 0x13 | `COMMERCE_EVENT` | `PurchaseEvent` |
| 7 | +0x38 | 0x15 | `DOWNLOAD_EVENT` | `CoreContentUpdated` |
| 8 | +0x40 | 0x19 | `BLOCKED_USER_EVENT` | `BlockListUpdated` |
| **9** | **+0x48** | **0x1b** | **`ONLINE_STATUS_EVENT`** | **`OnlineStatusEvent`** |
| 10 | +0x50 | 0x1d | `ACHIEVEMENT_EVENT` | `AchievementSets` |
| 11 | +0x58 | 0x0f | `INVITE_EVENT` | `MultiplayerInvitePending` |
| 12 | +0x60 | 0x14 | `CHAT_EVENT` | `ChatMessageEvent` |
| 13 | +0x68 | 0x11 | `PRESENCE_EVENT` | `CurrentUserPresenceEvent` |
| 14 | +0x70 | 0x1e | `BROADCAST_EVENT` | `BroadcastEvent` |
| 15 | +0x78 | 0x11 | `PRESENCE_EVENT` | `PresenceVisibilityEvent` |
| 16 | +0x80 | 0x13 | `COMMERCE_EVENT` | `QueryEntitlementsResponse` |
| 17 | +0x88 | 0x20 | `PROGRESSIVE_INSTALLATION_EVENT` | `ChunkStatus` |
| 18 | +0x90 | 0x0c | `IGO_EVENT` | `IGOUnavailable` |
(13 independent service-enum/element pairings all agree with the table — the mapping is not a guess.)
### 3. `<Login>` carries exactly one attribute: `IsLoggedIn`, parsed as a bool
Deserializer `0x147138640` (reached via `0x14712fd80` from the `Login` matcher) reads exactly one
attribute name, `"IsLoggedIn"` (string @`0x14394e0f0`), then converts with `0x14713ffa0`:
```
0x14713ffc8: lea rdx, "false"
0x14713ffd2: call strcmp
0x14713ffda: setne al ; value != "false" => true
0x14713ffdd: mov [rdi], al ; stored as a byte
```
Same helper is used for `<OnlineStatusEvent isOnline="...">` (`0x147139e6e`) and
`<PresenceVisibilityEvent Visible="...">`. `<ProfileEvent>` carries `Changed` + `UserId`.
### 4. FIFA subscribes to the Login event, and it is the ONLY event that mutates
### FIFA's Origin login state
FIFA registers 9 Origin event callbacks in a loop at `0x146f33e90`+ (call site `0x146f34068`
`OriginRegisterEventCallback` @`0x1470db310`), from a constant array:
```
xmm0 @0x143565970 = {0,1,2,3}
xmm1 @0x143902060 = {4,5,6,7}
plus 9
=> enums {0,1,2,3,4,5,6,7,9} (all with the same callback 0x146f20c20)
```
Cross-referenced against the table above, FIFA subscribes to exactly:
IGOEvent, MultiplayerInvite, **Login**, ProfileEvent, GetPresenceResponse, QueryFriendsResponse,
PurchaseEvent, CoreContentUpdated, **OnlineStatusEvent**.
`0x146f20c20` forwards to the FIFA Origin-manager dispatcher `0x146f1e060`
(manager singleton via `0x146f28790`, global `[0x1448acf50]`). Its `case 2` is the only case that
writes state:
```
0x146f1e099: cmp edx, 2 ; eventEnum == 2 (Login)
0x146f1e09e: cmp DWORD PTR [r9], 1 ; converted IsLoggedIn == 1 ?
0x146f1e0a2: lea rbx,[rcx+0x80] ; listener list for event 2
0x146f1e0ab: mov BYTE PTR [rcx+0x13], 1 ; <== OriginMgr.m_isLoggedIn = TRUE
0x146f1e0af: mov DWORD PTR [rcx+0x14], 0 ; <== clear login error/reason
(else)
0x146f1e0b8: mov BYTE PTR [rcx+0x13], 0 ; m_isLoggedIn = FALSE
0x146f1e116: broadcast to listener list
```
All other cases (0,1,3,4,5,6,7,9) only broadcast to their listener list.
`OriginMgr+0x13` is written from exactly two places in the whole image: this Login-event case,
and `0x146f20c80` (the async login/`CheckOnline` completion callback, which also clears +0x18).
**There is no requestable LSX verb that sets it** — it is unreachable without a pushed `<Login>`.
### 5. Why this matters: GetAuthCode is enqueue-driven and has never been enqueued
* FIFA-level auth-code issuer: `FifaOnline::FirstPartyAuthTokenRetriever::DoTick` @`0x146f199b9`
`OriginRequestAuthCodeSync` @`0x1470db3c0``Origin::OriginSDK::RequestAuthCodeSync`
@`0x1470e67f0` → LSX `<GetAuthCode>`.
* `DoTick` walks a **2-slot request array** at `retriever+0x8` and does nothing when both slots
are null (`0x146f199e0: mov rsi,[rbx]; test rsi,rsi; je <exit>`).
* Enqueue path: `RequestAuthCode` @`0x146f5b8ab`, reached via thunk `0x146f57bf0`
(`mgr = *[0x1448a3b20]` gated by byte `[0x1448a3ac3]`; retriever = `mgr + 0x4e98`).
* **Live read of the stuck game (PID 3362053) confirmed:**
`[0x1448a3ac3] = 1`, `mgr = 0x43dc3e70`, retriever = `0x43dc8d08`, **both slots = 0x0**
no auth-code request has ever been created. This is why `/tmp/openfut_authcode.txt` stays
empty and `GetAuthCode` never appears in the LSX log. The SDK itself has no login gate inside
`RequestAuthCodeSync`; the gate is entirely FIFA-side, upstream of the enqueue.
### 6. Corroborating: `LoginStatePCLogin` has an explicit "not logged in to Origin" abort
FIFA's Blaze login state machine (`LoginStateMachineImpl`, `LoginStateBase` subclasses; per-state
`GetName` thunks in `0x14719b360..0x14719b4a7`; states: ShowMaintenance, Isp, LoadIspAccountInfo,
Connect, LoadConfig, Logout, VersionCheck, Login, **PCLogin**, VerifyAccount, UpgradeAccount,
LoginComplete, Unsuspend, CheckUser, RecheckUser, WebOffer).
`LoginStatePCLogin` vtable @`0x14395c188`; its driver is `0x1471b58e0` (a 0x19-case sub-state
machine on `this+0x260`). Its very first sub-state does:
```
0x1471b59a5: rcx = *[0x144b86bf0]; call [vt+0x60] ; get the Origin/Ebisu session object
0x1471b59b5: test rax,rax
0x1471b59b8: je 0x1471b5b42 ; NULL -> failure branch
...
0x1471b5b64: lea rax, "TXT_NOT_LOGIN_TO_EBISU" ; loc key stored at state+0x80
0x1471b5b72: mov DWORD PTR [r14+0x260], 1 ; -> error sub-state
```
("Ebisu" is EA's internal codename for Origin; the sibling key
`TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE` is the "title version outdated" gate we already beat via
`GetGameInfo UPTODATE`. Both live at `0x1439633e8` / `0x14395bfc0`-ish in the same loc-key block.)
Also proven live: FIFA's separate "Origin is online" byte `[0x1448a3ac0]` was **1** during the
stuck session, so the *internet/online* gate (fed by `GetInternetConnectedState`, callback
`0x146f1e6ae`, broadcasts `FE::FIFA::OriginOnlineEvent`) is already satisfied. **Online ≠ logged
in.** They are two different flags with two different feeds; we only ever fed the first one.
---
## Wire recipe for `lsx_responder.py`
Same framing as the existing `<Challenge>` event we already push successfully
(NUL-terminated; after the handshake everything is `hex(AES128-ECB(session_key, pkcs7(xml)))`):
```python
def push_event(conn, key, xml):
conn.sendall(lsx_encrypt(xml, key))
LOGIN_EVENT = '<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>'
ONLINE_EVENT = '<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>'
PROFILE_EVT = '<LSX><Event sender="PROFILE_EVENT"><ProfileEvent Changed="0" UserId="33068179"/></Event></LSX>'
```
Trigger points (after sending our normal `<Response>`):
1. after answering the first `GetProfile` → push `LOGIN_EVENT`, then `ONLINE_EVENT`
2. after answering `GetInternetConnectedState` → push `LOGIN_EVENT` again
3. after answering `GetGameInfo UPTODATE` → push `LOGIN_EVENT` + `ONLINE_EVENT` again
Notes / gotchas:
* `sender` must be **exactly** `LOGIN_EVENT` / `ONLINE_STATUS_EVENT`. A wrong or missing `sender`
makes `HandleMessage` return false and the message is silently dropped (no error, no crash) —
which is exactly the failure mode to watch for.
* An `id` attribute on `<Event>` is optional.
* Sending an event before `OriginSDK::Initialize` has built the handler array is a silent no-op,
so never send before the client's first request.
* If the game still doesn't call `GetAuthCode` after this, the next thing to instrument is
`OriginMgr+0x13` (byte) and `OriginMgr+0x14` (dword) via `/proc/PID/mem`
(`OriginMgr = *[0x1448acf50]`): +0x13 flipping 0→1 proves the event landed and moves the
investigation downstream to `LoginStatePCLogin` / the Blaze `Authentication::logout` loop.
## Open / not proven
* I could not statically locate the consumer that *reads* `OriginMgr+0x13` (no matching
`[reg+0x13]` byte-read in the FIFA online region) — it is presumably an inlined accessor or a
reaction to the event-2 broadcast. So "flag false ⇒ GetAuthCode never enqueued" is a strong
inference from (a) the flag being login-specific, (b) `LoginStatePCLogin`'s
`TXT_NOT_LOGIN_TO_EBISU` abort and (c) the live-confirmed empty auth-code slots — but the exact
read site is unconfirmed. Instrumenting +0x13 live is the cheap way to close this.
* The one identified event-2 listener list subscriber is the EAStore/DLC subsystem
(registrar `0x14735077f`, listener `0x147350e30`), not the login flow — consistent with the
login gate reading the flag rather than listening.
* The Blaze side still ends in `Authentication::logout (1/0x46)` → disconnect →3s ping-reconnect
loop (`/tmp/blaze_responder.log`). If the Login event does not change that, the second
candidate is the FIFA `LoginStateMachine` transition out of `LoginStateLogout`, which is a
separate (Blaze-side) investigation.
+294
View File
@@ -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 <Request recipient="" id="22"><GetInternetConnectedState version="3"/></Request>
resp <Response id="17" sender=""><InternetConnectedState connected="0"/></Response> <-- THE GATE
resp <Response id="19..22" sender=""><ErrorSuccess Code="0" Description=""/></Response>
```
## 2. What the Steampunks emu actually is (the decisive structural finding)
`stp-origin_emu.dll` is UPX-packed on disk (hence garbled strings); the unpacked image
lives at `0x6ffffc931000-0x6ffffc93d000` (**rwxp**, already writable).
It is **not an LSX server. It never parses a request.** It is a *blind fixed-script
replayer*: it sends 18 hard-coded responses with hard-coded ids 1..18, in a fixed order,
whatever the game asks — then loops forever emitting `ErrorSuccess`.
Reconstructed script (`lea r8,<template>` + `mov r9d,<id>` pairs), **confirmed against the
live capture** (ids 15→18 match exactly):
| id | response template |
|----|---|
| 1 | `ChallengeAccepted response="%s"` |
| 2 | `GetConfigResponse Config="false"` |
| 3 | `GetProfileResponse ... PersonaId=%llu ... Persona=%s` |
| 4 | `GetSettingResponse Setting="false"` |
| 5 | `GetGameInfoResponse GameInfo="false"` |
| 6 | `GetGameInfoResponse GameInfo="ar_SA,…,zh_TW"` |
| 7 | `GetSettingResponse Setting="production"` |
| 8 | `GetSettingResponse Setting="false"` |
| 9 | `IsProgressiveInstallationAvailableResponse Available="false"` |
| 10 | `GetProfileResponse …` |
| 11 | `GetGameInfoResponse GameInfo="ar_SA,…"` |
| 12 | `GetSettingResponse Setting="%s"` |
| 13 | `GetGameInfoResponse GameInfo="false"` |
| 14 | `ErrorSuccess Code="0"` |
| 15 | `GetSettingResponse Setting="production"` |
| 16 | `GetGameInfoResponse GameInfo="false"` |
| **17** | **`InternetConnectedState connected="0"` ← the offline verdict** |
| 18 | `GetProfileResponse …` (PersonaId 33068179 / CAGE) |
| 19+ | `ErrorSuccess Code="0"` **forever** (loop @ `0x6ffffc932dd3`, `esi++`) |
Two consequences:
1. The offline verdict is a **hard-coded string literal**, not a computed decision. There
is no "check" to patch — only a canned answer.
2. **The emu can never answer `GetAuthCode`, `QueryEntitlements`, or a *second*
`GetInternetConnectedState`.** It has no `AuthCode` / `QueryEntitlementsResponse`
template at all. Everything after step 18 is `ErrorSuccess`. That is exactly why the
re-probe at `id=22` above got `ErrorSuccess` instead of a connected-state answer.
## 3. Socket setup — why we can preempt it
`sub_0x6ffffc932130` (the `DllInit` server thread):
```
WSAStartup(0x202)
getaddrinfo("127.0.0.1", "4216", {AI_PASSIVE, AF_INET, SOCK_STREAM, IPPROTO_TCP})
socket() -> bind() -> listen(0x7fffffff) -> accept()
closesocket(listen_fd) <-- @0x6ffffc9322bb, immediately after accept()
```
* **No `SO_REUSEADDR`.** On `bind()` failure it branches to `0x6ffffc932245`:
`freeaddrinfo → closesocket → WSACleanup → return 1`. It exits **cleanly** — no crash,
no retry.
* It accepts **exactly one** connection then closes the listener. Confirmed live: `ss`
shows the ESTAB pair `127.0.0.1:4216 <-> 127.0.0.1:51162` but **no LISTEN on 4216**.
So: **bind 127.0.0.1:4216 before launching the game and the stub politely stands down**,
and the game's OriginSDK connects to us. No DNAT, no hosts trick, no DLL patching needed.
(Wine's WS2_32 maps to real Linux sockets, so a normal Linux listener wins the port.)
## 4. LSX wire protocol (fully reversed)
Transport: TCP `127.0.0.1:4216`, each message a **NUL-terminated** byte string
(`send(len = strlen+1)`).
**Handshake — plaintext:**
1. server → client:
`<LSX><Event sender="EALS"><Challenge key="<32 hex>" build="release" version="10,4,13,6637"/></Event></LSX>`
2. client → server: message carrying `response="…"` and `key="…"`
3. server → client:
`<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="H"/></Response></LSX>`
where `H = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED))`, 64 hex chars,
and `K_FIXED = 000102030405060708090a0b0c0d0e0f` (emu `.rdata` @ `0x6ffffc935038`,
read live).
**Session key derivation** (`sub_0x6ffffc931f10` tail, `0x9320bf``0x932101`), using
MSVCR120 `srand`/`rand` (IAT `0x6ffffc9340a0` / `0x6ffffc934100`, resolved by export name):
```
srand(7); r0 = rand() # r0 == 61 (verified numerically)
bx = (uint16)((H[0] << 8) + H[1]) # first two ASCII chars of H
srand(bx + r0)
key[i] = (uint8)rand() for i in 0..15
```
**All later messages:** `hex_lower( AES128_ECB( pkcs7_pad16( xml ) ) )` + `NUL`
(encoder `sub_0x931dc0`, decoder `sub_0x931ce0`, byte-wise AES with S-box @ `0x934330`
and inverse S-box @ `0x934430`, hex format `"%02x"` @ `0x9345d0`).
Reference implementation, round-trip verified:
`/tmp/claude-1000/-home-alex-Documents-OpenFUT/b89d9ca6-265d-4444-969c-6923501c168a/scratchpad/lsx_responder.py`
```
ChallengeAccepted response = 00b9c8afef744cbc1dd1b1e8aca6a2ed5fb0f43c5e287f833ea2750983772e0f
derived session key = 4a216b49ea0b8c8a7b9864c3d0dd07c9
roundtrip OK = True
```
## 5. The fix
### 5a. Minimal /proc/mem patch — one byte (stopgap only)
| | |
|---|---|
| **VA** | `0x6ffffc9353f4` |
| **before** | `30` (`'0'`) |
| **after** | `31` (`'1'`) |
Context (`0x6ffffc9353b0`, verified live):
`<LSX><Response id="%d" sender=""><InternetConnectedState connected="0"/></Response></LSX>`
```bash
python3 .../scratchpad/memtool.py patch 6ffffc9353f4 31
```
Page is already `rwxp`; the format string is re-read on every use.
**Limits — read these before relying on it.** (i) It must be applied **before the emu
reaches script step 17**, i.e. right after launch; pressing **Q to re-connect will not
help**, because the emu is a linear script and is permanently parked in the `ErrorSuccess`
loop for the rest of this run. (ii) Even applied in time it only fixes the *one* scripted
occurrence — the game's later re-probe still gets `ErrorSuccess`. (iii) It does **not**
give us `GetAuthCode` or `QueryEntitlements`, so it gets us past gate 1 straight into
gate 2. Use it only as a cheap one-shot experiment to confirm `OriginIsOnlineTrue` fires.
### 5b. The real fix — replace the emu with our own LSX responder (recommended)
Because the offline verdict is a canned string in a script that also cannot answer the
auth-code or entitlement questions, **patching cannot get us to a logged-in state**. Serve
LSX ourselves:
```bash
python3 /tmp/.../scratchpad/lsx_responder.py # bind 4216 FIRST
# then launch FIFA 17 — stub's bind() fails, it returns 1, we own the socket
```
This is request-*driven* (parses `<Request id=… ><Verb …/>`), so it survives re-probes,
arbitrary ordering, and the reconnect loop.
## 6. Exact LSX responses the forced-online path must emit
Element and attribute names below were read out of FIFA17.exe's own LSX name tables
(element table @ `0x143937900` / `0x14394dc00`, attribute pool @ `0x14394de00`).
```xml
<!-- THE GATE -->
<LSX><Response id="N" sender=""><InternetConnectedState connected="1"/></Response></LSX>
<!-- auth code handed to Blaze; element name confirmed as <AuthCode> -->
<LSX><Response id="N" sender="EbisuSDK"><AuthCode Code="<blob>" Return="<blob>"/></Response></LSX>
<!-- identity: MUST match stp-origin_emu.ini [Globals] and our Blaze side -->
<LSX><Response id="N" sender="EbisuSDK"><GetProfileResponse IsSubscriber="true"
PersonaId="33068179" AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US"
UserId="33068179" Persona="CAGE" IsUnderAge="false" CommerceCurrency="USD"/></Response></LSX>
<!-- online entitlement -->
<LSX><Response id="N" sender="EbisuSDK"><QueryEntitlementsResponse>
<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" ResourceId="1027460"
OfferId="1027460" GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>
</QueryEntitlementsResponse></Response></LSX>
```
Recovered attribute pool relevant here: `connected` (InternetConnectedState),
`ClientId` (GetAuthCode request), `PersonaId · Persona · AvatarId · Country · IsUnderAge ·
IsSubscriber · GeoCountry · CommerceCountry · CommerceCurrency` (GetProfileResponse),
`ItemId · EntitlementId · ResourceId · GrantDate · OfferId · bIsOwned · Uses` (Entitlement).
**One open item:** the value attribute of `<AuthCode>` is not 100 % pinned. The pool
position between `ClientId` (GetAuthCode) and `connected` (InternetConnectedState) is
empty, which means it is suffix-shared — `Code` (the tail of the pooled string
`"AuthCode"`) is the strong candidate, with `Return` the alternative. The responder emits
**both attributes**; a name-keyed XML attribute reader takes the one it knows and ignores
the other, so this resolves itself on the next run. Confirm from the log which one the
client consumes.
## 7. Auth-code → Blaze handoff (keeping a1/a3 consistent)
Flow, from the OriginSDK symbols in FIFA17.exe
(`…\External\EA\OriginSDK\src\impl\…`) and the `Blaze::Authentication` symbol set:
1. `origin.nav` probes `GetInternetConnectedState` → needs `connected="1"`
emits `OriginIsOnlineTrue``startFutBlazeLogin`.
2. The client calls LSX `GetAuthCode` (with a `ClientId`) → our `<AuthCode Code="…"/>`.
This is `lsx::GetAuthCodeT → lsx::AuthCodeT`, handler
`…GetAuthCodeT,struct lsx::AuthCodeT…::HandleMessage` @ `0x1439385cf`.
3. That code is then presented to **Blaze component `0x0001` (Authentication)** — the
nucleus path (`nucleusConnect` / `nucleusConnectTrusted` / `nucleus_id`,
`ExpressLoginRequest`, `GetAuthTokenResponse`, `GetUserAccessTokenResponse`). This is
the empty-payload `0x0001/0x0046` call we already saw stall in the session log.
4. Blaze then validates entitlements (`AUTH_ERR_NO_SUCH_ENTITLEMENT`,
`AUTH_ERR_ENTITLEMENT_TAG_REQUIRED`) and persona
(`AUTH_ERR_INVALID_PERSONA`, `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA`,
`AUTH_ERR_PERSONA_NOT_FOUND`).
**Consistency contract for the Blaze side (a1/a3):**
| field | value | source of truth |
|---|---|---|
| persona / nucleus id | `33068179` | `stp-origin_emu.ini` + LSX `GetProfileResponse` |
| persona name | `CAGE` | same |
| entitlement tag | `ONLINE_ACCESS` | FIFA17.exe `0x1438991e8` (retail exe) |
| content / offer id | `1027460` | EA offer id for FIFA 17 |
| country / currency | `US` / `USD` | LSX `GetProfileResponse` |
| auth code | whatever our LSX `<AuthCode Code=…>` returned | must be echoed/accepted verbatim by Blaze Authentication |
Since we author *both* ends, the auth code can be any opaque token — but the Blaze
Authentication reply **must** return the same `33068179` / `CAGE`, or the client trips the
persona-mismatch errors above. Recommend a shared constants module so the LSX responder
and `blaze_responder_v2.py` cannot drift.
## 8. Suggested next run
1. Start `lsx_responder.py` (binds 4216).
2. Start the Blaze stack (`blaze_responder_v2.py`).
3. Launch FIFA 17. Watch the LSX log for `GetInternetConnectedState``connected="1"`,
then for `GetAuthCode` and which attribute the client reads back.
4. Expect the nav flow to advance `OriginIsOnlineTrue → startFutBlazeLogin`, putting the
stall back on Blaze `0x0001` — which is then a1/a3's territory, now with a real auth
code and a consistent persona.
## Appendix — addresses
| what | VA |
|---|---|
| emu image base | `0x6ffffc930000` |
| emu unpacked code (rwxp) | `0x6ffffc931000-0x6ffffc93d000` |
| `InternetConnectedState connected="0"` template | `0x6ffffc9353b0` |
| **patch byte** (`'0'``'1'`) | **`0x6ffffc9353f4`** |
| fixed AES key `000102…0f` | `0x6ffffc935038` |
| encoder (pad+AES+hex) | `0x6ffffc931dc0` |
| decoder (hex+AES+unpad) | `0x6ffffc931ce0` |
| challenge parse + key derive | `0x6ffffc931f10` |
| server thread (socket setup) | `0x6ffffc932130` |
| `closesocket(listener)` after accept | `0x6ffffc9322bb` |
| bind-failure exit path | `0x6ffffc932245` |
| `ErrorSuccess` forever-loop | `0x6ffffc932dd3` |
| AES S-box / inv S-box | `0x6ffffc934330` / `0x6ffffc934430` |
| nav flow `OriginIsOnlineTrue` | `0x41bc5e2e` |
| error string "log in to Origin in Online Mode." | `0x7b8fab9` |
| LSX response element table | `0x143937900` |
| LSX request element table | `0x14394dc00` |
| LSX attribute name pool | `0x14394de00` |
| `ONLINE_ACCESS` / `TRIAL_ONLINE_ACCESS` | `0x1438991e8` |
Tools written this pass (all in `…/scratchpad/`):
`origin/lsxdump.py` (harvest all LSX messages from live memory),
`origin/emu_live.bin` + `origin/emu_text.asm` (unpacked emu image + disassembly),
`lsx_responder.py` (clean-room LSX server).
+366
View File
@@ -0,0 +1,366 @@
# Util::preAuth (component 0x0009 / command 0x0007) — PreAuthResponse + Fire2 reply rules
Research for the FIFA 17 offline Blaze emulator. **Clean-room.** Nothing below comes from
the 2021 EA source leak. Every item is tagged with a provenance class:
| Class | Meaning |
|---|---|
| **(a-obs)** | Observed directly in *our own* FIFA 17 client's traffic (`fifa17-recon/captures/blaze/`) |
| **(a-cr)** | Independent third-party clean-room reimplementation / packet capture (repos listed below) |
| **(a-conv)** | Converged: ≥3 independent (a-cr) sources agree byte-for-byte |
| **(b-?)** | Provenance unverified — treat as suspect, do not copy verbatim without an independent check |
---
## 0. Sources used (and their provenance)
| Ref | What | Game / Blaze ver | Provenance |
|---|---|---|---|
| **R1** | `scratchpad/grid-blaze/` = [grid-leak/blaze](https://github.com/grid-leak) (Rust) | Mirror's Edge Catalyst, server `Blaze 15.1.1.0.5` | (a-cr) README explicitly states "clean-room implementation based entirely on network analysis"; credits packet-capture contributors |
| **R2** | `scratchpad/pamplona-future/` = [ploxxxy/pamplona-future](https://github.com/ploxxxy/pamplona-future) (TS) | MEC, same | (a-cr) same lineage as R1 (R1 is its Rust successor) |
| **R3** | `scratchpad/tdf/` = [jacobtread/tdf](https://github.com/jacobtread/tdf) (Rust) | generic Heat2 codec | (a-cr) codec only, derived from PocketRelay network RE |
| **R4** | `scratchpad/catalyst-mitm/` = [ploxxxy/catalyst-mitm](https://github.com/ploxxxy/catalyst-mitm) | MEC MITM capture tool | (a-cr) capture tooling only, no schema |
| **R5** | [Khysnik/Z7](https://github.com/Khysnik/Z7) — `Research/data/Blaze/**/*.txt` **decoded live captures** + `MasterServer/src/**` (C++ server) | PvZ Garden Warfare 2, server `Blaze 15.1.1.4.6`, client BSDK `15.1.1.1.0` | (a-cr) repo self-describes as "a reverse-engineered blaze server"; the `Research/data` files are decoded wire captures with redactions |
| **R6** | [Khysnik/GW2BlazeServer](https://github.com/Khysnik/GW2BlazeServer) + [Khysnik/BlazeSDK](https://github.com/Khysnik/BlazeSDK) (Go) | GW2 | (a-cr) working Fire2 codec + server; `Fire2.go` header doc-comment is the clearest framing spec found |
| **R7** | [PocketRelay/PocketArk](https://github.com/PocketRelay/PocketArk) `src/blaze/models/util.rs` | Mass Effect (ME4/Andromeda-era), `INST="masseffect-4-pc"` | (a-cr) PocketRelay lineage = network RE |
| **R8** | [Aim4kill/BlazeSDK](https://github.com/Aim4kill/BlazeSDK) `Blaze3SDK/Blaze/Util/PreAuthResponse.cs`, `ProtoFire/Frames/*` | Blaze **3.x** SDK reimplementation | **(b-?)** Contains full EA class names, member names (`mAnonymousChildAccountsEnabled`), TDF member indices and tag hashes. That level of detail is consistent with *reflection extraction from a game binary* (same technique we use), but the repo carries **no provenance statement**. Used here **only** to confirm field *semantics* that are already independently confirmed by (a-cr) sources. Do not copy code from it. |
Local copies of everything fetched: `/tmp/claude-1000/-home-alex-Documents-OpenFUT/b89d9ca6-265d-4444-969c-6923501c168a/scratchpad/refs/`
Notable **negative result**: no FIFA-specific Blaze emulator exists publicly. GitHub code
search for `fifa-2017-pc`, `"-PC-SERVER-BLAZE" fifa`, `INST "fifa-2017" blaze` returns nothing.
We are the first. All schema below must be adapted from the MEC / GW2 / ME 15.x cousins.
---
## 1. ⚠️ CORRECTION to our previously assumed Fire2 header layout
The header layout in the task brief was **wrong** and would have produced replies the client
drops. What we read as `msgType` was actually the low byte of a 24-bit message number.
### Correct layout (16 bytes, big-endian) — **(a-conv)**: R1 `packet.rs:120-180`, R2 `blaze.ts:48-95`, R5 `packet.cpp:85-170`, R6 `Fire2.go` header comment + `Fire2Encoder.go:EncodePacket`
```
[0:4] u32 payload length (NOT counting header or metadata)
[4:6] u16 metadata length (bytes of extra TDF struct placed BETWEEN header and payload)
[6:8] u16 component id
[8:10] u16 command id
[10:13] u24 message number <-- 3 bytes, big-endian
[13] u8 (msgType << 5) | (userIndex & 0x1F)
[14] u8 options (OPTION_IMMEDIATE = 0x01)
[15] u8 reserved (0)
```
Wire order: `header(16) || metadata(metaLen) || payload(payloadLen)`.
There is **no error-code field in the Fire2 header** (that is Fire *v1*, a different 12-byte
frame with an error u16 at [6:8] — see R8 `ProtoFire/Frames/FireFrame.cs`). Do not echo an
"error/msgId" field; it does not exist here.
### Re-decode of our own captures with the corrected layout — **(a-obs)**
| file | payloadLen | comp | cmd | **msgNum** | byte13 | msgType |
|---|---|---|---|---|---|---|
| `blaze_fire2_46521.bin` | 203 | 0x0009 | 0x0007 | **0** | 0x00 | MESSAGE |
| `blaze_fire2_45833.bin` | 0 | 0x0009 | 0x0002 | **1** | 0x00 | MESSAGE |
| `blaze_fire2_37161.bin` | 203 | 0x0009 | 0x0007 | **2** | 0x00 | MESSAGE |
| `blaze_fire2_36227.bin` | 0 | 0x0009 | 0x0002 | **3** | 0x00 | MESSAGE |
| `blaze_fire2_40571.bin` | 203 | 0x0009 | 0x0007 | **4** | 0x00 | MESSAGE |
| `blaze_fire2_39309.bin` | 0 | 0x0009 | 0x0002 | **5** | 0x00 | MESSAGE |
| `blaze_fire2_33803.bin` | 203 | 0x0009 | 0x0007 | **6** | 0x00 | MESSAGE |
| `blaze_fire2_41609.bin` | 0 | 0x0009 | 0x0002 | **7** | 0x00 | MESSAGE |
So: FIFA 17 sends `preAuth` then immediately an **empty `Util::ping` (cmd 2) without waiting
for the preAuth reply**, on every connection attempt, and `msgNum` is a *process-global*
counter that keeps incrementing across reconnects. Every one of these frames is
`msgType = 0 (MESSAGE)`, `userIndex = 0`. This exactly matches GW2's observed order
(R5: msgNum 0 = preAuth, msgNum 1 = ping).
### MessageType enum — **(a-conv)**: R1 `packet.rs:11-20`, R2 `blaze.ts:5-12`, R6 `Types.go`, R8 `MessageType.cs`
```
MESSAGE = 0 (client request)
REPLY = 1 (<-- what we must send for PreAuthResponse)
NOTIFICATION = 2 (server-initiated, unsolicited)
ERROR_REPLY = 3
PING = 4
PING_REPLY = 5
```
Byte 13 of a reply is therefore `1 << 5 = 0x20` (with userIndex 0).
### Reply construction rules — **(a-conv)**
1. **Copy the whole request header**, then overwrite byte 13's top 3 bits with `REPLY`.
Component, command, **msgNum and userIndex are echoed verbatim**. (R5 `Packet::createReply()`
literally memcpy's the header; R6 `MsgNum(pkt.Header.MessageNumber)`; R1 `Fire2Frame::reply()`
keeps `..*self`.)
2. `metadataLen = 0` and `payloadLen = len(serialized TDF)` are recomputed.
3. **Notifications** use `msgType = 2` and `msgNum = 0` (R1 `Fire2Frame::notification`,
R2 `.encode(0)`), i.e. notifications are *not* correlated to a request.
4. **No qtail / seqno / context.** Context and jumbo-frame handling exist only in Fire v1
(R8 `FireFrame.cs` `Option.HAS_CONTEXT/JUMBO_*`). Fire2 has none of it — all four Fire2
emulators write a flat 16-byte header and nothing else.
5. **Ping**: FIFA 17's keep-alive is `Util::ping` as a normal `MESSAGE` (not msgType 4), so
reply with a normal `REPLY` on component 9 / command 2. The msgType 4/5 PING/PING_REPLY
pair is a separate transport-level heartbeat (R1 routes it as component 0 / command 0 and
answers with msgType 5 and an empty body) — implement that too as a cheap safety net.
### Error replies — **UNRESOLVED, three conflicting clean-room encodings**
| Source | Where the error code goes |
|---|---|
| R5 `packet.cpp:155` (working GW2 C++ server) | `msgType=3`, empty metadata+payload, u16 error written into header bytes **[14:16]** |
| R6 `Fire2.go` (working GW2 Go server) | `msgType=3`, `ERRC` read from the **metadata** TDF struct |
| R1 `packet.rs:103` | `msgType=1(!)` with `CNTX`/`ERRC` in the **payload** — code carries a `// TODO: move ErrorBody to metadata` |
Not on the preAuth critical path (we return a success REPLY). Flag for later; prefer R6
(metadata `ERRC`) since its decoder was validated against a real client, and cross-check
against FIFA 17's own reaction.
---
## 2. Heat2 (TDF) encoding rules — **(a-conv)** R3 + R6, validated byte-for-byte against our own capture
* **Tag**: 3 bytes. `packed = Σ_{i<4} ((upper(tag[i]) - 0x20) & 0x3F) << (26 - 6*i)`; emit the
top 3 bytes of that u32. Missing/short chars contribute 0 (decode to a trailing space, which
is why real tags are written `"PSA "`, `"LNP "`, `"SNA "`, `"UID "`, `"LOC "`).
Verified: `CDAT``8e 48 74`, exactly the bytes at offset 0x10 of our capture.
* **Field** = `tag(3) || type(1) || value`.
* **Types**: `0 int(varint)`, `1 string`, `2 blob`, `3 struct`, `4 list`, `5 map`, `6 union`,
`7 variable`, `8 objtype`, `9 objid`, `10 float(be f32)`, `11 timevalue(varint µs)`, `12 generic`.
* **Varint**: first byte = 6 data bits, **bit 0x40 = negative sign (NOT data)**, bit 0x80 = continue;
subsequent bytes 7 data bits + 0x80 continue. (Our existing `decode_fire2.py` masks `&0x3f`, so
it reads magnitudes correctly but silently drops the sign — fine for now, noted.)
* **String**: `varint(len+1) || bytes || 0x00` — the length **includes** the NUL terminator.
* **Struct**: nested fields, terminated by a `0x00` byte. **The root payload has NO terminator.**
(R6 `encodeStruct(fields, root)`.)
* **List**: `elemType(1) || varint(count) || elements`.
* **Map**: `keyType(1) || valType(1) || varint(count) || (key,value)*`.
* **Struct as a list/map element**: just `fields... || 0x00`, no prefix. *Except*: some
polymorphic struct lists take a leading arm byte (R3 `#[tdf(prefix_two)]` → a literal `0x02`;
R6 `ArmedStruct`). None of the preAuth fields need it.
* **Empty lists/maps are omitted entirely** rather than emitted with count 0 (R6 `isEmptyCollection`).
* **Member order**: EA emits members sorted by *packed tag value* ascending (≈alphabetical).
Decoders are tag-driven so this is cosmetic, but R8's member tables are in that order and every
capture obeys it — match it, it's free.
---
## 3. Our FIFA 17 preAuth REQUEST, re-decoded cleanly (203/203 bytes consumed) — **(a-obs)**
```
CDAT { IITO=0 LANG=1701729619 ('enUS') SVCN="fifa-2017-pc" TYPE=0 }
CINF { BSDK="15.1.1.3.0" BTIM="Jun 9 2017 16:15:40" CLNT="FIFA17" CPFT=4 (pc)
CSKU="FIFAPC" CVER="3175939" DSDK="15.1.2.1.0"
ENV="prod" LOC=1701729619 ('enUS') PTVR="1.1" }
FCCR { CFID="BlazeSDK" }
LADD = 1761610250
```
**Key insight:** `FCCR.CFID = "BlazeSDK"` is an embedded `FetchClientConfigRequest`. The
`CONF` block of the PreAuthResponse is the answer to it — i.e. `CONF.CONF` must be the
**`BlazeSDK` config section** (pingPeriod, connIdleTimeout, nucleus* URLs, …). That is exactly
what MEC/GW2/ME all put there. Confirmed independently by R5 `util.cpp`, whose
`fetchClientConfig` handler has a dedicated `section == "BlazeSDK"` branch returning
`{pingPeriod, defaultRequestTimeout, connIdleTimeout, autoReconnectEnabled, maxReconnectAttempts}`.
GW2's request for comparison (R5 `Blaze__Util__PreAuthRequest.txt`) is identical in shape;
FIFA 17 adds one extra field, `CINF.PTVR = "1.1"`.
---
## 4. PreAuthResponse — the concrete field list
### 4.1 Field-by-field, with semantic names — **(a-conv)** for tags, (b-?) only for the human-readable names
Tag names/semantics from R6 `types/UtilComponent.go` (a-cr), R1/R2 comments (a-cr), R5
`util.cpp` comments (a-cr); the EA-style long names in R8 (b-?) agree with all of them.
| Tag | Type | Meaning | MEC (R1/R2) | GW2 (R5/R6, real capture) | ME4 (R7) |
|---|---|---|---|---|---|
| `ASRC` | string | authenticationSource (numeric title/telemetry id) | `"308903"` | `"310695"` | `"310335"` |
| `CIDS` | list<int> | componentIds — components configured on the server | see below | see below | see below |
| `CLID` | string | clientId — Nucleus client id for this service | `"MirrorsEdgeCatalyst-SERVER-PC"` | `"PVZGW2-PC-SERVER-BLAZE"` | `"ME4-PC-SERVER-BLAZE"` |
| `CONF` | struct | config — a `FetchConfigResponse`, i.e. `{ CONF: map<string,string> }` = the `BlazeSDK` section | see 4.2 | see 4.2 | see 4.2 |
| `ESRC` | string | entitlementSource | `"308903"` | `"310695"` | `"310335"` |
| `INST` | string | **serviceName — must match the client's `CDAT.SVCN`** | `"mirrorsedgecatalyst-2016-pc"` | `"plantsvszombies-gw2-pc"` | `"masseffect-4-pc"` |
| `MAID` | int | machineId — uniquely identifies the server machine, arbitrary u32 | `1129238128` | `3310897674` | `2291763061` |
| `MINR` | int/bool | underageSupported | `0` | `1` | `0` |
| `NASP` | string | personaNamespace | `"cem_ea_id"` | `"cem_ea_id"` | `"cem_ea_id"` |
| `PILD` | string | legalDocGameIdentifier | `""` | `""` | `""` |
| `PLAT` | string | platform | `"pc"` | `"pc"` | `"pc"` |
| `QOSS` | struct | qosSettings (`QosConfigInfo`) | see 4.3 | see 4.3 | see 4.3 |
| `RSRC` | string | registrationSource | `"308903"` | `"310695"` | (const) |
| `SVER` | string | serverVersion | `"Blaze 15.1.1.0.5 (CL# 1893137)\n"` | `"Blaze 15.1.1.4.6 (CL# 2136954)\n"` | (const) |
Fields present in R8's Blaze **3.x** descriptor but **absent from every 15.x capture**:
`ANON` (anonymousChildAccountsEnabled), `CNGN` (parentalConsentEntitlementGroupName),
`PTAG` (parentalConsentEntitlementTag). Conversely `CLID`/`ESRC`/`MAID` are 15.x additions
not in the 3.x descriptor. **Do not emit ANON/CNGN/PTAG** — no 15.x server does.
Exact raw GW2 capture (R5 `Research/data/Blaze/Util/Blaze__Util__PreAuthResponse.txt`) is
saved verbatim at `refs/z7_preauth.txt`. Header line: `//comp=0x0009 cmd=0x0007 msgType=Reply msgNum=0`
— confirming reply msgType and msgNum echo.
### 4.2 `CONF` — the `BlazeSDK` config section
`CONF` is a **struct** containing a single **map<string,string>** also tagged `CONF`.
Union of MEC + GW2 + ME4 keys (all three agree on the common subset):
```
associationListSkipInitialSet = "1"
autoReconnectEnabled = "0"
bytevaultHostname = <host> # point at ourselves or leave EA's
bytevaultPort = "42210"
bytevaultSecure = "true"|"false"
cachedUserRefreshInterval = "1s" # GW2/ME4 only
connIdleTimeout = "40s"
defaultRequestTimeout = "20s"
maxReconnectAttempts = "30" # GW2/ME4 only
nucleusConnect = "https://accounts.ea.com"
nucleusConnectTrusted = "https://accounts2s.ea.com"
nucleusPortal = "https://signin.ea.com"
nucleusProxy = "https://gateway.ea.com"
pingPeriod = "20s"
userManagerMaxCachedUsers = "0"
voipHeadsetUpdateRate = "1000"
xblTokenUrn = "accounts.ea.com"
xboxOneStringValidationUri = "client-strings.xboxlive.com"
```
Game-specific extras seen: `Override_ProtoHttp_LoginStateMachine_DedicatedServer_vers`
(GW2), `arubaDisabled/arubaEndpoint/arubaHostname/riverEnv/riverHost/riverPort/
disableDisconnectOnOrbitError` (ME4), `bugSentry*`/`gateway*`/`npsWebUrlBase` (MEC — but those
live in the *game's own* config section, not `BlazeSDK`). Start with the common set only.
`pingPeriod` is what tells the client how often to send `Util::ping`. `connIdleTimeout` is
what our responder must not exceed before it drops the socket.
### 4.3 `QOSS` — QosConfigInfo (struct)
Tags from R6 `types/FrameworkTypes.go` (a-cr):
```
QOSS {
BWPS { # bandwidthPingSiteInfo (QosPingSiteInfo) — leave blank/zero
"PSA " = "" # address
"PSP " = 0 # port
"SNA " = "" # siteName (present in MEC; ABSENT in the GW2 capture)
}
"LNP " = 10 # numLatencyProbes
LTPS = map<string, struct QosPingSiteInfo> # pingSiteInfoByAliasMap, alias -> {PSA,PSP,SNA}
SVID = <u32> # serviceId (present in MEC; ABSENT in the GW2 capture)
TIME = 5000000 | 10000000 # timeout (µs)
}
```
Real EA aliases/hosts (all three sources agree): `bio-dub`, `bio-iad`, `bio-sjc`, `bio-syd`,
`m3d-brz`/`i3d-gru`, `m3d-nrt`/`i3d-nrt``qos-prod-<alias>-common-common.gos.ea.com` port
**17504**. (The GW2 dump prints `PSP = 34976` = 17504<<1, an artefact of that dumper's varint
printing; 17504 is the real port, confirmed by MEC + ME4 source.)
Both working emulators (R5, R7) replace the map with a **single entry pointing at localhost**
so the client's QoS probe fails fast locally instead of timing out against dead EA hosts.
Do the same. `SNA` is optional; include it (harmless) or drop it.
### 4.4 `CIDS` — component id list
This is a hint list of which components the server has configured. Observed values:
* MEC: `30728, 24, 1, 30729, 25, 30730, 27, 9, 10, 33, 63490, 15, 30720, 30722, 30723, 30724, 21, 30726, 2000, 30727`
* GW2: `61448, 1, 61449, 25, 61450, 27, 4, 7, 9, 10, 33, 126978, 15, 61440, 61441, 61442, 61443, 61444, 61445, 61446, 61447, 3984`
* ME4: `1, 4, 7, 9, 10, 11, 14, 15, 25, 2000, 27, 30720, 30721, 30722, 30723, 30724, 33, 30725, 30726, 30727, 30728, 30729, 30730, 63490`
Base component ids (R6 `Fire2.go` doc-comment, a-cr): 1 Authentication, 3 Example,
4 GameManager, 5 Redirector, 7 Stats, 9 Util, 10 CensusData, 11 Clubs, 15 Messaging,
25 AssociationLists, 27 GpsContentController, 28 GameReporting, 31 ByteVault,
33 Achievements, 1025 XBLSystemConfigs, 1031 Friends, **0x7802 = 30722 UserSessions**.
Note the `0x7800`-range (MEC/ME4) vs `0xF000`-range (GW2) discrepancy for the framework
components — version-dependent, and GW2 lists `0xF002` in CIDS while its notifications
genuinely arrive on `0x7802`. Treat CIDS as advisory.
**FIFA 17's real component set is unknown and is not recoverable from any public repo.**
Recommended: (i) first shot = the MEC list (closest structural analogue, same `0x7800` range,
Blaze 15.1.1.0.5 vs FIFA's client 15.1.1.3.0); (ii) recover the authoritative list from
FIFA17.exe via the reflection-descriptor technique we already used for the redirector schema —
BlazeSDK registers a component-id table and per-component RPC name tables (we already know
979 RPC names live in that binary).
---
## 5. What comes next: the preAuth → postAuth → login sequence
Exact msgNum ordering from R5's decoded GW2 session (a-cr), which matches FIFA 17's observed
opening two frames (a-obs):
| msgNum | Direction | Component/Command | Payload |
|---|---|---|---|
| 0 | C→S | `9/7` Util::preAuth | `CDAT/CINF/FCCR/LADD` |
| 0 | S→C | `9/7` **Reply** | **PreAuthResponse** (section 4) |
| 1 | C→S | `9/2` Util::ping (empty) | — |
| 1 | S→C | `9/2` Reply | `STIM = <unix seconds>` |
| 2 | C→S | `9/1` Util::fetchClientConfig | `CFID = "IdentityParams"` |
| 2 | S→C | `9/1` Reply | `CONF = { "display": "console2/welcome", "redirect_uri": "http://127.0.0.1/success" }` |
| — | (client) | opens the Nucleus/Origin login web flow using those params, obtains an auth code | |
| 3 | C→S | `1/10` Authentication::login | `AUTH = <nucleus auth code>`, `ACHT{SHID:[] SKID:[]}`, `EXTB:[]`, `EXTI=0` |
| — | S→C | `0x7802 / 8` **Notification** `UserSessions::UserAuthenticated` | `1CON ALOC BUID CGID DSNM FRST KEY LAST LLOG MAIL NASP PID PLAT UID USTP XREF` |
| 3 | S→C | `1/10` Reply | LoginResponse: `ANON=0`, `SESS{ 1CON BUID FRST KEY LLOG MAIL PDTL{DSNM LAST PID PLAT STAS XREF} UID }`, `SPAM=0`, `UNDR=0` |
| 4 | C→S | `9/8` Util::postAuth | `DSUI=0`, `MAC="<mac>"`, `UDID=""` |
| — | S→C | `0x7802 / 5` Notification `UpdateExtendedDataAttribute` (`FLGS`,`ID`) — MEC does this *before* the reply | |
| 4 | S→C | `9/8` Reply | PostAuthResponse: `TELE{ADRS ANON DISA EDCT FILT LOC MINR NOOK PORT SDLY SESS SKEY SPCT STIM SVNM}`, `TICK{ADRS PORT SKEY}`, `UROP{TMOP "UID "}` |
| — | S→C | `0x7802 / 1 or 2` Notification `UserSessionExtendedDataUpdate` / `UserAdded` | `DATA{ADDR BPS CTY CVAR DMAP HWFG ISP PSLM PSM QDAT{BWHR DBPS NAHR NATT UBPS} TZ UATT ULST USER{...} XPLT}`, `SUBS`, `USID` |
| … | C→S | `9/28` Util::setClientState | `MODE=1`, `STAT=0` |
| … | C→S | `1/0x24` Authentication::getAuthToken | (empty) → Reply `AUTH="<token>"` |
| … | C→S | `25/6` AssociationLists::getLists, `0x7802/20` UserSessions::updateNetworkInfo, `9/22` setClientMetrics, `1/29` listEntitlements … | |
Util command ids (a-conv, R1/R2/R6 identical): 1 fetchClientConfig, 2 ping, 3 setClientData,
4 localizeStrings, 5 getTelemetryServer, 6 getTickerServer, **7 preAuth**, **8 postAuth**,
10-15 userSettings*, 20 filterForProfanity, 21 fetchQosConfig, 22 setClientMetrics,
23 setConnectionState, 25/26 get/setUserOptions, 27 suspendUserPing, 28 setClientState.
### ⚠️ Ping reply tag conflict — resolve empirically
* `TIME` — MEC / pamplona (R1 `PingResponse{TIME}`, R2 `TDFInteger('TIME', …)`), Blaze 15.1.1.0.x
* `STIM` — GW2 (R5 real capture `STIM = 3557264654`; R5 C++ + R6 Go both emit `STIM`) and ME4 (R7), Blaze 15.1.1.1.0+
FIFA 17's client BSDK is `15.1.1.3.0`, i.e. **newer than GW2's 15.1.1.1.0 → `STIM` is the
likely one**. Unknown tags are skipped by the decoder, so **emit both `STIM` and `TIME`**
(sorted: STIM before TIME) and let the client pick.
### The real wall after preAuth
`Authentication::login` takes a **Nucleus auth code**, obtained by the client from
`accounts.ea.com` using the `IdentityParams` config. Both MEC and GW2 emulators still rely on
live EA OAuth (R2's README notes EA deleting their OAuth client ids as an existential threat;
R1 substitutes Discord OAuth entirely). For a fully offline FIFA 17 we will have to either
(i) redirect `nucleusConnect`/`nucleusPortal`/`nucleusProxy` in the `CONF` map at our own
local HTTP stub and mint our own code, or (ii) accept whatever `AUTH` string arrives and
reply with a canned LoginResponse (what R6 `components/authentication.go` does — it ignores
the token completely and loads a user from `config/user.json`). **(ii) is the right first
move.** Note our `CONF` map is where those three URLs are set, so preAuth is already the
lever for redirecting Nucleus.
---
## 6. Ready-to-use artefacts produced by this research
| File | What |
|---|---|
| `scratchpad/preauth_build.py` | Clean-room Heat2 **encoder** + a concrete FIFA 17 `PreAuthResponse` and `PingResponse` builder + `fire2()` framer |
| `scratchpad/fire2_full.py` | Full Heat2 **decoder** (all types, incl. list/map/union/objid) + Fire2 header parse with the corrected layout |
| `scratchpad/refs/` | Local copies of every third-party file cited above |
**Validation performed:** `fire2_full.py` parses our real FIFA 17 preAuth request with
`consumed 203/203 (CLEAN)`, and parses `preauth_build.py`'s 915-byte generated reply with
`consumed 899/899 (CLEAN)`. Generated reply header:
`00 00 03 83 | 00 00 | 00 09 | 00 07 | 00 00 00 | 20 | 00 | 00`
(payload 899, meta 0, comp 9, cmd 7, msgNum 0, msgType REPLY).
### Values that are still guesses for FIFA 17 (iterate on client reaction)
| Field | Placeholder used | How to resolve |
|---|---|---|
| `ASRC`/`ESRC`/`RSRC` | `"309111"` | EA numeric title id; grep FIFA17.exe strings for a 6-digit telemetry/project id near `river`/`telemetry` |
| `CLID` | `"FIFA17-PC-SERVER-BLAZE"` | grep FIFA17.exe for `-PC-SERVER-BLAZE` / `SERVER-BLAZE` / Nucleus client-id strings |
| `CIDS` | MEC list | recover from FIFA17.exe component registration table (reflection technique) |
| `SVER` | `"Blaze 15.1.1.3.0 (CL# 1234567)\n"` | matched to the client's own `CINF.BSDK`; trailing `\n` is what both real servers send |
| `MAID` | `1129238128` | arbitrary |
| ping tag | both `STIM`+`TIME` | observe which one stops the retry loop |
`INST = "fifa-2017-pc"` is **not** a guess — it must equal the client's `CDAT.SVCN`, which we
observed directly.
@@ -0,0 +1,265 @@
# FIFA 17 Blaze `Util::preAuth` — reflection-reversed schema
**Date:** 2026-07-30 · **Method:** live `/proc/<pid>/mem` reflection walk of `FIFA17.exe` (PID 7618, alive throughout) + targeted disassembly of the response handler.
## Clean-room provenance
Everything below came from one of three sources, all allowed:
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 the findings.
2. **Disassembly of code in the binary we own** (`ConnectionManager::onPreAuthResponse` and the Util component's `getCommandName` switch).
3. **Our own captured wire bytes** (`fifa17-recon/captures/blaze/*.bin`).
One item — the Fire2 header field layout — was **cross-checked** against three independent third-party clean-room BlazeSDK-15.x reimplementations cloned in the scratchpad (`pamplona-future`, `catalyst-mitm`, `grid-blaze`, all Mirror's Edge Catalyst / Mass Effect era). That is flagged inline. **No EA/FIFA leaked source was consulted at any point.**
---
## 0. Correction: the Fire2 header layout we were using is wrong
Our working assumption was `[10:12]=u16 error/msgId, [12]=u8 msgType`. That is **incorrect**, and it matters: a reply built with it would put the message type in the wrong byte and drop the sequence number, so the client would never match the reply to its pending request.
Correct 16-byte header (all big-endian):
| Bytes | Field |
|---|---|
| `[0:4]` | u32 payload length |
| `[4:6]` | u16 metadata length (always 0 observed) |
| `[6:8]` | u16 component |
| `[8:10]` | u16 command |
| `[10:13]` | **u24 msgNum** (3 bytes, not 2) |
| `[13]` | **msgType << 5** |
| `[14]` | options |
| `[15]` | reserved |
`msgType`: `0=MESSAGE 1=REPLY 2=NOTIFICATION 3=ERROR_REPLY 4=PING 5=PING_REPLY`.
**Evidence this is right, independent of the reference repos:** byte `[12]` across our 8 captured frames takes the values `0,1,2,3,4,5,6,7` — a monotonic counter, alternating `preAuth`(even) / `ping`(odd). Under the old reading that would be eight different "message types", which is nonsense. Under the new reading every captured frame is `msgNum = 0..7, msgType = 0 (MESSAGE)` — i.e. all eight are client *requests*, which is exactly what we expect since we never replied. This also retires the earlier "ping/pong msgType 0x01/0x03" note: both 16-byte frames are plain `Util::ping` **requests** (command 0x0002, see §2), not a ping/pong pair.
`heat2.py`'s `build_fire2_frame` / `parse_fire2_frame` still encode the old layout and should be replaced by the versions in `build_preauth_response.py`. Its **TDF value encoding is fine** — it round-trips the 219-byte capture byte-for-byte.
---
## 1. `Blaze::Util::PreAuthResponse` — 14 members
Descriptor at VA `0x144875600`, member table `0x144874a90`, count 14. Members are listed and must be **serialized in ascending packed-tag order** (which is what the table itself is sorted by, and what the captured request does).
| Tag | Member name | TDF type | Wire type | Struct offset | Confidence |
|---|---|---|---|---|---|
| `ASRC` | `authenticationSource` | string | `0x01` | +0x1d8 | certain |
| `CIDS` | `componentIds` | list\<uint16\> | `0x04` | +0x070 | certain |
| `CLID` | `clientId` | string | `0x01` | +0x058 | certain |
| `CONF` | `config` | `Blaze::Util::FetchConfigResponse` | `0x03` | +0x0b8 | certain |
| `ESRC` | `entitlementSource` | string | `0x01` | +0x228 | certain |
| `INST` | `serviceName` | string | `0x01` | +0x040 | certain |
| `MAID` | `machineId` | uint32 | `0x00` | +0x240 | certain |
| `MINR` | `underageSupported` | bool | `0x00` | +0x220 | certain |
| `NASP` | `personaNamespace` | string | `0x01` | +0x1c0 | certain |
| `PILD` | `legalDocGameIdentifier` | string | `0x01` | +0x208 | certain |
| `PLAT` | `platform` | string | `0x01` | +0x028 | certain |
| `QOSS` | `qosSettings` | `Blaze::QosConfigInfo` | `0x03` | +0x120 | certain |
| `RSRC` | `registrationSource` | string | `0x01` | +0x1f0 | certain |
| `SVER` | `serverVersion` | string | `0x01` | +0x010 | certain |
Tag decoding is validated: the request's four member tags round-trip exactly (`0x8e4874``CDAT`, `0x8e9ba6``CINF`, `0x9a38f2``FCCR`, `0xb21924``LADD`), matching the capture.
### Nested types
```
Blaze::Util::FetchConfigResponse (CONF, 1 member)
CONF config map<string,string>
Blaze::QosConfigInfo (QOSS, 4 members)
BWPS bandwidthPingSiteInfo Blaze::QosPingSiteInfo (struct)
LNP numLatencyProbes uint16
LTPS pingSiteInfoByAliasMap map<string, Blaze::QosPingSiteInfo>
TIME timeout TimeValue (int, microseconds)
Blaze::QosPingSiteInfo (2 members)
PSA address string
PSP port uint16
```
Note `CONF` nests a struct whose single member is *also* tagged `CONF` — the outer is the `FetchConfigResponse` struct, the inner is the map. Easy to get wrong.
**Map key/value order:** the key descriptor is at `+0x28`, the value at `+0x30`. The binary's own `map<A,B>` *name string* is written **value-first**, so it reads backwards — don't trust it. Confirmed against two maps whose semantics are unambiguous: `pingSiteLatencyByAliasMap` is named `map<int32_t,string>` but is really `map<string alias, int32 latency>`; `permissionsByComponent` is named `map<list<string>,string>` but is really `map<string component, list<string> permissions>`. So `LTPS` = `map<string, QosPingSiteInfo>`.
### Request side, for reference
`Blaze::Util::PreAuthRequest` (4 members) — matches our capture exactly, which is what validated the whole descriptor-walking method:
| Tag | Member | Type |
|---|---|---|
| `CDAT` | `clientData` | `Blaze::Util::ClientData` |
| `CINF` | `clientInfo` | `Blaze::ClientInfo` (10 members) |
| `FCCR` | `fetchClientConfig` | `Blaze::Util::FetchClientConfigRequest` { `CFID` configSection: string } |
| `LADD` | `localAddress` | uint32 |
`Blaze::Util::ClientData` = { `IITO` ignoreInactivityTimeout: bool, `LANG` locale: uint32, `SVCN` serviceName: string, `TYPE` clientType: enum }.
---
## 2. Util component RPC table — **complete**
Component id **0x0009**, confirmed directly in the binary: the Util notification dispatcher compares against `0x00c80009`, `0x00980009`, `0x00640009`, `0x00960009`, `0x00970009` — i.e. `(notificationId << 16) | 9`.
Recovered by locating the compiler-generated `getCommandName(commandId)` switch: 21 `lea rax,[rip+name]; ret` stubs emitted in alphabetical order at RVA `0x1b17a43 + 8k`, plus a jump table of 28 entries at VA `0x146df72f4` indexed by `commandId - 1`.
| Command | RPC | Command | RPC |
|---|---|---|---|
| `0x0001` | `fetchClientConfig` | `0x000f` | `userSettingsLoadMultiple` |
| `0x0002` | **`ping`** ✅ | `0x0010``0x0013` | *(unused)* |
| `0x0003` | `setClientData` | `0x0014` | `filterForProfanity` |
| `0x0004` | `localizeStrings` | `0x0015` | `fetchQosConfig` |
| `0x0005` | `getTelemetryServer` | `0x0016` | `setClientMetrics` |
| `0x0006` | `getTickerServer` | `0x0017` | `setConnectionState` |
| `0x0007` | **`preAuth`** ✅ | `0x0018` | *(unused)* |
| `0x0008` | `postAuth` | `0x0019` | `getUserOptions` |
| `0x0009` | *(unused)* | `0x001a` | `setUserOptions` |
| `0x000a` | `userSettingsLoad` | `0x001b` | `suspendUserPing` |
| `0x000b` | `userSettingsSave` | `0x001c` | `setClientState` |
| `0x000c` | `userSettingsLoadAll` | | |
| `0x000d` | *(unused)* | | |
| `0x000e` | `deleteUserSettings` | | |
**This table is self-validating**: it independently reproduces both values we observed on the wire — `ping = 0x0002` and `preAuth = 0x0007`. Confidence: high.
### Request/response type mapping (from the reflection type index)
| Command | Request | Response |
|---|---|---|
| `0x0002` ping | *(empty)* | `Blaze::Util::PingResponse` { `STIM` serverTime } |
| `0x0007` preAuth | `PreAuthRequest` | `PreAuthResponse` |
| `0x0008` postAuth | `PostAuthRequest` { `DSUI` dirtySockUserIndex: int32, `UDID` uniqueDeviceId: string } | `PostAuthResponse` |
| `0x0001` fetchClientConfig | `FetchClientConfigRequest` { `CFID` } | `FetchConfigResponse` { `CONF` map } |
| `0x0015` fetchQosConfig | *(empty)* | `QosConfigInfo` |
`Blaze::Util::PostAuthResponse` (3 members) — **the next thing we'll need**:
```
TELE telemetryServer Blaze::Util::GetTelemetryServerResponse (15 members)
ADRS address:string ANON isAnonymous:bool DISA disable:string
EDCT enableDisconnectTelemetry:bool FILT filter:string
LOC locale:uint32 MINR underage:bool NOOK noToggleOk:string
PORT port:uint32 SDLY sendDelay:uint32 SESS sessionID:string
SKEY key:string SPCT sendPercentage:uint32
STIM useServerTime:string SVNM telemetryServiceName:string
TICK tickerServer Blaze::Util::GetTickerServerResponse
ADRS address:string PORT port:uint32 SKEY key:string
UROP userOptions Blaze::Util::UserOptions
TMOP telemetryOpt:enum UID userId:int64
```
### Component ids (recovered from each component's notification dispatcher)
`Authentication=1`, `GameManager=4`, `Redirector=5`, `Util=9`, `GameReporting=28`, `UserSessions=30722`. FIFA-custom components (`CoopSeason`, `EaAccess`, `Easfc`, `FifaCups`, `SponsoredEvents`, `VProSPManagement`, `OSDKSettings`, `OSDKTournaments`, `OsdkArena`) live in the `2069/2070/2077`… range; I did not separate them individually (their dispatchers are adjacent and my scan window overlapped). `Stats`, `Clubs`, `Messaging`, `Mail`, `AssociationLists`, `GpsContentController`, `CensusData` are also linked in.
---
## 3. What the client actually *does* with the response
`ConnectionManager::onPreAuthResponse(this, PreAuthResponse* r /*rdx*/, errorCode /*r8d*/)` at **VA `0x146e1cf10`**. Signature confirmed because every `[r14+offset]` it touches matches a `PreAuthResponse` member offset from §1.
The function performs **no validation whatsoever** — on the success path it only copies fields out and then advances. So the practical requirement is: *reply with a well-formed `REPLY` frame carrying the right `msgNum`.* Nothing in the body is checked for a specific value.
Field-by-field:
| Field | What happens | Mandatory? |
|---|---|---|
| `CONF` config | whole map copied into ConnectionManager `+0x11b8` | **effectively yes** — see the tunables below |
| `CIDS` componentIds | list copied to `+0x1240`; this is the client's view of which components the server has | **yes, practically** — later components look themselves up here |
| `SVER` serverVersion | `memcpy``+0x1288` (512-byte buf) | no, free-form |
| `PLAT` platform | `memcpy``+0x1488` (512-byte buf) | no, free-form |
| `INST` serviceName | `memcpy``+0x1688` (512-byte buf) | no — echo the request's `SVCN` (`fifa-2017-pc`) |
| `CLID` clientId | `memcpy``+0x1888` (512-byte buf) | no |
| `NASP` personaNamespace | `memcpy``+0x1a88`, **capped at 32 bytes** | no here, but auth will care |
| `RSRC` registrationSource | `memcpy``+0x1a?8` | no |
| `ASRC` authenticationSource | `memcpy``+0x1ae8` | no |
| `PILD` legalDocGameIdentifier | `memcpy``+0x1b28` | no |
| `MINR` underageSupported | byte → `+0x1b6c` | no |
| `ESRC` entitlementSource | `memcpy``+0x1b6d` | no |
| `MAID` machineId | passed to `0x146124610` | no |
| `QOSS` qosSettings | passed to QoS manager init `0x146e1c3f0` | see below |
Then it calls `0x146e1e460` and `0x146e1c3f0` (QoS start) and returns.
### The `CONF` map keys the client reads
Read at `0x146e1d0a5``0x146e1d1a2`, all via `ConnectionManager` vtable getters (`+0x50` = uint32, `+0x58` = TimeValue/int64, both returning "found"):
| Key | Parsing | Behaviour if absent |
|---|---|---|
| `pingPeriod` | value ÷ 1000 (so the config value is in **microseconds** → stored as ms); if result < 1000 ms, clamped to 15000 | **defaults to 15000 ms** (`0x3a98`) |
| `defaultRequestTimeout` | ÷1000 → ms, stored `+0x278` | left unchanged |
| `connIdleTimeout` | ÷1000 → ms, stored `+0xd28` | left unchanged |
| `autoReconnectEnabled` | `!= 0` → bool `+0x11b7` | left unchanged |
| `maxReconnectAttempts` | uint32 → `+0xc5c` | left unchanged |
**None of these are strictly required** — every one has a fallback. But `pingPeriod` decides how fast the client starts hammering `Util::ping` (cmd `0x0002`), so set it deliberately.
### Turning off the QoS probes — important for an offline emulator
Separate helper at `0x146e1bbc0` reads two more keys and starts from flags `= 3` (both tests on):
| Key | Effect |
|---|---|
| `enableQosFirewallTest` | if the value string equals **`"false"`** exactly (string at VA `0x14354be74`), clears bit 0 |
| `enableQosBandwidthTest` | same → clears bit 1 |
So putting `enableQosFirewallTest=false` and `enableQosBandwidthTest=false` in `CONF` stops the client trying to reach real QoS ping servers. Combined with an **empty `LTPS` map**, the QoS manager has nothing to probe. Recommended for our first working response.
---
## 4. Practical recipe
`build_preauth_response.py` implements this and self-verifies (correct header layout, all 14 fields present in tag order, decode round-trip). It reuses `heat2.py`'s validated TDF value encoders and **overrides** its Fire2 framing.
```
reply = fire2(component=0x0009, command=0x0007,
msg_num=<echo from the request>, msg_type=REPLY(1),
payload=preauth_response(...))
```
Produced frame for `msgNum=2`: 344 bytes, header `00 00 01 48 | 00 00 | 00 09 | 00 07 | 00 00 02 | 20 | 00 | 00`. Note byte 13 = `0x20` = `REPLY << 5`.
Body sent (all fields present; strings the client only stores are left empty or placeholder):
- `CIDS` = `[1, 4, 5, 7, 9, 15, 25, 28, 30722]`
- `CONF.CONF` = `pingPeriod=20000000`, `defaultRequestTimeout=30000000`, `connIdleTimeout=90000000`, `enableQosFirewallTest=false`, `enableQosBandwidthTest=false`
- `INST` = `fifa-2017-pc` (echo of the request's `CDAT/SVCN`)
- `QOSS` = `{ BWPS:{PSA:"127.0.0.1", PSP:17502}, LNP:10, LTPS:{}, TIME:5000000 }`
- `MAID=0`, `MINR=0`, remaining strings empty / placeholder
Right after this the client will start sending `Util::ping` (`0x0009/0x0002`) on `pingPeriod`; reply with `msgType=REPLY` and a `PingResponse { STIM: serverTime }`. `ping_reply_frame()` in the same module does that.
### Residual uncertainty
- **`NASP` / `PLAT` / `SVER` values are placeholders, not reverse-engineered.** `onPreAuthResponse` doesn't validate them, but `Authentication` (component 1) almost certainly will care about `NASP`. Expect to revisit.
- **Group-inside-list/map framing is unverified.** Our capture never exercises it, and the third-party crates disagree with some Blaze versions about a `0x02` group-start marker. This is why the recommended `LTPS` is empty — it sidesteps the question. If we later need populated `LTPS`, verify that encoding first.
- The `2069/2070/2077` FIFA-custom component ids were not individually attributed to component names.
- Varint sign convention (bit `0x40` of the first byte) is unverified; nothing in `PreAuthResponse` is signed, so it doesn't bite here.
## Tooling produced
| File | Purpose |
|---|---|
| `reflect2.py` | recursive TDF type-descriptor walker (`raw` / `walk` / `byname` / `index`) |
| `build_preauth_response.py` | corrected Fire2 framer + PreAuthResponse/PingResponse builders, self-testing |
### Reflection metadata layout (for reuse)
Type descriptor, 64 bytes:
```
+0x00 u32 typeEnum +0x04 u32 nameHash
+0x08 ptr fullName +0x10 ptr shortName (interior)
+0x18 ptr auxTypeInfo +0x20 ptr runtimeInstance
+0x28 ptr shortName2 +0x30 ptr memberTable +0x38 u64 memberCount
```
Member entry, 48 bytes: `+0x00` member type descriptor · `+0x08` member name string · `+0x20` **u32 wire tag, 6-bit-packed in the top 3 bytes** · `+0x28` u64 byte offset within the C++ struct.
`typeEnum`: `2`=map `3`=list `4`=float `5`=enum `6`=string `7`=variable `9`=blob `10`=union `11`=**struct/class** `12`=ObjectType `13`=ObjectId `14`=TimeValue `15`=bool `16`=int8 `17`=uint8 `19`=uint16 `20`=int32 `21`=uint32 `22`=int64 `23`=uint64.
Containers keep their element types at `+0x28` (list elem / map key) and `+0x30` (map value).
Blaze reflection strings live around VA `0x143888000``0x1438a1000`; descriptors around `0x144860000``0x144890000`.
+89
View File
@@ -0,0 +1,89 @@
# trace_login.gdb -- observe FIFA17's Origin login-event path while we push a
# <Login IsLoggedIn="true"> Event. Answers the 3-question ladder from the
# repack-reverse workflow (REPACK_INTEL.md sec.3):
# Q1 does the pushed frame even REACH the sender matcher / dispatcher?
# Q2 what sender STRING does the matcher strcmp compare against? (dump the table entry)
# Q3 does dispatcher case-2 execute and does m_isLoggedIn actually flip?
#
# Substitute PIDHERE (trace_login.sh does this) and run:
# gdb -batch -x trace_login.gdb 2>&1 | tee /tmp/trace_login.log
# Requires kernel.yama.ptrace_scope=0. All VAs are the Wine-flat-mapped FIFA17.exe
# (base 0x140000000); stable across launches.
set pagination off
set confirm off
set width 0
attach PIDHERE
set architecture i386:x86-64
# CRITICAL for Wine: pass its scheduling signals silently or gdb halts on the
# first SIGUSR1 and (-batch) detaches within seconds -> zero hits.
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIG32 nostop noprint pass
handle SIG33 nostop noprint pass
handle SIG34 nostop noprint pass
handle SIG35 nostop noprint pass
# helper: print a register both as pointer and as a best-effort C string
define pstr
printf " %s = %#lx", $arg1, $arg0
# try to read it as a string; if it faults, gdb prints nothing extra
printf " ascii="
x/s $arg0
end
# ---- Q2: the <Event> sender matcher / inline strcmp @0x147102880 ----
# The docstring: reads sender attr via vtbl+0x70 -> rax; test rax,rax; je fail;
# then inline strcmp of that against the handler's registered service name.
# We do not know a-priori which regs hold the two pointers, so dump the usual
# candidates as strings; whichever are char* reveal BOTH sides of the compare.
break *0x147102880
commands
silent
printf "\n>> [Q2] sender matcher 0x147102880 hit\n"
pstr "rax" $rax
pstr "rcx" $rcx
pstr "rdx" $rdx
pstr "rsi" $rsi
pstr "rdi" $rdi
pstr "r8 " $r8
pstr "r9 " $r9
continue
end
# ---- Q1/element: the <Login> element attribute parser @0x147138660 ----
# Reaching here proves the Event matched sender AND element == "Login".
break *0x147138660
commands
silent
printf "\n>> [Q1] <Login> parser 0x147138660 ENTERED (sender+element matched)\n"
continue
end
# ---- Q3: dispatcher case-2 (OriginEventT::Login) ----
# 0x146f1e09e: cmp DWORD PTR [r9],1 (r9 -> parsed IsLoggedIn value)
break *0x146f1e09e
commands
silent
printf "\n>> [Q3] dispatcher case-2 reached: IsLoggedIn(parsed)=%d OriginMgr(rcx)=%#lx\n", *(int*)$r9, $rcx
printf " current m_isLoggedIn byte [rcx+0x13] = %d\n", *(unsigned char*)($rcx+0x13)
continue
end
# 0x146f1e0ab: mov BYTE PTR [rcx+0x13],1 (SET logged-in = TRUE)
break *0x146f1e0ab
commands
silent
printf ">> [Q3] ARM set m_isLoggedIn = 1 *** SUCCESS PATH ***\n"
continue
end
# 0x146f1e0b8: mov BYTE PTR [rcx+0x13],0 (SET logged-in = FALSE / bind failed)
break *0x146f1e0b8
commands
silent
printf ">> [Q3] ARM set m_isLoggedIn = 0 (IsLoggedIn parsed as false / bind failed)\n"
continue
end
printf "\n=== trace_login armed. Push a Login event now (heartbeat or navigate). Ctrl-C in gdb to detach. ===\n"
continue
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# trace_login.sh -- attach the login-path tracer to the running FIFA17.exe.
# Prereqs: FIFA 17 running; kernel.yama.ptrace_scope=0; lsx_responder_v2 up with
# an UNBOUNDED heartbeat so pushes are still in flight while we trace, e.g.:
# OPENFUT_LSX_EVENT_COUNT=100000 python3 -u tools/lsx_responder_v2.py
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
PID="$(pgrep -x FIFA17.exe | head -1 || true)"
if [ -z "${PID}" ]; then echo "FIFA17.exe not running"; exit 1; fi
echo "attaching to FIFA17.exe pid=${PID}"
TMP="$(mktemp /tmp/trace_login.XXXX.gdb)"
sed "s/PIDHERE/${PID}/" "${DIR}/trace_login.gdb" > "${TMP}"
# gdb must run with ptrace rights; if not root, ptrace_scope=0 suffices.
gdb -batch -x "${TMP}" 2>&1 | tee /tmp/trace_login.log
rm -f "${TMP}"
+92 -5
View File
@@ -11,7 +11,10 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import datetime, json, os, re, http.server
import datetime, json, os, re, sys, http.server
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST # forged starter squad (clean-room)
ADDR = ("127.0.0.1", 8099)
LOG = "/tmp/utas_server.log"
@@ -71,9 +74,82 @@ USER_POST = {"login": True, "userData": user_info(),
"squad": {}, "starterPack": {}, "bonusPacks": []}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser 0x180174630).
# ANY content here (userInfo AND/OR squad) DESYNCS CardsDLL's massinfo parser ->
# infinite tokenizer spin (busy-loop freeze at 0x1801c7f1a). Proven: {} reaches
# the hub; {userInfo,...} and {...,squad,...} both freeze. The userInfo sub-deser
# 0x18013ec10 mis-consumes some field in user_info(). So keep userMassInfo EMPTY
# (hub-reaching) and deliver club/squad via their OWN endpoints (/user, /club,
# /squad) whose parsers we know work. Select via env FUT_MASSINFO (empty|userinfo|full).
_MI = os.environ.get("FUT_MASSINFO", "empty")
if _MI == "full":
MASSINFO = {"userInfo": user_info(), "squad": SQUAD,
"settings": {"configs": []}, "userData": {}}
elif _MI == "userinfo":
MASSINFO = {"userInfo": user_info(), "settings": {"configs": []}, "userData": {}}
else:
MASSINFO = {} # proven hub-reaching
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
# record at item+0x10, filled by looking the resourceId up in the FUT item-def
# store. That store is network-filled; empty offline => generic cards. FIFA
# fetches definitions from ut/<sku>/item/resource, ut/<sku>/defid, and batch
# ut/<sku>/item?idList=<ids>. We serve them here (deser 0x18013fe00, same as items).
# resourceId = playerId | version<<24 ; assetId = resourceId & 0xffffff.
PLAYER_DEFS = {
# assetId: (name, rating, position, nation, leagueId, teamid, [6 attrs])
20801: ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]),
}
def item_def(rid):
"""Build one FUT item-definition for a requested resourceId."""
asset = rid & 0xffffff
name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
return {
"id": rid,
"resourceId": rid,
"definitionId": rid,
"assetId": asset,
"cardassetid": asset,
"commodityId": asset,
"cardsubtypeid": 0,
"cardType": 0,
"itemType": "player",
"rareflag": 1,
"rating": rating,
"preferredPosition": pos,
"nation": nation,
"leagueId": league,
"teamid": team,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"name": name,
"commonName": name,
"lastName": name,
"itemState": "free",
"untradeable": True,
}
def defs_route(h):
# Parse every integer id out of the query string (idList=a,b,c / definitionId=x
# / resourceId=x) and return a definition for each.
q = h.path.split("?", 1)[1] if "?" in h.path else ""
ids = [int(n) for n in re.findall(r"\d{3,}", q)]
if not ids:
return 200, {"itemData": []}
return 200, {"itemData": [item_def(i) for i in ids]}
G = r"/ut/game/[^/]+"
ROUTES = [
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
(re.compile(G + r"/item(\?|$)"), lambda m, h: defs_route(h)),
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())),
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
@@ -84,15 +160,20 @@ ROUTES = [
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
(re.compile(G + r"/user/credits"), lambda m, h: (200, {"credits": 15000})),
# ---- club/squad routes reverted to known-good {} stubs (2026-08-01) ----
# The forged squad in MASSINFO/SQUAD/CLUB HANGS CardsDLL's deserializer (hard
# freeze at boot). Re-enable only after the exact shape is reversed. The forged
# data still lives in fut_seed.py + MASSINFO/squad_route below (unrouted).
(re.compile(G + r"/user/list"), lambda m, h: (200, {})),
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})),
(re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)),
(re.compile(G + r"/squad"), lambda m, h: (200, {})),
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, {})),
# STEP 1 (wf_0bc80ab3): zero-resolve squad in MASSINFO; club stays {}.
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, MASSINFO)),
(re.compile(G + r"/season"), lambda m, h: (200, {})),
(re.compile(G + r"/club"), lambda m, h: (200, {})),
(re.compile(G + r"/club"), lambda m, h: (200, CLUB)),
]
@@ -105,6 +186,12 @@ def user_route(h):
return 200, USER_GET
def squad_route(h):
# GET = LoadActiveSquad, PUT = updateActiveSquad. Always echo the full canonical
# squad (never {} — an empty body resets the client's 23 slots, 0x18013d1f0).
return 200, SQUAD
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
@@ -115,7 +202,7 @@ class H(http.server.BaseHTTPRequestHandler):
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
if body:
log(" body: %s" % body[:1200].decode("utf-8", "replace"))
log(" body: %s" % body[:65536].decode("utf-8", "replace"))
code, payload = 200, {}
for rx, fn in ROUTES:
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""ADVERSARIAL independent validator for the PreAuthResponse TDF payload.
Deliberately re-implemented from the documented wire rules rather than
importing heat2's decoder, so an encoder/decoder bug that cancels out in a
round-trip is still caught. Checks:
* every tag decodes to a legal 4-char label (chars 0x20..0x5F, no embedded
space, trailing-space padding only)
* fields at every nesting level are in STRICTLY ascending packed-tag order
* varints are canonical (shortest form), no 0x40 sign bit set
* string lengths include exactly one trailing NUL and no interior NUL
* every struct/group is terminated by exactly one 0x00
* the payload is consumed exactly (no trailing bytes, no overrun)
* list/map headers use legal element type codes
"""
import struct
import sys
PROBLEMS = []
def bad(off, msg, ctx=b""):
PROBLEMS.append((off, msg, ctx))
VALID_TYPES = {0x00: "int", 0x01: "string", 0x02: "blob", 0x03: "struct",
0x04: "list", 0x05: "map", 0x06: "union", 0x07: "intlist",
0x08: "objtype", 0x09: "objid", 0x0A: "float"}
def dec_tag(b, off):
a, b1, c = b[0], b[1], b[2]
v = [(a >> 2) & 0x3F, ((a & 3) << 4) | ((b1 >> 4) & 0xF),
((b1 & 0xF) << 2) | ((c >> 6) & 3), c & 0x3F]
chars = []
for x in v:
chars.append(chr(x + 0x20) if x else " ")
raw = "".join(chars)
label = raw.rstrip()
if not label:
bad(off, "tag decodes to all-padding (empty label) raw=%s" % b.hex())
if " " in label:
bad(off, "tag %r has an interior space (padding is not trailing-only) raw=%s"
% (raw, b.hex()))
for ch in label:
if not (0x20 <= ord(ch) <= 0x5F):
bad(off, "tag %r contains non-Heat2 char %r raw=%s" % (raw, ch, b.hex()))
if not (ch.isupper() or ch.isdigit()):
bad(off, "tag %r char %r is not [A-Z0-9] (suspicious for a Blaze tag)"
% (raw, ch))
# re-encode check
cc = [(ord(ch) - 0x20) & 0x3F for ch in raw]
re_enc = bytes(((cc[0] << 2) | (cc[1] >> 4),
((cc[1] & 0xF) << 4) | (cc[2] >> 2),
((cc[2] & 3) << 6) | cc[3]))
if re_enc != bytes(b):
bad(off, "tag %r does not re-encode: %s != %s" % (raw, re_enc.hex(), bytes(b).hex()))
return label, bytes(b)
def rd_varint(buf, i, what):
start = i
b = buf[i]; i += 1
if b & 0x40:
bad(start, "%s: first varint byte 0x%02x has sign bit 0x40 set" % (what, b))
val = b & 0x3F
nbytes = 1
if b & 0x80:
shift = 6
while True:
if i >= len(buf):
bad(start, "%s: varint runs past end of buffer" % what)
return val, i
b = buf[i]; i += 1
nbytes += 1
val |= (b & 0x7F) << shift
shift += 7
if not (b & 0x80):
if b == 0x00:
bad(start, "%s: non-canonical varint (trailing zero group) %s"
% (what, buf[start:i].hex()))
break
# canonical length check
v = val
exp = 1
v >>= 6
while v:
exp += 1
v >>= 7
if exp != nbytes:
bad(start, "%s: varint for %d used %d bytes, canonical is %d (%s)"
% (what, val, nbytes, exp, buf[start:i].hex()))
return val, i
def rd_value(buf, i, typ, path, depth):
if typ == 0x00:
v, i = rd_varint(buf, i, path)
return v, i
if typ == 0x01:
start = i
ln, i = rd_varint(buf, i, path + ".len")
if ln == 0:
bad(start, "%s: string length 0 (must be >=1 to hold the NUL)" % path)
return "", i
if i + ln > len(buf):
bad(start, "%s: string length %d overruns buffer" % (path, ln))
return "", len(buf)
raw = buf[i:i + ln]; i += ln
if raw[-1] != 0x00:
bad(start, "%s: string not NUL-terminated, last byte 0x%02x (%s)"
% (path, raw[-1], raw.hex()))
if 0x00 in raw[:-1]:
bad(start, "%s: string has interior NUL (%s)" % (path, raw.hex()))
return raw[:-1].decode("utf-8", "replace"), i
if typ == 0x02:
ln, i = rd_varint(buf, i, path + ".len")
return bytes(buf[i:i + ln]), i + ln
if typ == 0x03:
return rd_struct(buf, i, path, depth + 1, terminated=True)
if typ == 0x04:
et = buf[i]
if et not in VALID_TYPES:
bad(i, "%s: list element type 0x%02x is not a legal TDF type" % (path, et))
i += 1
n, i = rd_varint(buf, i, path + ".count")
items = []
for k in range(n):
v, i = rd_value(buf, i, et, "%s[%d]" % (path, k), depth)
items.append(v)
return (VALID_TYPES.get(et), items), i
if typ == 0x05:
kt = buf[i]; vt = buf[i + 1]
for nm, t in (("key", kt), ("value", vt)):
if t not in VALID_TYPES:
bad(i, "%s: map %s type 0x%02x is not a legal TDF type" % (path, nm, t))
i += 2
n, i = rd_varint(buf, i, path + ".count")
items = []
for k in range(n):
kk, i = rd_value(buf, i, kt, "%s{%d}.k" % (path, k), depth)
vv, i = rd_value(buf, i, vt, "%s{%d}.v" % (path, k), depth)
items.append((kk, vv))
return (VALID_TYPES.get(kt), VALID_TYPES.get(vt), items), i
bad(i, "%s: type 0x%02x not handled by validator" % (path, typ))
raise SystemExit("cannot continue")
def rd_struct(buf, i, path, depth, terminated):
fields = []
prev = None
while True:
if i >= len(buf):
if terminated:
bad(i, "%s: struct ran off the end without a 0x00 terminator" % path)
break
if terminated and buf[i] == 0x00:
i += 1
break
if i + 4 > len(buf):
bad(i, "%s: %d trailing bytes, too short for a tag+type header (%s)"
% (path, len(buf) - i, buf[i:].hex()))
break
label, packed = dec_tag(buf[i:i + 3], i)
typ = buf[i + 3]
if typ not in VALID_TYPES:
bad(i + 3, "%s.%s: type byte 0x%02x is not a legal TDF type" % (path, label, typ))
if prev is not None and packed <= prev[1]:
rel = "==" if packed == prev[1] else "<"
bad(i, "%s: field %r (tag %s) is %s previous %r (tag %s) -- ORDER VIOLATION"
% (path, label, packed.hex(), rel, prev[0], prev[1].hex()))
prev = (label, packed)
i += 4
val, i = rd_value(buf, i, typ, "%s.%s" % (path, label), depth)
fields.append((label, VALID_TYPES.get(typ), val))
return fields, i
def show(fields, d=0):
for lbl, tn, v in fields:
if tn == "struct":
print(" " * d + "%s (struct) {" % lbl)
show(v, d + 1)
print(" " * d + "}")
else:
print(" " * d + "%s (%s) = %r" % (lbl, tn, v))
def main(path):
data = open(path, "rb").read()
plen = struct.unpack_from(">I", data, 0)[0]
mlen = struct.unpack_from(">H", data, 4)[0]
comp = struct.unpack_from(">H", data, 6)[0]
cmd = struct.unpack_from(">H", data, 8)[0]
msgnum = (data[10] << 16) | (data[11] << 8) | data[12]
mtype = (data[13] >> 5) & 7
uidx = data[13] & 0x1F
print("== %s (%d bytes) ==" % (path, len(data)))
print("payload_len=%d meta_len=%d comp=0x%04x cmd=0x%04x msgNum=%d "
"msgType=%d userIdx=%d opts=0x%02x rsv=0x%02x"
% (plen, mlen, comp, cmd, msgnum, mtype, uidx, data[14], data[15]))
if 16 + mlen + plen != len(data):
bad(0, "frame size mismatch: 16+%d+%d=%d but file is %d"
% (mlen, plen, 16 + mlen + plen, len(data)))
payload = data[16 + mlen:16 + mlen + plen]
fields, end = rd_struct(payload, 0, "", 0, terminated=False)
if end != len(payload):
bad(end, "payload not fully consumed: stopped at %d of %d (rest=%s)"
% (end, len(payload), payload[end:].hex()))
print("--- decoded ---")
show(fields)
print("--- result ---")
if PROBLEMS:
for off, msg, _ in PROBLEMS:
lo = max(0, off - 8)
print("BUG @0x%04x (payload): %s" % (off, msg))
print(" bytes %s" % payload[lo:off + 16].hex(" "))
return 1
print("PASS: %d top-level fields, payload consumed exactly (%d bytes)"
% (len(fields), len(payload)))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1]))
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Virtual Xbox-360 gamepad over /dev/uinput (OpenFUT FIFA-17 recon).
FIFA 17 runs under Proton and reads input via evdev/SDL (a real controller),
NOT via X11 XTEST — so xdotool keystrokes never reach it. This creates a
kernel-level virtual pad whose events are indistinguishable from hardware, so
FIFA's native gamepad path picks them up. Prototype (pure ctypes, no deps) to
PROVE the approach; port to a Rust driver once confirmed.
./vgamepad.py daemon create the pad + hold it open, read commands from
the FIFO /tmp/vpad.fifo until killed
./vgamepad.py <cmd> [...] send command(s) to the running daemon, e.g.
./vgamepad.py a (A / confirm)
./vgamepad.py b (B / back)
./vgamepad.py up down left right
./vgamepad.py lb rb start back guide
NOTE: FIFA enumerates controllers at launch, so the daemon must be running
BEFORE FIFA starts (or FIFA relaunched) for the pad to be seen.
"""
import os, sys, time, struct, fcntl
FIFO = "/tmp/vpad.fifo"
UINPUT = "/dev/uinput"
# ---- ioctl numbers (x86_64) ------------------------------------------------
UI_SET_EVBIT = 0x40045564
UI_SET_KEYBIT = 0x40045565
UI_SET_ABSBIT = 0x40045567
UI_DEV_CREATE = 0x5501
UI_DEV_DESTROY = 0x5502
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
SYN_REPORT = 0
BUS_USB = 0x03
# Xbox-360 button codes
BTN = {
"a": 0x130, "b": 0x131, "x": 0x133, "y": 0x134,
"lb": 0x136, "rb": 0x137, "back": 0x13a, "start": 0x13b,
"guide": 0x13c, "l3": 0x13d, "r3": 0x13e,
}
ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ = 0, 1, 2, 3, 4, 5
ABS_HAT0X, ABS_HAT0Y = 0x10, 0x11
STICKS = [ABS_X, ABS_Y, ABS_RX, ABS_RY] # -32768..32767
TRIGGERS = [ABS_Z, ABS_RZ] # 0..255
HATS = [ABS_HAT0X, ABS_HAT0Y] # -1..1
# d-pad direction -> (hat axis, value)
DPAD = {
"up": (ABS_HAT0Y, -1), "down": (ABS_HAT0Y, 1),
"left": (ABS_HAT0X, -1), "right": (ABS_HAT0X, 1),
}
def _ev(fd, etype, code, value):
# struct input_event { timeval time(16); u16 type; u16 code; s32 value; }
os.write(fd, struct.pack("llHHi", 0, 0, etype, code, value))
def _syn(fd):
_ev(fd, EV_SYN, SYN_REPORT, 0)
def create_device():
fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(fd, UI_SET_EVBIT, EV_KEY)
fcntl.ioctl(fd, UI_SET_EVBIT, EV_ABS)
fcntl.ioctl(fd, UI_SET_EVBIT, EV_SYN)
for code in BTN.values():
fcntl.ioctl(fd, UI_SET_KEYBIT, code)
for ax in STICKS + TRIGGERS + HATS:
fcntl.ioctl(fd, UI_SET_ABSBIT, ax)
# legacy uinput_user_dev: name[80], input_id{bus,vendor,product,version}(u16*4),
# ff_effects_max(u32), absmax/min/fuzz/flat[64] each s32
name = b"Microsoft X-Box 360 pad".ljust(80, b"\0")
idv = struct.pack("HHHH", BUS_USB, 0x045e, 0x028e, 0x0114)
ff = struct.pack("I", 0)
absmax = [0] * 64; absmin = [0] * 64; absfuzz = [0] * 64; absflat = [0] * 64
for ax in STICKS:
absmax[ax] = 32767; absmin[ax] = -32768; absflat[ax] = 128
for ax in TRIGGERS:
absmax[ax] = 255; absmin[ax] = 0
for ax in HATS:
absmax[ax] = 1; absmin[ax] = -1
payload = (name + idv + ff
+ struct.pack("64i", *absmax) + struct.pack("64i", *absmin)
+ struct.pack("64i", *absfuzz) + struct.pack("64i", *absflat))
os.write(fd, payload)
fcntl.ioctl(fd, UI_DEV_CREATE)
time.sleep(0.3) # let udev create /dev/input/eventN + jsN
return fd
def do(fd, cmd):
cmd = cmd.strip().lower()
if not cmd:
return
if cmd in BTN:
_ev(fd, EV_KEY, BTN[cmd], 1); _syn(fd); time.sleep(0.08)
_ev(fd, EV_KEY, BTN[cmd], 0); _syn(fd)
elif cmd in DPAD:
ax, val = DPAD[cmd]
_ev(fd, EV_ABS, ax, val); _syn(fd); time.sleep(0.10)
_ev(fd, EV_ABS, ax, 0); _syn(fd)
elif cmd.startswith("hold_") and cmd[5:] in BTN: # hold_lb etc. (no auto-release)
_ev(fd, EV_KEY, BTN[cmd[5:]], 1); _syn(fd)
elif cmd.startswith("rel_") and cmd[4:] in BTN:
_ev(fd, EV_KEY, BTN[cmd[4:]], 0); _syn(fd)
else:
sys.stderr.write("unknown cmd: %s\n" % cmd)
time.sleep(0.12)
def daemon():
if os.path.exists(FIFO):
os.unlink(FIFO)
os.mkfifo(FIFO)
fd = create_device()
sys.stderr.write("[vgamepad] device created, listening on %s\n" % FIFO)
sys.stderr.flush()
try:
while True:
with open(FIFO, "r") as f: # blocks until a writer sends a line
for line in f:
for cmd in line.split():
do(fd, cmd)
finally:
try:
fcntl.ioctl(fd, UI_DEV_DESTROY)
except Exception:
pass
os.close(fd)
if os.path.exists(FIFO):
os.unlink(FIFO)
def send(cmds):
if not os.path.exists(FIFO):
sys.stderr.write("!! daemon not running (no %s). Start: vgamepad.py daemon\n" % FIFO)
sys.exit(2)
with open(FIFO, "w") as f:
f.write(" ".join(cmds) + "\n")
print("sent: %s" % " ".join(cmds))
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
if sys.argv[1] == "daemon":
daemon()
else:
send(sys.argv[1:])
+57
View File
@@ -0,0 +1,57 @@
# watch_login.gdb -- HARDWARE watchpoint on OriginMgr.m_isLoggedIn.
# Software int3 breakpoints looked unreliable under Wine wow64 (i386:x64-32 arch
# warning, zero hits even though FIFA drains the socket). Debug-register
# watchpoints work at the CPU level and catch ANY write to the byte, revealing
# the real setter + backtrace regardless of which code path does it.
#
# Q: does m_isLoggedIn ([OriginMgr+0x13]) EVER get written? by what instruction?
#
# Also arms HARDWARE exec breakpoints (hbreak) on the known setter/matcher so we
# can tell "bp mechanism broken" apart from "code never runs".
set pagination off
set confirm off
set width 0
attach PIDHERE
set architecture i386:x86-64
# CRITICAL for Wine: it drives thread scheduling with SIGUSR1/USR2 and realtime
# signals. gdb halts on them by default, which (in -batch) ends the script and
# DETACHES within seconds -- the reason earlier traces saw zero hits. Pass them
# through silently so the game keeps running and our bps/watchpoints survive.
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIG32 nostop noprint pass
handle SIG33 nostop noprint pass
handle SIG34 nostop noprint pass
handle SIG35 nostop noprint pass
# resolve the live OriginMgr and the flag byte address
set $om = *(unsigned long*)0x1448acf50
printf "OriginMgr = %#lx m_isLoggedIn byte @ %#lx = %d\n", $om, $om+0x13, *(unsigned char*)($om+0x13)
# --- the decisive probe: catch ANY write to the flag byte ---
watch *(unsigned char*)($om+0x13)
commands
printf "\n*** m_isLoggedIn WRITE: %d -> %d at rip=%#lx ***\n", $arg0, *(unsigned char*)($om+0x13), $rip
printf "backtrace:\n"
bt 6
continue
end
# --- validation / cross-check: hardware exec bps on the theorised machinery ---
hbreak *0x146f1e0ab
commands
silent
printf ">> [hbreak] setter 0x146f1e0ab reached (mov [rcx+0x13],1) rcx=%#lx\n", $rcx
continue
end
hbreak *0x147102880
commands
silent
printf ">> [hbreak] sender matcher 0x147102880 reached\n"
continue
end
printf "\n=== watch_login armed (HW watchpoint + hbreak). Drive the game: dismiss popup, ONLINE tab, reconnect. ===\n"
continue
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Attach the HW-watchpoint login tracer to the running FIFA17.exe.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
PID="$(pgrep -x FIFA17.exe | head -1 || true)"
[ -z "${PID}" ] && { echo "FIFA17.exe not running"; exit 1; }
echo "attaching HW watchpoint to FIFA17.exe pid=${PID}"
TMP="$(mktemp /tmp/watch_login.XXXX.gdb)"
sed "s/PIDHERE/${PID}/" "${DIR}/watch_login.gdb" > "${TMP}"
gdb -batch -x "${TMP}" 2>&1 | tee /tmp/watch_login.log
rm -f "${TMP}"