edab23f04a
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.
Package:
- tools/openfut-fut.sh one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md the reverse-engineering write-ups
All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
494 lines
30 KiB
Markdown
494 lines
30 KiB
Markdown
# CONNECT_PLAN — Origin-SDK connect + Blaze-login trigger (clean-room synthesis)
|
||
|
||
Synthesis of four independent reverses (Origin connect writer, connect-wait guard, "service 0x1e8"
|
||
dispatcher, Blaze LoginManager), plus my own live re-verification against **pid 13643** (`FIFA17.exe`,
|
||
still up at time of writing). Every VA/byte below is from our decrypted dumps, `/proc/13643/mem`,
|
||
`captures/lsx/full_origin_init_session.log`, `/tmp/lsx.log` or `/tmp/blaze_responder.log`.
|
||
|
||
---
|
||
|
||
## 0. HEADLINE — BRIEF3's two premises were both wrong, and the real cause is one line of our responder
|
||
|
||
| BRIEF3 said | Truth (live-verified) |
|
||
|---|---|
|
||
| `0x1470dbfa0` = service/interface lookup; "service `0x1e8` returns NULL → not connected" | `0x1470dbfa0` is the **Origin SDK allocator thunk** (`jmp [0x144b7c778]`, live target `0x1470d9380` = aligned-alloc). `0x1e8` is an **allocation size** (488 = `sizeof(LSXRequest<GetAuthCodeT,…>)`), and the neighbouring `0x1e6/0x1e8/0x1ea` are `__LINE__` args to the logger. **There is no service table and nothing to register.** |
|
||
| `0xa2080000` comes from the auth path failing a service lookup | `0xa2080000` is the LSXRequest/EventHandler **"response failed to validate"** default, written at `0x1471192dd`→`[req+0x1e0]`. It means *we replied and the client threw our reply away.* |
|
||
| Empty Blaze `fetchClientConfig` leaves `[cfg+0x750]` NULL and blocks login | `[cfg+0x750]` is live **non-NULL** = `0x43c47ff0` (verified: `*[0x144b86bf8]=0x43c46c70`, `+0x360=0x43c47330`, `+0x750=0x43c47ff0`). It is fed by **`Util::preAuth`'s CONF map**, not by `fetchClientConfig`. No `fetchClientConfig` payload can change it. |
|
||
|
||
**The actual blocker is a one-attribute protocol mismatch in `lsx_responder_v2.py`.**
|
||
Every LSX `<Response>` is accepted only if its `sender` attribute **byte-equals the `recipient` the
|
||
client put on the matching `<Request>`**. FIFA sends `recipient=""` for everything after `GetConfig`
|
||
(because our `GetConfigResponse` is empty, so its 34-entry service-name table is all empty strings),
|
||
but we answer `GetProfile`, `GetAuthCode` and `QueryEntitlements` with `sender="EbisuSDK"`.
|
||
Those three replies — and only those three — are discarded. `GetProfile` is the *sole* writer of
|
||
`OriginSDK+0x3a0/+0x3a8`, so the whole chain (default user → auth code → OSDK login state → Blaze
|
||
`Authentication::login`) never starts.
|
||
|
||
---
|
||
|
||
## 1. THE CONNECT MECHANISM — end to end
|
||
|
||
### 1.1 The writer is a synchronous statement, not an event
|
||
|
||
`0x1470e5ad5` (the `+0x3a0` write) lives inside **`Origin::OriginSDK::Initialize`**, function start
|
||
`0x1470e5770` (prologue `push rbp/rsi/rdi/r12/r13/r14/r15; sub rsp,0xf0`, epilogue `0x1470e5caf`).
|
||
Its logger passes `# 0x143937c78` = `"Origin::OriginSDK::Initialize"` and
|
||
`# 0x143937b70` = `"E:\p4\fifafb\rl\empatch\TnT\Code\External\EA\OriginSDK\src\impl\OriginSDKimpl.cpp"`
|
||
(both read live). Sequence inside `Initialize`:
|
||
|
||
```
|
||
0x1470e5959..96 connect loop: call 0x14712ca40(conn=sdk+0x168, …, port)
|
||
on false -> Sleep(1000); inc ebx; cmp ebx,0x1e; jl => 30 x 1s (NOT 15 s)
|
||
0x1470e59fb version gate: cmp eax,0x9b00000; jb -> 0xa0020007 (passes today)
|
||
0x1470e5a2f ecx=0x1a8; call 0x1470dbfa0 <-- ALLOC 424 bytes for the GetConfig request
|
||
0x1470e5a46 lea rdx,[rip..] # 0x143937d58 = "EbisuSDK" <-- HARD-CODED recipient/sender
|
||
... SendSync(GetConfig) ...
|
||
0x1470e5a96 lea rdx,[rbx+0x80]; call 0x1470e0520 <-- consume GetConfigResponse
|
||
-> fills serviceNames[0..33]
|
||
0x1470e5aa8 call 0x14710df80(sdk) <-- register ~19 event handlers,
|
||
each keyed on serviceNames[i]
|
||
0x1470e5ab7..c7 lea r9,[rsp+0x70]; xor edx,edx; mov r8d,0x3a98(=15000ms); mov rcx,rdi
|
||
call 0x147118d80 <-- GetProfileSync(idx=0)
|
||
0x1470e5ad5 mov rax,[rdi+0x80]; mov [rbp+0x3a0],rax <-- UserId
|
||
0x1470e5ae1 mov rax,[rdi+0x88]; mov [rbp+0x3a8],rax <-- PersonaId
|
||
```
|
||
|
||
So: **the only stimulus for the `+0x3a0` write is a well-formed `<GetProfileResponse>` to the
|
||
`<GetProfile index="0">` FIFA already sends at boot (log id=3).** There is nothing else to serve, no
|
||
event to push, no "connect" verb we are missing. `+0x3a0`/`+0x3a8` are **u64 scalars** (UserId /
|
||
PersonaId), not object pointers — the deserializer `0x147136140` maps `UserId→resp+0x00`,
|
||
`PersonaId→+0x08`, `Persona→+0x10`, `AvatarId→+0x30`, `Country→+0x50`, `IsUnderAge→+0x70`,
|
||
`IsSubscriber→+0x71`, `GeoCountry→+0x78`, `CommerceCountry→+0x98`, `CommerceCurrency→+0xb8`, and the
|
||
response sub-object sits at `request+0x80`.
|
||
|
||
`0x147118d80` = `Origin::OriginSDK::GetProfileSync` (both exits converge on
|
||
`lea rax,[rip..] # 0x143945d00` = `"Origin::OriginSDK::GetProfileSync"`). It is **not** a poll loop:
|
||
it builds the request, calls the send-and-wait template `0x1471186f0`, which serializes
|
||
`<LSX><Request recipient="X" id="N"><GetProfile index="0" version="3"/></Request></LSX>`, then blocks
|
||
on a timed condvar (`0x145e318f0(&req+0x1e8,&req+0x230)`) until the reader thread sets `[req+0x228]=1`.
|
||
Success is `[req+0x1e0]==0` (`0x147118851: cmp DWORD PTR [rbx+0x1e0],0x0; sete al`).
|
||
|
||
### 1.2 The rejection — verified byte-for-byte, live
|
||
|
||
Response matcher `0x1471189b0` (disassembled from `/proc/13643/mem` this session):
|
||
|
||
```
|
||
1471189c6 call [rax+0x10] ; reader ok? -> je FAIL
|
||
1471189d7 call [rax+0x58] ; root element name
|
||
1471189dd lea rdx,[rip..] # 0x143938024 ; "LSX" (4-byte compare loop) -> jne FAIL
|
||
147118a14 call [rax+0x18] rdx=[this+0xd8] ; child "Response" -> je FAIL
|
||
147118a2a lea rdx,[rip..] # 0x14355de00 = "id" ; optional -> atoi -> [this+0x138]
|
||
147118a5d lea rdx,[rip..] # 0x143938028 = "sender"
|
||
147118a7d call [rax+0x70] ; GetAttributeValue("sender")
|
||
147118a80 test rax,rax ; je 0x147118ad9 ; ATTRIBUTE MUST BE PRESENT
|
||
147118a85 lea r8,[rdi+0xf8] ; expected sender (std::string, SSO at +0x18)
|
||
147118a99..aae inline byte-wise strcmp ; MISMATCH -> jne FAIL (0x147118ad9)
|
||
147118ab3 lea rdx,[rip..] # 0x143945ce8 = "GetProfileResponse" -> je FAIL
|
||
147118acf tail-jump to deserializer
|
||
```
|
||
|
||
On FAIL, `HandleMessage 0x147119210` does `call 0x1470e2e30` (look for an `<Error>` node), and when
|
||
there is none:
|
||
|
||
```
|
||
1471192dd mov edx,0xc347a20f
|
||
1471192e4 lea edx,[rdx-0x213fa20f] ; = 0xA2080000
|
||
1471192ef mov DWORD PTR [rbx+0x1e0],edx
|
||
147119311 call 0x145e30d20 ; signal condvar (so it fails FAST, never times out)
|
||
147119316 mov BYTE PTR [rbx+0x228],1
|
||
```
|
||
|
||
The expected string `[req+0xf8]` and the emitted `recipient` are **the same std::string**, taken from
|
||
`sdk->serviceNames[facility]` via accessor `0x1470e4870`
|
||
(`cmp edx,0x21; movsxd rax,edx; shl rax,5; add rax,[rcx+0x3b0]`), and used symmetrically by the request
|
||
ctor `0x147117fe0` (`"Request"@0x1435a43b8` and `"Response"@0x143938198` both get arg2).
|
||
|
||
Facilities per verb (from the `add rdx,0xNN` after `mov rdx,[sdk+0x3b0]`, `NN/0x20 = idx`):
|
||
|
||
| Verb | site | facility |
|
||
|---|---|---|
|
||
| `GetConfig` | `0x1470e5a46` | **literal `"EbisuSDK"`** (not table-driven) |
|
||
| `GetProfile` | `0x147118df2` (`lea edx,[r12+1]`) | 1 PROFILE |
|
||
| `GetAuthCode` | `0x1470e6882` (`add rdx,0x120`) | 9 UTILITY |
|
||
| `GetSetting` / `GetGameInfo` | `0x1470e4903` / `0x1470e3683` (no add) | 0 SDK |
|
||
| `QueryUserId` | `0x1470e62b4` | 26 GET_USERID |
|
||
| `IsProgressiveInstallationAvailable` / `SetDownloaderUtilization` | `0x147129ddb` / `0x14712b821` | 31 PROGRESSIVE_INSTALLATION *(inferred)* |
|
||
|
||
### 1.3 Live state of the table (re-verified myself, pid 13643)
|
||
|
||
```
|
||
OriginSDK = *[0x144b7c7a0] = 0x25c98c50
|
||
+0x1b8 = 0x1fc (conn+0x50 socket, ALIVE -> transport healthy)
|
||
+0x1e8 = 0xffffffffffffffff (conn+0x80, a SECOND handle, still unset - see §5)
|
||
+0x218 = 0x0 (pending-request map size)
|
||
+0x270 = 0x1c (28 requests issued == our lsx.log last id 27 + 1)
|
||
+0x3a0 = 0x0 <-- default user STILL NULL
|
||
+0x3a8 = 0x0
|
||
+0x3b0 = 0x25cb93e0 .. +0x3b8 = 0x25cb9820 -> (0x440)/0x20 = 34 entries
|
||
all 34 std::strings: size == 0 ("all-empty")
|
||
```
|
||
|
||
34 entries allocated proves `Initialize` **did** reach and accept our `GetConfigResponse`
|
||
(`0x1470e0520`: `lea rbp,[rcx+0x3b0]; lea edx,[rbx+0x22]; call 0x1470eadd0` resizes to 34) — and all
|
||
empty proves it contained **zero `<Service>` children**, because we answer
|
||
`<GetConfigResponse Config="false"/>`.
|
||
|
||
### 1.4 On the wire — the smoking gun
|
||
|
||
`captures/lsx/full_origin_init_session.log`:
|
||
|
||
```
|
||
id=1 <Request recipient="EALS" …><ChallengeResponse …> (plaintext handshake)
|
||
id=2 <Request recipient="EbisuSDK" …><GetConfig version="3"/> -> we reply sender="EbisuSDK" MATCH ✔
|
||
id=3 <Request recipient="" …><GetProfile index="0"/> -> we reply sender="EbisuSDK" MISMATCH ✘
|
||
id=4 <Request recipient="" …><GetSetting …/> -> we reply sender="" MATCH ✔
|
||
id=5 <Request recipient="" …><GetGameInfo …/> -> we reply sender="" MATCH ✔
|
||
… ids 6..14 all recipient=""
|
||
```
|
||
|
||
Independent live proof that an **empty** `sender` attribute really does validate (i.e. `vt[0x70]`
|
||
returns a pointer to `""`, not NULL): FIFA's OSDK failure classifier `0x14717d5d0` only reaches its
|
||
`cmp eax,0xa2000003` branch **after** `OriginGetGameInfoSync(id=0)` returns rc==0 **and** the buffer
|
||
strncmp-equals `"true"@0x14354be6c`; otherwise it would set `TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE`.
|
||
Live `[0x43d189d8+0x80] = 0x14395ca10 = "OSDK_INVALID_USER"` and `[+0x260] = 16` — so our
|
||
`GetGameInfo(UPTODATE) → GameInfo="true"` reply, sent with `sender=""`, **was accepted**. (It also
|
||
pins `GameInfoId` enum 0 == `UPTODATE`.)
|
||
|
||
### 1.5 Events are broken by the same mechanism
|
||
|
||
Event validator `0x1471015e0` (per report 3) requires root `"LSX"@0x143938024`, child literal
|
||
`"Event"@0x14365d8fc` (len 5), optional `id`, then `call [rax+0x70]` for `"sender"@0x143938028` →
|
||
`test rax,rax; je FAIL` → inline strcmp against the per-handler string `[handler+0x10]`.
|
||
I disassembled the registrar `0x14710df80` live and confirmed **that string is
|
||
`serviceNames[facility]`** — every registration is `mov edx,<idx>; call 0x1470e4870; mov rdx,rax;
|
||
call <per-event registrar>`:
|
||
|
||
```
|
||
0x14710df8a edx=0x0c IGO_EVENT -> 0x1470eb6a0
|
||
0x14710dfac edx=0x0f INVITE_EVENT -> 0x1470eb060 (0xe7ffa20f+0x18005e00 = 0x0f)
|
||
0x14710dfcd edx=0x0e LOGIN_EVENT -> 0x1470eb100 (0xc390050f+0x3c6ffaff = 0x0e) << ours
|
||
0x14710dfee edx=0x10 PROFILE_EVENT -> 0x1470eb1a0
|
||
0x14710e009 edx=0x11 PRESENCE_EVENT -> 0x1470eb240
|
||
0x14710e024 edx=0x12 FRIENDS_EVENT -> 0x1470eb2e0
|
||
0x14710e03f edx=0x13 COMMERCE_EVENT -> 0x1470eb380
|
||
0x14710e05a edx=0x15 DOWNLOAD_EVENT -> 0x1470eb420
|
||
0x14710e075 edx=0x19 BLOCKED_USER_EVENT -> 0x1470eb4c0
|
||
0x14710e090 edx=0x14 CHAT_EVENT -> 0x1470ebfc0
|
||
0x14710e0ab edx=0x1b ONLINE_STATUS_EVENT -> 0x1470eb560 << ours
|
||
0x14710e0c6 edx=0x1d ACHIEVEMENT_EVENT -> 0x1470eb600
|
||
0x14710e0e1 edx=0x0f INVITE_EVENT (2nd) -> 0x1470ebf20
|
||
… 0x1e BROADCAST_EVENT, 0x20 PROGRESSIVE_INSTALLATION_EVENT, plus repeats of 0x11/0x13
|
||
```
|
||
|
||
**This corrects report 1's open question #5: `LOGIN_EVENT` (14) and `ONLINE_STATUS_EVENT` (27)
|
||
handlers ARE registered here** — they exist and are listening. They just expect `sender == ""` right
|
||
now, while we push `sender="LOGIN_EVENT"` / `"LOGIN"` / `"ONLINE_STATUS_EVENT"`. All 3219 pushed
|
||
`<Event>` frames in `/tmp/lsx.log` have been silently dropped.
|
||
|
||
### 1.6 The connect story, one paragraph
|
||
|
||
There is no separate "connect" handshake to serve. `Initialize` connects the socket (already alive,
|
||
`[sdk+0x1b8]=0x1fc`), passes the version gate (`[sdk+0x360]="10,4,13,6637"`), does GetConfig (accepted,
|
||
table sized to 34 but left empty), registers ~19 event handlers keyed on the now-empty names, and then
|
||
calls `GetProfileSync`. **Our `GetProfileResponse` is thrown away for a `sender` mismatch**, so
|
||
`+0x3a0`/`+0x3a8` stay 0, `OriginGetDefaultUser (0x1470da6d0)` keeps returning NULL,
|
||
`OriginRequestAuthCodeSync (0x1470e67f0)` keeps failing its `test rdx,rdx` / `cmp rdx,[rcx+0x3a0]`
|
||
guards with `0xa2000003`, and FIFA's OSDK classifier turns that into `OSDK_INVALID_USER`.
|
||
|
||
---
|
||
|
||
## 2. THE BLAZE-LOGIN TRIGGER
|
||
|
||
### 2.1 `login` is compiled in and is exactly `1/0x0A` — it is simply never invoked
|
||
|
||
* Stub `0x146e15070` ends `mov r9d,0xa; movzx r8d,WORD PTR [rdi+0x10]; call 0x146df0e80` (generic
|
||
`sendRequest`). Live component id: `WORD[0x7c17170+0x10] = 0x0001` = Authentication. **Our
|
||
responder's `1/0x0A = login` mapping is correct.**
|
||
* Its only caller `0x146e12d83` sits in `0x146e12d20`, which appears at **vtable slot +0xd0** of all
|
||
four LoginState vtables (`0x14389f828/…938/…a70/…b98`).
|
||
* Live: all four `LoginManagerImpl` (vptr `0x14389f5a0`) are still in **`LoginStateInit`**
|
||
(`stateMachine+0x28 == +0x08 == stateInit`, `+0x30 stateId = 0xFFFFFFFF`). The state machine has
|
||
never been asked to advance.
|
||
* `Authentication::logout (1/0x46)` comes from `LoginStateBase::logout` = vt+0x48 (`0x146e15eb0` →
|
||
RPC stub `0x146e109d0`). It is a **normal step** of FIFA's OSDK sequence
|
||
(Connect → LoadConfig → **Logout** → VersionCheck → Login) and matches our wire log exactly
|
||
(preAuth → 6× fetchClientConfig → logout, 37 occurrences in `/tmp/blaze_responder.log`).
|
||
**It is not an error signal and our empty REPLY to it is correct.**
|
||
|
||
### 2.2 The precondition FIFA checks — it is an Origin check, not a Blaze one
|
||
|
||
FIFA's own OSDK login state (`0x43d189d8`, vptr `0x14395c180`, Update `0x1471b58e0`, jump table
|
||
`0x141e7f55c`):
|
||
|
||
* case 0 → `mov rcx,[0x144b86bf0]; call [rax+0x60]` — live `vt[0x60] = 0x146f82070 = xor eax,eax; ret`,
|
||
so it always falls to `0x1471b5b42`, sets `TXT_NOT_LOGIN_TO_EBISU@0x1439633e8`, `[state+0x260]=1`,
|
||
and calls the classifier `0x14717d5d0`.
|
||
* Classifier `0x14717d5d0`: `OriginGetGameInfoSync(0)` must be rc==0 and `"true"` (it is — see §1.4),
|
||
then reads the Ebisu/Origin manager's last error via `[rsi]->vt[0x80]()` and
|
||
`cmp eax,0xa2000003; jne`. On match → `[state+0x80] = "OSDK_INVALID_USER"@0x14395ca10`,
|
||
`call 0x147190d50(state, 0xe)`.
|
||
* **Live right now: `[0x43d189d8+0x260] = 16`, `[+0x80] = "OSDK_INVALID_USER"`.**
|
||
|
||
`0xa2000003` is precisely `OriginRequestAuthCodeSync`'s "NULL/invalid user" return —
|
||
`OriginSDK+0x3a0 == 0`. The dword `0xa2000003` is resident at `0x43d320f8`, adjacent to the state heap.
|
||
|
||
### 2.3 `fetchClientConfig` is a dead end for this gate — do not touch it
|
||
|
||
* `[cfg+0x750]` is **live non-NULL** (`0x43c47ff0`), holding the Blaze ConnectionManager whose string
|
||
map at `+0x1218` (16 entries, 0x30 bytes each, begin `0x43f56450`) is **byte-for-byte our
|
||
`Util::preAuth` CONF** — `blazeSdkClientId=FIFA17PC`, `blazeServerClientId=FIFA17PC-SERVER`,
|
||
`blazeSdkClientSecret=openfut-secret`, `identityRedirectUri=http://127.0.0.1/login_successful.html`,
|
||
etc. `NUCLEUS_ADDED_URL`/`NUCLEUS_CREATE_URL` (which we only send via
|
||
`fetchClientConfig(CFID=OSDK_NUCLEUS)`) are **absent**, proving the two containers are different.
|
||
* The getter the Blaze auth fetchers use is `vt[0x48] = 0x146e1bc50`, a binary search over that same
|
||
`[this+0x1218]` map; the keys it asks for are `"blazeServerClientId"@0x143972690`,
|
||
`"blazeSdkClientId"@0x1439726a8`, `"blazeSdkClientSecret"@0x1439726c0`,
|
||
`"identityRedirectUri"@0x1439726d8` — all present.
|
||
* The class that owns `blaze_authfetch1/2` (vtable base **`0x143972480`**, not `0x143972560`) has
|
||
**zero live instances** (full-memory scan for the vptr: 0 hits, while the same scanner finds exactly
|
||
1 hit for the config vtable `0x1438a0850`). That code never runs. Even if it did, it would bail on
|
||
`call 0x1470da6d0` (GetDefaultUser) → `0x1470db3c0` → `0xa2000003`, storing it at `[this+0x950]`.
|
||
|
||
**Conclusion: the Blaze-login trigger is `OriginSDK+0x3a0 != 0`.** Once GetProfile validates, the
|
||
Origin default user exists, `OriginRequestAuthCodeSync` succeeds, the classifier stops writing
|
||
`OSDK_INVALID_USER`, the OSDK machine advances past sub-state 16, and LoginState vt+0xd0
|
||
(`0x146e12d20`) fires `Authentication::login (1/0x0A)` with no Blaze-side change at all.
|
||
|
||
---
|
||
|
||
## 3. CONCRETE SERVER-SIDE CHANGES (ranked, minimal)
|
||
|
||
### RANK 1 — `lsx_responder_v2.py`: echo the request's `recipient` back as the response's `sender` ★ do this first
|
||
|
||
This is the whole fix, and it is correct for both the current empty-table state **and** any future
|
||
populated table.
|
||
|
||
**Edits (line numbers as of today's file):**
|
||
|
||
1. **L418** — widen the request regex so we capture `recipient`:
|
||
```python
|
||
REQ_RE = re.compile(
|
||
r'<Request[^>]*\brecipient="([^"]*)"[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
|
||
```
|
||
(`recipient` precedes `id` in every captured frame — ids 1..14 all have
|
||
`<Request recipient="…" id="N">`. If you prefer robustness, keep the old `REQ_RE` and add a
|
||
separate `RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"')`, defaulting to `""`.)
|
||
|
||
2. **L475-481** — thread it through:
|
||
```python
|
||
recip, mid, name, rest = mm.group(1), mm.group(2), mm.group(3), mm.group(4)
|
||
attrs = dict(ATTR_RE.findall(rest))
|
||
reply = build_reply(mid, name, attrs, conn, recip)
|
||
```
|
||
|
||
3. **L312 / L316** — make it the default sender:
|
||
```python
|
||
def resp(mid, body, sender=""): # unchanged
|
||
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
|
||
|
||
def build_reply(mid, req_name, attrs, conn, recipient=""):
|
||
... # pass sender=recipient at every return site
|
||
```
|
||
|
||
4. **DELETE the three `sender="EbisuSDK"` overrides** — **L352** (`GetAuthCode`), **L358**
|
||
(`QueryEntitlements`), **L371** (`GetProfile`). These are the only three rejected verbs.
|
||
**L400** (`GetConfig`) may stay as-is or become `sender=recipient`; with echo it is identical,
|
||
since FIFA really does send `recipient="EbisuSDK"` there (its literal is hard-coded at
|
||
`0x1470e5a46`).
|
||
|
||
5. **Always emit the attribute, even when empty.** `0x147118a80: test rax,rax; je FAIL` rejects a
|
||
*missing* `sender`; an empty-valued one is fine (proven in §1.4). `resp()` already does this —
|
||
don't "optimise" it away.
|
||
|
||
6. **L231-232 / L235** — events: with the table empty, the handler strings are empty too, so push
|
||
`sender=""`:
|
||
```python
|
||
LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN") # "" first; the others are harmless no-ops
|
||
ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")
|
||
```
|
||
Mismatched senders are dropped silently and cost nothing, so keeping the old candidates as a hedge
|
||
is free. Optionally add `id="N"` to `<Event>` (read at `0x14710164c-72` → `handler+0x78`, optional).
|
||
|
||
**Observables (restart FIFA; boot `GetProfile` is id=3):**
|
||
|
||
| # | Signal | How to read |
|
||
|---|---|---|
|
||
| 1 | `Origin Error(a2080000)` disappears for GetProfile/GetAuthCode | `/tmp/lsx.log`, FIFA log |
|
||
| 2 | **`OriginSDK+0x3a0` and `+0x3a8` become `0x1f89493` (33068179)** | `SDK=*[0x144b7c7a0]`, read `+0x3a0` — this is *the* pass/fail bit |
|
||
| 3 | `[0x43d189d8+0x80]` stops being `"OSDK_INVALID_USER"`, `[+0x260]` leaves 16 | live read (state VA may move across restarts — re-find via vptr `0x14395c180`) |
|
||
| 4 | FIFA issues `<GetAuthCode ClientId="FIFA17PC">` **without** our forging anything | `/tmp/lsx.log` |
|
||
| 5 | **`Authentication::login (1/0x0A)`** appears | `/tmp/blaze_responder.log` (currently: 37× `logout`, 0× `login`) |
|
||
|
||
> Note: `[SDK+0x1e8]` (= `conn+0x80`, live `-1`) is **not** "service 0x1e8" and is *not* the success
|
||
> signal BRIEF3 asked for — there is no service to become non-null. Use signal #2 instead. Whether
|
||
> `+0x1e8` flips as a side effect is a bonus observation (see §5).
|
||
|
||
---
|
||
|
||
### RANK 2 — `lsx_responder_v2.py`: make `GetConfigResponse` actually populate the service-name table (EA-faithful; do AFTER rank 1 confirms)
|
||
|
||
Not required once rank 1 is in (echo is correct for empty names), but it restores the real protocol,
|
||
gives events non-empty senders, and de-risks any code path that dislikes empty names.
|
||
|
||
Consumer chain, all verified: matcher `0x1470e304f` checks child `"GetConfigResponse"@0x143937b30` →
|
||
deserializer `0x147135610` builds the child element name as prefix + `"Service"@0x14394dd78` (len 7;
|
||
namespaced variant `":Service"@0x14394e1a8` with ns prefix `"lsx"@0x14394def0`) and loops
|
||
`0x14713af50` per element → reads `"Name"@0x1435604fc` into `entry+0x00` and `"Facility"@0x14394e198`
|
||
via `0x1471400a0`→`0x147140010`, which **linearly strcmps the value against the 34-pointer table at
|
||
`0x144341420`** and writes the matched index to `entry+0x20` (init 0). Then `0x1470e0520` assigns
|
||
`names[entry.index] = entry.name`, skipping `index > 0x21` and empty names.
|
||
Records are 40 bytes (`{std::string name; int32 index;}`), which is why `0x1470e0520` divides by 0x28.
|
||
|
||
**Facility enum strings — read live from `0x144341420` this session (case-exact, required):**
|
||
|
||
```
|
||
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
|
||
```
|
||
|
||
Reply (keep the envelope `sender` = echo of `recipient`, i.e. `"EbisuSDK"`):
|
||
|
||
```xml
|
||
<LSX><Response id="{id}" sender="EbisuSDK"><GetConfigResponse>
|
||
<Service Name="SDK" Facility="SDK"/>
|
||
<Service Name="PROFILE" Facility="PROFILE"/>
|
||
...one line per facility, Name == the Facility enum name...
|
||
<Service Name="CONTENT" Facility="CONTENT"/>
|
||
</GetConfigResponse></Response></LSX>
|
||
```
|
||
|
||
Choosing **`Name == Facility`** is deliberate: with rank 1's echo, every response stays correct
|
||
automatically, and the pushed events' *original* senders (`"LOGIN_EVENT"`, `"ONLINE_STATUS_EVENT"`)
|
||
become correct for the first time. `Name` is free-form as far as anything we found goes (we own both
|
||
ends), but see §5.
|
||
|
||
**Ordering hazard (important):** populating the table while any verb still hard-codes
|
||
`sender="EbisuSDK"` — or while events still push `""` — breaks verbs that work today.
|
||
**Rank 1 must land first (or in the same commit).** Once echo is in place, rank 2 is a pure superset.
|
||
|
||
**Observable:** all 34 `std::string`s at `[SDK+0x3b0]` become non-empty (script in §5), and the
|
||
`<Event sender="LOGIN_EVENT">` push stops being dropped.
|
||
|
||
---
|
||
|
||
### RANK 3 — `blaze_responder_v3b.py`: change nothing that matters now; pre-stage the post-login RPCs
|
||
|
||
* **DO NOT** enlarge `fetchClientConfig` hoping to fill `[cfg+0x750]` — it is already `0x43c47ff0`
|
||
and is fed by `preAuth`. Keep `blazeSdkClientId` / `blazeServerClientId` / `blazeSdkClientSecret` /
|
||
`identityRedirectUri` **exactly where they are, in `blazesdk_config()` (preAuth CONF)**; moving them
|
||
would break getter `0x146e1bc50`.
|
||
* Keep the empty REPLY to `Authentication::logout (1/0x46)` — normal, not a failure.
|
||
**Fix the misleading log line (~L5935/6777/6947 pattern, emitted around `blaze_responder_v3b.py:1091`):**
|
||
it currently blames "layer 1 or blazeSdkClientId". Replace with: *"logout is a normal OSDK step; the
|
||
login gate is `OriginSDK+0x3a0 != 0` (LSX GetProfile), not Blaze config."*
|
||
* **Pre-stage Authentication handlers** for the stubs actually compiled into this client (found by
|
||
enumerating all 188 rel32 callers of `sendRequest 0x146df0e80` and reading the preceding
|
||
`mov r9d,imm32`): `0x0A` login, `0x14`, `0x1E`, `0x26`, `0x2D`, `0x2F`, `0x46` logout, `0xF1`,
|
||
`0xF2`, `0xF6`, `0x122`.
|
||
`0x1E` is the one that will stall you next: callers `0x146e15a6e`/`0x146e15cbe` (vtable
|
||
`0x14389fb98` slots +0xe8/+0xe0, discriminated by a bool arg) =
|
||
`getTermsOfServiceContent` / `getPrivacyPolicyContent`; `LoginStateAuthenticated` caches the result
|
||
at `state+0x68/+0x78` (strings `"LoginStateAuthenticated::mTermsOfServiceBuffer"@0x14389fe98`,
|
||
`"…mPrivacypolicyBuffer"@0x14389fec8`) and will hang on an empty body. Serve non-empty text.
|
||
* Expect `login`'s `LoginRequest.AUTH` to carry the auth code we hand out at
|
||
`lsx_responder_v2.py:352` (`OPENFUT_AUTHCODE`, default `OPENFUT-000000000000000000000000`).
|
||
|
||
---
|
||
|
||
## 4. MEMORY-FORGE SHORTCUT (fallback only — server-side is strictly better)
|
||
|
||
If rank 1 somehow does not take, you can force the same state. Note `+0x3a0` is a **u64 scalar**
|
||
(UserId), not a pointer — ENQUEUE_PLAN's "point it at the SDK object" advice works only because the
|
||
guards are `!=0` and `==[sdk+0x3a0]`; the *correct* value is our UserId.
|
||
|
||
```python
|
||
# force_defaultuser.py (ptrace_scope=0)
|
||
import struct
|
||
pid = 13643 # pgrep -x FIFA17.exe
|
||
f = open(f"/proc/{pid}/mem","r+b")
|
||
def q(va): f.seek(va); return struct.unpack('<Q', f.read(8))[0]
|
||
sdk = q(0x144b7c7a0) # live 0x25c98c50
|
||
f.seek(sdk+0x3a0); f.write(struct.pack('<Q', 33068179)) # UserId 0x1f89493
|
||
f.seek(sdk+0x3a8); f.write(struct.pack('<Q', 33068179)) # PersonaId 0x1f89493
|
||
```
|
||
|
||
Raw bytes: at `SDK+0x3a0` write `93 94 f8 01 00 00 00 00`, and the same eight bytes at `SDK+0x3a8`.
|
||
|
||
This alone satisfies `OriginGetDefaultUser (0x1470da6d0)` and both guards in
|
||
`OriginRequestAuthCodeSync (0x1470e67f0)` (`test rdx,rdx` / `cmp rdx,[rcx+0x3a0]`). It does **not**
|
||
fix the response matcher, so the resulting `<GetAuthCode>` reply would still be rejected with
|
||
`0xa2080000` unless rank 1 is also in — i.e. **the forge is only useful as a same-run A/B control**
|
||
("does `+0x3a0 != 0` really clear `OSDK_INVALID_USER`?"), not as a shipping path.
|
||
|
||
Explicitly **not recommended any more**: forging the `FirstPartyAuthCodeFutureImpl` node
|
||
(ENQUEUE_PLAN §1/§3a). With the matcher fixed, FIFA enqueues it itself, and the forged-node route
|
||
leaves the OSDK state machine untouched — which is why the earlier forge produced a `<GetAuthCode>`
|
||
on the wire but never a Blaze `login`.
|
||
|
||
---
|
||
|
||
## 5. STILL NEEDS A LIVE EXPERIMENT
|
||
|
||
1. **The whole of §3 rank 1 needs a FIFA restart.** `GetProfile` has not recurred since log id=18
|
||
(line 1068 of 3385) and `+0x3a0` is still 0 in pid 13643 — the running instance will **not**
|
||
self-heal. `Initialize` runs once.
|
||
2. **`Name` semantics in `<Service>`.** Nothing in `0x1470d0000-0x147160000` compares a service *Name*
|
||
against a literal, but the search was not exhaustive. If some path does, `Name == Facility-enum-name`
|
||
would break and real Origin-client Names would be needed. Cheap probe: rank 2 with `Name==Facility`;
|
||
if a verb that worked under rank 1 starts failing, that is the culprit.
|
||
3. **Facility index for `GetInternetConnectedState`, `SetDownloaderUtilization`,
|
||
`IsProgressiveInstallationAvailable`** is inferred (9 / 31 / 31). Echo-back makes this moot for
|
||
rank 1; it only matters if you ever hard-code senders again. Confirm by reading the
|
||
`add rdx,0xNN` after `mov rdx,[sdk+0x3b0]` at `0x1470e040a`, `0x14712b821`, `0x147129ddb`.
|
||
4. **`[SDK+0x1e8]` (= `conn+0x80`, live `-1`)** — a second connection handle, read by `0x14712cc00`
|
||
and waited on by `0x1470e6b70` (condvar `[sdk+0xf8]`, alt-wake `[sdk+0x240]`, both live 0). Its
|
||
*writer* is untraced; the conn object at `SDK+0x168` has vptr `0x14394c218` with 2 slots
|
||
(`0x14712c9b0`, `0x14712ce00`) — disassemble those. Watch whether it flips after rank 1; if it
|
||
stays `-1` and something blocks on `0x1470e6b70`, that is the next gate.
|
||
Also unidentified: `0x14712cc30(SDK+0x168, &outString)`, called after that wait, whose result is
|
||
inserted into the map at `SDK+0x1f8` (size `[SDK+0x218]`, live 0).
|
||
5. **Which of `UserId` / `PersonaId` lands in `+0x3a0` vs `+0x3a8`** is unobservable while we send the
|
||
same number for both. Make them differ once, then read both slots.
|
||
6. **Does the fixed chain reach Blaze in one go**, or is there a further OSDK sub-state after 16?
|
||
Watch `[0x43d189d8+0x260]` (re-find via vptr `0x14395c180`) walk off 16 and
|
||
`LoginManagerImpl+0x148 → stateMachine+0x30` leave `0xFFFFFFFF`.
|
||
7. **After `login` fires**, `Authentication 0x1E` (ToS / privacy content) is the next likely stall —
|
||
see rank 3.
|
||
8. **Post-fix event senders.** Confirm the `LOGIN_EVENT`/`ONLINE_STATUS_EVENT` handlers stop dropping
|
||
frames: under rank 1 push `sender=""`; under rank 2 push `sender="LOGIN_EVENT"`. Note the earlier
|
||
claim that `0xa2080000` in the log means failure is **weak** for events — with 73 EventHandler
|
||
template instantiations, non-matching handlers log it as ordinary fan-out noise. Use
|
||
`OriginMgr(*[0x1448acf50])+0x13` (`m_isLoggedIn`) flipping to 1 *by itself* as the event observable.
|
||
|
||
### Live verification snippet (run before and after)
|
||
|
||
```python
|
||
#!/usr/bin/env python3
|
||
import struct, glob
|
||
pid = next(int(d.split('/')[-1]) for d in glob.glob('/proc/[0-9]*')
|
||
if open(d+'/comm').read().strip() == 'FIFA17.exe')
|
||
f = open(f"/proc/{pid}/mem", "rb")
|
||
def rd(va,n): f.seek(va); return f.read(n)
|
||
def q(va): return struct.unpack('<Q', rd(va,8))[0]
|
||
sdk = q(0x144b7c7a0)
|
||
print(f"SDK={sdk:#x} +0x3a0={q(sdk+0x3a0):#x} +0x3a8={q(sdk+0x3a8):#x} "
|
||
f"+0x1e8={q(sdk+0x1e8):#x} +0x270={q(sdk+0x270):#x}")
|
||
b, e = q(sdk+0x3b0), q(sdk+0x3b8)
|
||
for i in range((e-b)//0x20):
|
||
s = rd(b+i*0x20, 0x20)
|
||
n, cap = struct.unpack('<Q', s[0x10:0x18])[0], struct.unpack('<Q', s[0x18:0x20])[0]
|
||
if n:
|
||
print(i, (rd(struct.unpack('<Q', s[:8])[0], n) if cap >= 16 else s[:n]).decode())
|
||
```
|
||
|
||
Baseline recorded today (pid 13643): `+0x3a0=0x0 +0x3a8=0x0 +0x1e8=0xffffffffffffffff +0x270=0x1c`,
|
||
all 34 names empty, `[0x43d189d8+0x80]="OSDK_INVALID_USER"`, `[+0x260]=16`,
|
||
`/tmp/blaze_responder.log`: 37× `logout`, 0× `login`.
|