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:
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
@@ -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 `0x14389d6xx–0x14389d928` contains **exactly the same 33 RPC names** as the pamplona enum (`acceptLegalDocs … logout … upgradeAccount`), i.e. FIFA 17 and MEC ship the *same* Authentication component definition. `"logout"` is at `0x14389d8d8`.
|
||||
3. `logout` is the only Authentication RPC in that set that takes **no request parameters**, which matches the observed 0-byte payload. (`getAuthToken` is also parameterless but is `0x24`, not `0x46`.)
|
||||
|
||||
> Confidence: **high**, cross-validated three ways, but *not* 100% binary-pinned — FIFA 17's `getCommandName` for Authentication is not emitted as the `lea rax,[rip+str]; ret` stub pattern that yielded the Util table, and the name pool has **zero** code xrefs (searched every `lea/mov rip-rel` and every aligned+unaligned 8-byte pointer, module-wide). So the id↔name mapping itself is inherited from (A) clean-room 3P, not re-derived from FIFA 17.
|
||||
|
||||
### What this changes
|
||||
|
||||
The client **never attempted `login` (cmd 10)**. It went:
|
||||
|
||||
`preAuth → ping → 6× fetchClientConfig (all answered EMPTY) → logout → give up`
|
||||
|
||||
So it is *not* waiting on an Authentication response we failed to send. It decided, **before** issuing any login, that it had nothing to log in with, and tore the Blaze session down. That is consistent with the two-layer gate: it had **no Origin auth code** to put in `LoginRequest.AUTH`, and/or the OSDK layer failed to initialise from the empty configs.
|
||||
|
||||
**(A) binary corroboration of layer 1:** `FIFA17.exe` contains the localisation key **`TXT_NOT_LOGIN_TO_EBISU`** at `0x1439633e8` (Ebisu = EA's internal codename for Origin), sitting immediately beside `TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE`, `OSDK_PRESENCE_OFFLINE`, `OSDK_OL_STATE_NONE`. The UI flow events `checkOriginConnected` / `OriginIsOnline` / `OriginIsOffline` live at `0x143b4cb58`. This is almost certainly the on-screen message, and it is produced by the **Origin/LSX layer**, not by Blaze.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication component — command ids
|
||||
|
||||
Component id **0x0001**. Ids **(A) clean-room 3P** (pamplona enum); every name **(A) binary**-confirmed present in FIFA 17's RPC name pool unless noted.
|
||||
|
||||
| Cmd | RPC | Cmd | RPC |
|
||||
|---|---|---|---|
|
||||
| 10 (0x0A) | **`login`** | 54 | `disableOptIn` |
|
||||
| 11 (0x0B) | `trustedLogin` | 60 (0x3C) | `expressLogin` |
|
||||
| 20 | `updateAccount` | **70 (0x46)** | **`logout`** ← *observed* |
|
||||
| 21 | `upgradeAccount` | 90 (0x5A) | `getPersona` |
|
||||
| 29 (0x1D) | **`listUserEntitlements2`** | 100 | `listPersonas` |
|
||||
| 30 | `getAccount` | 101 | `expressCreateAccount` |
|
||||
| 31 | `grantEntitlement` | 230 | `createWalUserSession` |
|
||||
| 32 | `listEntitlements` | 241 | `acceptLegalDocs` |
|
||||
| 34 | `getUseCount` | 242 | `getEmailOptInSettings` |
|
||||
| 35 | `decrementUseCount` | 246 | `getTermsOfServiceContent` |
|
||||
| 36 (0x24) | **`getAuthToken`** | 260 | `getOriginPersona` |
|
||||
| 38 | `getPasswordRules` | 270 | `checkEmail` |
|
||||
| 39 | `grantEntitlement2` | 280 | `getPersonaNameSuggestions` |
|
||||
| 43 | `modifyEntitlement2` | 290 | `guestLogin` |
|
||||
| 44 | `consumecode` | | |
|
||||
| 45 | `passwordForgot` | | |
|
||||
| 47 | `getPrivacyPolicyContent` | | |
|
||||
| 48 | `listPersonaEntitlements2` | | |
|
||||
| 51 | `checkAgeReq` | | |
|
||||
| 52 | `getOptIn` | | |
|
||||
| 53 | `enableOptIn` | | |
|
||||
|
||||
FIFA 17 additionally ships types with no id in the MEC enum — `GetUserAccessTokenRequest/Response`, `GetUserXblTokenRequest/Response`, `StressLoginRequest`, `CheckLegalDocRequest/Response`, `GetSuggestionsRequest` — so FIFA's component is a **superset**. Ids for those are unknown.
|
||||
|
||||
**Error-code encoding (A) binary:** Blaze error codes are `(index << 16) | componentId`. Confirmed by the `0x000N7802` constant block at `0x146de08xx` sitting alongside the `USER_ERR_*` name stubs (component `0x7802` = UserSessions). So an Authentication error is `0xNNNN0001`.
|
||||
|
||||
---
|
||||
|
||||
## 3. `login` (1/10) request + response TDF — **(A) binary, authoritative**
|
||||
|
||||
Reflected from FIFA 17's own TDF type descriptors. **Fields MUST be emitted in ascending packed-tag order**, which for A–Z tags is plain alphabetical with `' '` (pad) sorting first — the member tables below are already in that order.
|
||||
|
||||
### Request — `Blaze::Authentication::LoginRequest` @ `0x14487ca10` (3 members)
|
||||
|
||||
| Tag | Member | Type |
|
||||
|---|---|---|
|
||||
| `AUTH` | `authCode` | string ← **the Origin/Nucleus auth code** |
|
||||
| `EXTB` | `externalBlob` | blob |
|
||||
| `EXTI` | `externalId` | uint64 |
|
||||
|
||||
> z7's `LoginRequest` also carried `ACHT{SHID,SKID}` — **FIFA 17 has no `ACHT` member**. z7 is a different title/version. Ignore it.
|
||||
|
||||
### Response — `Blaze::Authentication::LoginResponse` @ `0x14487d170` (**5 members**)
|
||||
|
||||
| Tag | Member | Type |
|
||||
|---|---|---|
|
||||
| `ANON` | `isAnonymous` | bool |
|
||||
| `NTOS` | `needsLegalDoc` | bool |
|
||||
| `SESS` | `userLoginInfo` | struct `UserLoginInfo` |
|
||||
| `SPAM` | `isOfLegalContactAge` | bool |
|
||||
| `UNDR` | `isUnderage` | bool |
|
||||
|
||||
> **Important divergence from the MEC emulators.** `grid-blaze/src/models/authentication.rs:23-50` and `pamplona-future/.../authentication.ts:78-128` both emit `CNTX`, `ERRC` and a top-level `SKEY` in the login *payload*. **FIFA 17's `LoginResponse` has none of those.** `CNTX`/`ERRC` are the Blaze **error metadata** block (see `grid-blaze/src/packet.rs:62-71` `ErrorBody`, and its own `// TODO: move ErrorBody to metadata`; pamplona sets `metadataSize: 75` on the login reply). Emit **exactly the 5 members above** in the payload; leave metadata empty on success. The session key lives at `SESS.KEY`, not at top level.
|
||||
|
||||
### `SESS` — `Blaze::Authentication::UserLoginInfo` @ `0x14487cb00` (8 members)
|
||||
|
||||
| Tag | Member | Type |
|
||||
|---|---|---|
|
||||
| `1CON` | `isFirstConsoleLogin` | bool |
|
||||
| `BUID` | `blazeUserId` | int64 |
|
||||
| `FRST` | `isFirstLogin` | bool |
|
||||
| `KEY` | `sessionKey` | string ← **the forged session key** |
|
||||
| `LLOG` | `lastLoginDateTime` | int64 |
|
||||
| `MAIL` | `email` | string |
|
||||
| `PDTL` | `personaDetails` | struct `PersonaDetails` |
|
||||
| `UID` | `userId` | int64 |
|
||||
|
||||
(`1` = 0x31 → packed 0x11, which is **below** `A` = 0x21, so `1CON` correctly sorts first.)
|
||||
|
||||
### `PDTL` — `Blaze::Authentication::PersonaDetails` @ `0x14487cab0` (6 members)
|
||||
|
||||
| Tag | Member | Type |
|
||||
|---|---|---|
|
||||
| `DSNM` | `displayName` | string |
|
||||
| `LAST` | `lastAuthenticated` | uint32 |
|
||||
| `PID` | `personaId` | int64 |
|
||||
| `PLAT` | `clientPlatform` | enum `ClientPlatformType` |
|
||||
| `STAS` | `status` | enum `PersonaStatus::Code` |
|
||||
| `XREF` | `extId` | uint64 |
|
||||
|
||||
### `getAuthToken` (1/36) response — `GetAuthTokenResponse` @ `0x14487d080`
|
||||
|
||||
Single member: `AUTH` `authToken` : string. (Matches z7 exactly.)
|
||||
|
||||
### `listUserEntitlements2` (1/29) response — `Blaze::Authentication::Entitlements` @ `0x14487d4e0`
|
||||
|
||||
Single member `NLST` : `list<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 `0x14389fd50–0x14389fef8` reads, in order:
|
||||
|
||||
```
|
||||
LoginManagerImpl · LoginData · LoginStateMachineImpl · LoginStateMachine
|
||||
nucleusConnectTrusted · "%s/connect/token" · "grant_type=client_credentials"
|
||||
recvBuf · "NEXUS_S2S " · "\"access_token\" : \"" · headers
|
||||
LoginStateBase::buffer
|
||||
LoginStateAuthenticated::mTermsOfServiceBuffer
|
||||
LoginStateAuthenticated::mPrivacypolicyBuffer
|
||||
nucleusConnect
|
||||
```
|
||||
|
||||
So the client builds **`<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 1–320 were swept; everything not listed returns the empty default string.
|
||||
|
||||
| Cmd | RPC | Cmd | RPC |
|
||||
|---|---|---|---|
|
||||
| `0x000A` (10) | **`login`** | `0x003C` (60) | `expressLogin` |
|
||||
| `0x000B` (11) | `trustedLogin` | **`0x0046` (70)** | **`logout`** ← *observed on the wire* |
|
||||
| `0x0014` (20) | `updateAccount` | `0x005A` (90) | `getPersona` |
|
||||
| `0x0015` (21) | `upgradeAccount` | `0x0064` (100) | `listPersonas` |
|
||||
| `0x001D` (29) | `listUserEntitlements2` | `0x0065` (101) | `expressCreateAccount` |
|
||||
| `0x001E` (30) | `getAccount` | `0x00E6` (230) | `createWalUserSession` |
|
||||
| `0x001F` (31) | `grantEntitlement` | `0x00F1` (241) | `acceptLegalDocs` |
|
||||
| `0x0020` (32) | `listEntitlements` | `0x00F2` (242) | `getEmailOptInSettings` |
|
||||
| `0x0022` (34) | `getUseCount` | `0x00F6` (246) | `getTermsOfServiceContent` |
|
||||
| `0x0023` (35) | `decrementUseCount` | `0x0104` (260) | `getOriginPersona` |
|
||||
| `0x0024` (36) | `getAuthToken` | `0x010E` (270) | `checkEmail` |
|
||||
| `0x0026` (38) | `getPasswordRules` | `0x0118` (280) | `getPersonaNameSuggestions` |
|
||||
| `0x0027` (39) | `grantEntitlement2` | `0x0122` (290) | `guestLogin` |
|
||||
| `0x002B` (43) | `modifyEntitlement2` | | |
|
||||
| `0x002C` (44) | `consumecode` | | |
|
||||
| `0x002D` (45) | `passwordForgot` | | |
|
||||
| `0x002F` (47) | `getPrivacyPolicyContent` | | |
|
||||
| `0x0030` (48) | `listPersonaEntitlements2` | | |
|
||||
| `0x0033` (51) | `checkAgeReq` | | |
|
||||
| `0x0034` (52) | `getOptIn` | | |
|
||||
| `0x0035` (53) | `enableOptIn` | | |
|
||||
| `0x0036` (54) | `disableOptIn` | | |
|
||||
|
||||
Confidence: **certain** for every row (the client itself produced these strings).
|
||||
|
||||
### Request / response type binding
|
||||
|
||||
From the reflection type index (`Blaze::Authentication::*`):
|
||||
|
||||
| Cmd | RPC | Request TDF | Response TDF |
|
||||
|---|---|---|---|
|
||||
| `0x000A` | `login` | `LoginRequest` | **`LoginResponse`** |
|
||||
| `0x000B` | `trustedLogin` | `TrustedLoginRequest` | `LoginResponse` |
|
||||
| `0x003C` | `expressLogin` | `ExpressLoginRequest` | `LoginResponse` |
|
||||
| `0x0122` | `guestLogin` | *(none)* | `LoginResponse` |
|
||||
| **`0x0046`** | **`logout`** | ***(none — empty)*** | ***(none — empty)*** |
|
||||
| `0x0024` | `getAuthToken` | *(none)* | `GetAuthTokenResponse` |
|
||||
| `0x001D` | `listUserEntitlements2` | `ListUserEntitlements2Request` | `Entitlements` |
|
||||
| `0x0030` | `listPersonaEntitlements2` | `ListPersonaEntitlements2Request` | `Entitlements` |
|
||||
| `0x0064` | `listPersonas` | *(none)* | `ListPersonasResponse` |
|
||||
| `0x005A` | `getPersona` | *(none)* | `GetPersonaResponse` |
|
||||
| `0x001E` | `getAccount` | *(none)* | `AccountInfo` |
|
||||
| `0x0104` | `getOriginPersona` | `GetOriginPersonaRequest` | `PersonaInfo` |
|
||||
|
||||
`logout` having no TDFs on either side is confirmed by absence: there is no `LogoutRequest` /
|
||||
`LogoutResponse` type anywhere in the client's index.
|
||||
|
||||
---
|
||||
|
||||
## 3. `Blaze::Authentication::LoginResponse` — the login reply (cmd `0x000A`)
|
||||
|
||||
Descriptor `0x14487d170`, member table `0x1448789c0`, 5 members. Serialise in **ascending packed-tag
|
||||
order** (which is the order the table itself is sorted in).
|
||||
|
||||
| Tag | Member | TDF type | Wire type | Offset |
|
||||
|---|---|---|---|---|
|
||||
| `ANON` | `isAnonymous` | bool | `0x00` | `+0x12` |
|
||||
| `NTOS` | `needsLegalDoc` | bool | `0x00` | `+0x13` |
|
||||
| `SESS` | `userLoginInfo` | `Blaze::Authentication::UserLoginInfo` | `0x03` | `+0x18` |
|
||||
| `SPAM` | `isOfLegalContactAge` | bool | `0x00` | `+0x10` |
|
||||
| `UNDR` | `isUnderage` | bool | `0x00` | `+0x11` |
|
||||
|
||||
### `Blaze::Authentication::UserLoginInfo` (`SESS`) — 8 members, descriptor `0x14487cb00`
|
||||
|
||||
| Tag | Member | Type | Offset |
|
||||
|---|---|---|---|
|
||||
| `1CON` | `isFirstConsoleLogin` | bool | `+0x51` |
|
||||
| `BUID` | `blazeUserId` | int64 | `+0x28` |
|
||||
| `FRST` | `isFirstLogin` | bool | `+0x50` |
|
||||
| `KEY ` | `sessionKey` | string | `+0x10` |
|
||||
| `LLOG` | `lastLoginDateTime` | int64 | `+0x58` |
|
||||
| `MAIL` | `email` | string | `+0x38` |
|
||||
| `PDTL` | `personaDetails` | `PersonaDetails` (struct) | `+0x60` |
|
||||
| `UID ` | `userId` | int64 | `+0x30` |
|
||||
|
||||
> Note the trailing-space tags: `KEY` and `UID` are 3-char tags. `heat2.py`'s `encode_tag`
|
||||
> pads to 4 with the 6-bit "space" code, which is what the wire expects.
|
||||
|
||||
### `Blaze::Authentication::PersonaDetails` (`PDTL`) — 6 members, descriptor `0x14487cab0`
|
||||
|
||||
| Tag | Member | Type | Offset |
|
||||
|---|---|---|---|
|
||||
| `DSNM` | `displayName` | string | `+0x18` |
|
||||
| `LAST` | `lastAuthenticated` | uint32 | `+0x3c` |
|
||||
| `PID ` | `personaId` | int64 | `+0x10` |
|
||||
| `PLAT` | `clientPlatform` | `Blaze::ClientPlatformType` (enum → int) | `+0x38` |
|
||||
| `STAS` | `status` | `PersonaStatus::Code` (enum → int) | `+0x40` |
|
||||
| `XREF` | `extId` | uint64 | `+0x30` |
|
||||
|
||||
### Request side (for completeness)
|
||||
|
||||
`Blaze::Authentication::LoginRequest` (`0x14487ca10`, 3 members):
|
||||
|
||||
| Tag | Member | Type |
|
||||
|---|---|---|
|
||||
| `AUTH` | `authCode` | string ← *the Origin/LSX auth code* |
|
||||
| `EXTB` | `externalBlob` | blob |
|
||||
| `EXTI` | `externalId` | uint64 |
|
||||
|
||||
`TrustedLoginRequest` (`0x14487ca60`): `ID ` id:string, `ITYP` idType:string, `TOKN` accessToken:string.
|
||||
`ExpressLoginRequest` (`0x14487d0d0`): `MAIL` email:string, `PASS` password:string, `PNAM` personaName:string.
|
||||
`StressLoginRequest` (`0x14487d120`): `MAIL` email:string, `NUID` nucleusId:uint64, `PNAM` personaName:string.
|
||||
|
||||
The `AUTH` field being a plain string is the whole hinge: **the client cannot fill it without a
|
||||
working Origin `GetAuthCode`.**
|
||||
|
||||
---
|
||||
|
||||
## 4. What the server must return for the client to consider itself authenticated
|
||||
|
||||
**Caveat on method.** For `PreAuthResponse` we could read `ConnectionManager::onPreAuthResponse`
|
||||
directly and prove field-by-field what is and isn't validated. The equivalent login-response handler
|
||||
lives in the FIFA/OSDK layer, and that code is **Denuvo-mutated** — the same protection that hid
|
||||
`getCommandName`. So the list below is derived from *structure* (what the types carry, what the
|
||||
UserSessions notification duplicates, what the client must have in order to name itself), not from
|
||||
reading the handler. Flagged accordingly.
|
||||
|
||||
| Field | Why it matters | Confidence it is required |
|
||||
|---|---|---|
|
||||
| `SESS.KEY ` `sessionKey` | The Blaze session credential. Every later component echoes it; a session with an empty key is not a session. Must be non-empty. | **high** |
|
||||
| `SESS.BUID` `blazeUserId` | The client's own BlazeId. Used as the identity key in UserManager and in every later `lookupUser*`. Must be non-zero. | **high** |
|
||||
| `SESS.UID ` `userId` | Nucleus user id. Should equal `blazeUserId` for a single-account offline emu unless you have a reason to split them. | **high** |
|
||||
| `SESS.PDTL.PID ` `personaId` | **Must equal `33068179`** (the `stp-origin_emu.ini` `PersonaId`). A mismatch is exactly what trips `AUTH_ERR_INVALID_PERSONA` / `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` / `AUTH_ERR_PERSONA_NOT_FOUND`. | **high** |
|
||||
| `SESS.PDTL.DSNM` `displayName` | **Must equal `CAGE`.** This is the name the UI renders and the value later persona lookups are matched against. | **high** |
|
||||
| `SESS.PDTL.STAS` `status` | Persona status enum. Must be the "active" value; `AUTH_ERR_PERSONA_INACTIVE` (19) and `AUTH_ERR_PERSONA_BANNED` (32) exist for the other cases. Send `0`. | medium |
|
||||
| `SESS.PDTL.PLAT` `clientPlatform` | Should match the `PLAT` you returned in `PreAuthResponse` (`pc`). | medium |
|
||||
| `SESS.PDTL.XREF` `extId` | External (Origin) id. Safe to mirror `personaId`. | low |
|
||||
| `SESS.MAIL` `email` | Cosmetic; any well-formed address. | low |
|
||||
| `ANON` `isAnonymous` | **Must be `false`/0.** An anonymous session is precisely a not-really-logged-in session and will not enable online features. | **high** |
|
||||
| `NTOS` `needsLegalDoc` | **Must be `false`/0**, otherwise the client will branch into the legal-doc flow (`acceptLegalDocs` = 241, `getTermsOfServiceContent` = 246) instead of proceeding. | **high** |
|
||||
| `UNDR` `isUnderage` | **Must be `false`/0** — underage gates online play. | **high** |
|
||||
| `SPAM` `isOfLegalContactAge` | Set `true`/1 for symmetry with `UNDR=false`. | medium |
|
||||
| `FRST` / `1CON` / `LLOG` | First-login flags and timestamp. Cosmetic; `false/false/<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.
|
||||
@@ -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 19–22) with a generic `ErrorSuccess Code="0"`** — a *type-mismatched* reply
|
||||
to a `GetInternetConnectedState` request (observed verbatim at `0x28793008`/`0x28793668`/
|
||||
`0x28793728`…). The client's Origin SDK finds no `connected` attribute to parse, so the byte
|
||||
it stores is stale/garbage. Net effect: **the online flag is non-deterministic garbage, never
|
||||
a genuine "online".** This is consistent with the flow still failing while the byte happens to
|
||||
read 1, and it means fixing the LSX layer must make *every* `GetInternetConnectedState` return
|
||||
a well-formed `connected="1"`, not just the first.
|
||||
|
||||
---
|
||||
|
||||
## 2. Order of operations the client actually performs
|
||||
|
||||
**Observed LSX order (boot, on 127.0.0.1:4216, in-process):**
|
||||
`IsProgressiveInstallationAvailable` (id 9) → `GetSetting` (→"production") → `GetGameInfo`
|
||||
(`GameInfoId="FREETRIAL"`, id 15) → `GetGameInfo` (id 16) → **`GetInternetConnectedState`
|
||||
(id 17) → `connected="0"`** → `GetProfile` (→ `Persona="CAGE"`) → repeated
|
||||
`GetInternetConnectedState` polls (ids 19–22) → all answered `ErrorSuccess`.
|
||||
**`GetAuthCode` is never reached.**
|
||||
|
||||
**FeFlow order:**
|
||||
`mainMenu` → `launchFUTFlow` (`/online/origin.nav`, action `checkOriginConnected` 0x27e1)
|
||||
→ **[GATE]** `OriginIsOnline` 0x27e2 → output `OriginIsOnlineTrue`
|
||||
→ `futBlazeLogin` (`/online/onlineLoginFlow.nav`, entry state `startLoginWithoutMultiplayerCheck`,
|
||||
which fires `sendScreenEvent ["OnlineLogin","0"]`)
|
||||
→ on success event `loginSuccess` → `TrialWelcomeCheck` → `CheckFUTRosters`
|
||||
→ `advance` → `postFUTBlazeLogin` → `futFlow` (`/fut/futFlow.nav`).
|
||||
|
||||
Failure branches inside `onlineLoginFlow.nav`: `evt_onlineLoginFailurePopup` /
|
||||
`evt_onlineBootLoginFailurePopup` → `onlineFailureLoginPopup` → `processLoginFailure`
|
||||
→ `loginFail` → `mainMenu`. Every one of those popups renders via the C++ action
|
||||
`onlineLoginPopupShow` — i.e. the same function that prints the Origin-offline text.
|
||||
|
||||
**Observed Blaze order (our session log):** `Util::preAuth` (9/0x07) → `Util::ping` (9/0x02)
|
||||
→ `Util::fetchClientConfig` (9/0x01) ×6 for `OSDK_CORE`, `OSDK_CLIENT`, `OSDK_NUCLEUS`,
|
||||
`OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_XMS_ABUSE_REPORTING` → `Authentication` (1/0x46,
|
||||
empty payload) → reconnect loop. **`Authentication::login` is never sent** — consistent with the
|
||||
Origin gate blocking upstream.
|
||||
|
||||
---
|
||||
|
||||
## 3. What makes it stop looping — the notification it waits on
|
||||
|
||||
Recovered the **UserSessions notification name table** (jump table `0x141b03f70`,
|
||||
switch at `0x146de19b1`, ids 1-based):
|
||||
|
||||
| ID | Notification |
|
||||
|---|---|
|
||||
| `0x01` | `UserSessionExtendedDataUpdate` |
|
||||
| `0x02` | `UserAdded` |
|
||||
| `0x03` | `UserRemoved` |
|
||||
| `0x05` | `UserUpdated` |
|
||||
| **`0x08`** | **`UserAuthenticated`** |
|
||||
| `0x09` | `UserUnauthenticated` |
|
||||
| `0x0c` | `ServerDraining` |
|
||||
|
||||
**`UserAuthenticated` (notification `0x08`) is the signal that flips the session to
|
||||
authenticated.** Expect to also need `UserAdded` (0x02) and
|
||||
`UserSessionExtendedDataUpdate` (0x01) so the local user object is populated.
|
||||
|
||||
### Util component (0x0009) — full command table, recovered
|
||||
|
||||
Jump table `0x141b17af4`, switch `0x146df6dd5`. This *validates the whole technique*: it
|
||||
matches our observed traffic exactly (preAuth=0x07, ping=0x02, fetchClientConfig=0x01).
|
||||
|
||||
| ID | Command | | ID | Command |
|
||||
|---|---|---|---|---|
|
||||
| `0x01` | `fetchClientConfig` | | `0x0f` | `userSettingsLoadMultiple` |
|
||||
| `0x02` | `ping` | | `0x14` | `filterForProfanity` |
|
||||
| `0x03` | `setClientData` | | `0x15` | `fetchQosConfig` |
|
||||
| `0x04` | `localizeStrings` | | `0x16` | `setClientMetrics` |
|
||||
| `0x05` | `getTelemetryServer` | | `0x17` | `setConnectionState` |
|
||||
| `0x06` | `getTickerServer` | | `0x19` | `getUserOptions` |
|
||||
| `0x07` | `preAuth` | | `0x1a` | `setUserOptions` |
|
||||
| **`0x08`** | **`postAuth`** | | `0x1b` | `suspendUserPing` |
|
||||
| `0x0a` | `userSettingsLoad` | | **`0x1c`** | **`setClientState`** |
|
||||
| `0x0b` | `userSettingsSave` | | `0x0e` | `deleteUserSettings` |
|
||||
| `0x0c` | `userSettingsLoadAll` | | | |
|
||||
|
||||
### Unresolved: Authentication (0x0001) command `0x46`
|
||||
|
||||
The Authentication component's `getCommandName` name table is **not compiled into this
|
||||
binary** (I scanned every MSVC jump-table switch in the module, both the dword-index and
|
||||
byte-index forms — 13 tables total; Util, UserSessions, Stats, Messaging, AssociationLists,
|
||||
Tournaments, GameReporting, OSDK are present, Authentication is not). So `0x46` cannot be named
|
||||
by table lookup. What *is* known: the client sends it with an **empty payload**, immediately
|
||||
after the six `fetchClientConfig` calls and **before** any login, and accepts our empty reply
|
||||
without erroring. Available Authentication TDF types are catalogued in
|
||||
`scratchpad/all_types.txt` (`Blaze::Authentication::*`, 290 entries incl. `ExpressLoginRequest`,
|
||||
`GetAuthTokenResponse`, `Entitlements`, `AcceptLegalDocsRequest`, `CheckLegalDocRequest`).
|
||||
To resolve it properly, use the descriptor-reflection method from
|
||||
`fifa17-recon/tools/preauth_schema_reflection.md` against the handler that dispatches
|
||||
component 1 replies.
|
||||
|
||||
---
|
||||
|
||||
## 4. Concrete fix order
|
||||
|
||||
**a4 (LSX / Origin) must be done first — it is the true first blocker.** Replace or shim the
|
||||
Steampunks stub's LSX server on `127.0.0.1:4216` (it is in-process, both socket ends are
|
||||
`FIFA17.exe`, so this means either patching `stp-origin_emu.dll`, or hooking its WS2_32 use,
|
||||
or supplying our own LSX responder). Required, all consistent with
|
||||
`stp-origin_emu.ini [Globals] PersonaId=33068179, PersonaName=CAGE, Language=en_US`:
|
||||
|
||||
1. `GetInternetConnectedState` → `<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 |
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+96
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
Executable
+15
@@ -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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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]))
|
||||
@@ -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:])
|
||||
@@ -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
|
||||
Executable
+11
@@ -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}"
|
||||
Reference in New Issue
Block a user