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
418 lines
23 KiB
Markdown
418 lines
23 KiB
Markdown
# UNDERAGE_PLAN — FIFA17 `OSDK_UNDERAGE_ERROR` : source, fix, and the live experiment
|
||
|
||
Clean-room synthesis of four independent reverses, **re-verified end-to-end by me against live
|
||
`pid 39211` (`/proc/39211/mem`)** on 2026-07-31 ~16:10. Every VA/byte below is either read out of that
|
||
process this session or quoted from our own `/tmp/lsx.log` / `/tmp/blaze_responder.log`.
|
||
No leaked EA source or headers were consulted.
|
||
|
||
---
|
||
|
||
## 0. HEADLINE (one paragraph)
|
||
|
||
**`OSDK_UNDERAGE_ERROR` is not an age check, and Origin error `0xa2000012` was never produced.**
|
||
The label is a *mislabelled catch-all* on the OSDK login classifier `0x14717d5d0`, reachable from
|
||
**three** conditions, only one of which is the `0xa2000012` compare. We are on a different arm:
|
||
**the Origin auth-code string is NULL.** It is NULL because `lsx::AuthCodeT`'s deserializer
|
||
(`0x1471312a0`) reads exactly one attribute — lowercase **`value`** (`0x1436c7768`) — while
|
||
`lsx_responder_v2.py` was answering `<AuthCode Code="…" Return="…"/>`. The parse "succeeds" with a
|
||
zero-length string, `OriginRequestAuthCodeSync` returns **0 with `out_len == 0`**, the Ebisu manager
|
||
refuses to cache a zero-length code, and the classifier falls into the underage arm.
|
||
**Fix = one attribute name.** It is already applied (`lsx_responder_v2.py:385`); it needs a FIFA
|
||
relaunch to observe, because the OSDK state machine is latched.
|
||
|
||
---
|
||
|
||
## 1. THE SOURCE — end to end, tied to an input we control
|
||
|
||
### 1.1 The classifier and its three arms (byte-exact, read live)
|
||
|
||
`0x14717d5d0` is the OSDK login-state classifier. Live bytes at `0x14717d6e8` (read from
|
||
`/proc/39211/mem` this session):
|
||
|
||
```
|
||
488b06 4889f1 ff9080000000 3d120000a2 7434 4885ed 742f 807d0000 7429 488b0deb94a0fd ...
|
||
```
|
||
|
||
decoded:
|
||
|
||
```
|
||
14717d6e8 48 8b 06 mov rax,[rsi] ; rsi = the 'ebmg' sub-object
|
||
14717d6eb 48 89 f1 mov rcx,rsi
|
||
14717d6ee ff 90 80 00 00 00 call [rax+0x80] ; -> 0x147237430 = GetLastError()
|
||
14717d6f4 3d 12 00 00 a2 cmp eax,0xa2000012 ; ARM 1 -- the "age" compare
|
||
14717d6f9 74 34 je 0x14717d72f
|
||
14717d6fb 48 85 ed test rbp,rbp ; ARM 2 -- auth-code ptr == NULL <<< WE ARE HERE
|
||
14717d6fe 74 2f je 0x14717d72f
|
||
14717d700 80 7d 00 00 cmp BYTE PTR [rbp+0x0],0 ; ARM 3 -- auth code == ""
|
||
14717d704 74 29 je 0x14717d72f
|
||
--- SUCCESS PATH ---
|
||
14717d706 48 8b 0d eb 94 a0 fd mov rcx,[rip+…] ; # 0x144b86bf8 (the Blaze manager)
|
||
14717d70d 48 8b 01 mov rax,[rcx]
|
||
14717d710 ff 90 88 01 00 00 call [rax+0x188]
|
||
14717d716 31 d2 xor edx,edx
|
||
14717d718 48 89 c1 mov rcx,rax
|
||
14717d71b e8 e0 7a c3 ff call 0x146db5200
|
||
14717d720 48 89 ea mov rdx,rbp ; <-- HANDS THE AUTH CODE ON
|
||
14717d723 4c 8b 00 mov r8,[rax]
|
||
14717d726 48 89 c1 mov rcx,rax
|
||
14717d729 41 ff 50 30 call [r8+0x30] ; subsys->vt[0x30](authcode)
|
||
14717d72d eb 9b jmp …
|
||
--- THE LABEL ---
|
||
14717d72f 48 8d 05 7a f2 7d fc lea rax,[rip+…] ; # 0x14395c9b0 = "OSDK_UNDERAGE_ERROR"
|
||
mov [rdi+0x80],rax ; lea edx,[r8+0xa] ; state 10
|
||
```
|
||
|
||
All three `je` displacements land on `0x14717d72f` (verified: `0x14717d6fb+0x34`, `0x14717d700+0x2f`,
|
||
`0x14717d706+0x29`). `rbp` is loaded at `0x14717d661` from `call [rdx+0x68]`.
|
||
|
||
Live string read: `0x14395c9b0 = b'OSDK_UNDERAGE_ERROR'`.
|
||
|
||
### 1.2 The sub-object: `mgr->vt[0x60]` is a FourCC hashmap `find`, not a getter
|
||
|
||
`0x14719b1b0` is `mov rcx,[rcx+0x358]; jmp <open-hash find>`. The classifier at `0x14717d5f3` passes
|
||
`mov edx,0x90c3a20f; lea edx,[rdx-0x2b6134a8]` = **`0x65626d67` = `'ebmg'`** (the Ebisu/Blaze-auth
|
||
component; the sibling site `0x14717d7b0` uses `0x636e6e63` = `'cnnc'`, the connMgr).
|
||
|
||
Live walk I performed on pid 39211:
|
||
|
||
```
|
||
mgr = *[0x144b86bf8] = 0x43c46c70 (vptr 0x143959168)
|
||
map = [mgr+0x358] = 0x43d15f38
|
||
buckets= [map+0x50] = 0x43d15fd0 , nbuckets = [map+0x58] = 193
|
||
0x65626d67 % 193 -> node 0x43d16880 -> value
|
||
ebmg = 0x43d317a8 (vptr 0x143972550)
|
||
vt[0x60] = 0x147237c30
|
||
vt[0x68] = 0x147237350 <- GetAuthCode(blazeServerClientId)
|
||
vt[0x80] = 0x147237430 <- GetLastError
|
||
```
|
||
|
||
`0x147237430` live bytes = `8b 81 50 09 00 00 c3` = **`mov eax,[rcx+0x950]; ret`**.
|
||
So the classifier's error input is literally the field **`ebmg+0x950`**, and `rbp` is **`ebmg+0x948`**.
|
||
|
||
### 1.3 The writer of `ebmg+0x950` — full disassembly of `vt[0x68]` (live)
|
||
|
||
```
|
||
147237350 push/sub…; xor edi,edi ; rdi = 0
|
||
14723735f mov [rsp+0x48],rdi ; out_ptr = 0
|
||
147237364 mov [rsp+0x50],rdi ; out_len = 0
|
||
147237369 call 0x1471995b0 ; = mov rax,[0x144b86bf8]
|
||
147237374 call [rdx+0x188] ; = mov rax,[rcx+0x360] (the CONF holder)
|
||
14723737a mov rcx,[rax+0x750]
|
||
147237381 test rcx,rcx
|
||
147237384 je 0x1472373e0 ; EARLY-OUT -- does NOT touch +0x950
|
||
14723738e lea rdx,[rip+…] # 0x143972690 = "blazeServerClientId"
|
||
14723739a call [rax+0x48] ; cfg->GetString(key)
|
||
14723739d call 0x1470da6d0 ; OriginGetDefaultUser
|
||
1472373b9 call 0x1470db3c0 ; OriginRequestAuthCodeSync
|
||
1472373be mov DWORD PTR [rbx+0x950],eax ; *** THE ERROR WRITE ***
|
||
1472373c4 test eax,eax ; jne 0x1472373e0 ; err != 0 -> no cache
|
||
1472373c8 mov rax,[rsp+0x48] ; test rax,rax ; je ; out_ptr == 0 -> no cache
|
||
1472373d2 cmp QWORD PTR [rsp+0x50],rdi ; je ; out_len == 0 -> no cache <<< WE DIE HERE
|
||
1472373d9 mov QWORD PTR [rbx+0x948],rax ; cache the code
|
||
1472373e0 mov rax,[rbx+0x948] ; ret ; returns rbp for the classifier
|
||
```
|
||
|
||
`0x1470db3c0`'s logger string is `0x143936180 = "OriginRequestAuthCodeSync"`; its body is
|
||
`call 0x1470e2840` (SDK-ready gate) → `call 0x1470e67f0` (the impl).
|
||
|
||
The sibling `0x147237440` is identical but keys on `0x1439726a8 = "blazeSdkClientId"` and caches into
|
||
the `char[0x400]` at `ebmg+0x140`, writing the same error slot at `0x1472374c5`.
|
||
|
||
**Only three sites in the entire image write `+0x950`:** the ctor `0x147236d1b`
|
||
(`mov DWORD PTR [r14+0x950],ebp`, adjacent to `mov [r14+0x948],rbp` at `0x147236d14`; `rbp` is the
|
||
ctor's zero register — it is also written into the bool at `+0x110` via `mov [r14+0x110],bpl`), and
|
||
the two `mov [..+0x950],eax` immediately after `call 0x1470db3c0`.
|
||
|
||
**Therefore `ebmg+0x950` can hold nothing but `OriginRequestAuthCodeSync`'s return value.**
|
||
That function's reachable returns are `0`, `0xa2000004` (INVALID_ARGUMENT), `0xa2000003`
|
||
(INVALID_USER, the gate we already cleared), or the LSXRequest's own error field via
|
||
`call [rax+0x40]`. `0xa2000012` is not among them.
|
||
|
||
### 1.4 `0xa2000012` is a decoder-table constant — it is not, and cannot be, our value
|
||
|
||
Live reads:
|
||
|
||
```
|
||
codes[] @ 0x144340ef0 (uint32[81]) ; codes[21] @ 0x144340f44 = 0xa2000012
|
||
strings[] @ 0x144340750 (const char*[81][3], stride 0x18)
|
||
strings[21] @ 0x144340948 = { "ORIGIN_ERROR_AGE_RESTRICTED",
|
||
"ORIGIN_LEVEL_2",
|
||
"The item has age restrictions." }
|
||
```
|
||
|
||
Sanity anchors from the same arrays: `codes[6]=0xa2000003 -> ORIGIN_ERROR_INVALID_USER`,
|
||
`codes[7]=0xa2000004 -> ORIGIN_ERROR_INVALID_ARGUMENT` — matching the classifier and `0x1470e67f0`.
|
||
|
||
The **only** reader is `0x14712c850`, a linear search **by value** (`cmp rdx,0x51`) returning
|
||
`&strings[3*i]`, called from `0x1470dbe00` (`ErrorCodeToDescription`, returns `triple[2]`) — a
|
||
**logging decoder**. A RIP-relative xref scan over `0x145000000-0x14a000000` for the table region
|
||
returned exactly two operand refs, both inside `0x14712c850`. Nothing indexes the table to *produce*
|
||
a code, and BRIEF4's arithmetic scan already found 0 construction sites.
|
||
|
||
Full-address-space scan for the dword `12 00 00 a2`: 4 hits — the table entry `0x144340f44`, the
|
||
classifier's `cmp` immediate at `0x14717d6f5`, and two unaligned hits in compressed asset data.
|
||
|
||
**The genuine underage error is a different code entirely:** `codes[57] @ 0x144340fb8 = 0xa2060005 =
|
||
`ORIGIN_ERROR_COMMERCE_UNDERAGE_USER`. The classifier never references it.
|
||
|
||
### 1.5 LIVE PROOF of which arm fired
|
||
|
||
```
|
||
[0x43d317a8+0x140] = 40 bytes of 0x00 (blazeSdkClientId cache: empty)
|
||
[0x43d317a8+0x940] = 0x1
|
||
[0x43d317a8+0x948] = 0x0 ; <-- NULL auth code => ARM 2
|
||
[0x43d317a8+0x950] = 0xdeadbeef ; <-- NOT 0xa2000012
|
||
|
||
osdk state obj 0x43d189d8 (vptr 0x14395c180):
|
||
+0x80 -> 0x14395c9b0 = "OSDK_UNDERAGE_ERROR"
|
||
+0x7c = 10
|
||
+0x260 = 16 (0x10 = the latch)
|
||
```
|
||
|
||
**Adjudication of the reports' one disagreement.** Two reverses read `+0x950 == 0` (~15:52), two read
|
||
`0xdeadbeef` (~15:58, and I confirm `0xdeadbeef` now). One report concluded from `0xdeadbeef` that
|
||
`vt[0x68]` **never ran** (early-return on `[cfg+0x750] == NULL`) and proposed an *ordering* fix.
|
||
**That is refuted by our own bytes:**
|
||
|
||
* the ctor writes **`ebp` (= 0)**, not `0xdeadbeef`, to `+0x950` (`0x147236d1b`);
|
||
* the early-out at `0x147237384` jumps to `0x1472373e0` and **never touches `+0x950`**;
|
||
* so `0xdeadbeef` can only have arrived through `mov [rbx+0x950],eax` after `call 0x1470db3c0`
|
||
— i.e. `ebmg::GetAuthCode` **did** execute past the CONF gate;
|
||
* it is not allocator poison: I dumped the full 0x1000 bytes of the object and `ef be ad de` occurs
|
||
**exactly once**, at `+0x950`, with zeros on both sides.
|
||
|
||
Ordering is fine too: `[mgr+0x360] = 0x43c47330`, `[+0x750] = 0x43c47ff0` (non-NULL, live), and
|
||
`Util::preAuth` was answered at `[15:36:36]` (`/tmp/blaze_responder.log:36234`) while the five
|
||
`GetAuthCode` requests are `/tmp/lsx.log` lines 100–120 of 127, with the log's last write at
|
||
`15:36:57`. So the auth-code fetches happened **after** the CONF map was installed.
|
||
|
||
The two `+0x950` readings tell one consistent story: **while the LSX socket was live, the fetch
|
||
returned `0` (success) with a zero-length code** (the empty-`value` signature — exactly ARM 2/3);
|
||
**after the socket went quiet, later fetches returned a garbage `0xdeadbeef`** from the failed
|
||
request object. Neither reading is `0xa2000012`.
|
||
|
||
### 1.6 THE CONDITION, tied to the input we control
|
||
|
||
Response handler for `LSXRequest<GetAuthCodeT, AuthCodeT, …>` is `0x1470e4ee0`. It sets
|
||
`[rbx+0x160] = 0` then calls the matcher `0x1470e2a70`, which checks root `"LSX"` (`0x143938024`),
|
||
attr `"id"` (`0x14355de00`), attr `"sender"` (`0x143938028`, byte-compared against the request's
|
||
recipient — the fix we already landed), then at `0x1470e2b63` matches child element
|
||
`0x143937ae0 = "AuthCode"` and tail-jumps `0x14712fac0 -> 0x1471312a0` = the `lsx::AuthCodeT`
|
||
deserializer.
|
||
|
||
Live disassembly of `0x1471312a0`'s attribute build/read (read this session):
|
||
|
||
```
|
||
14713131f … build ns prefix for "lsx" (0x14394def0) via 0x14713f940, optional ":" (0x143559ab0)
|
||
14713133d 4c 8d 05 24 64 59 fc lea r8,[rip+0xfc596424] # 0x1436c7768 <-- live cstr = b"value"
|
||
14713134c e8 df bd ff ff call 0x14712d130 ; concat -> attribute name
|
||
…
|
||
1471313e1 e8 6a ea 00 00 call 0x14713fe50 ; get-attribute-as-string (ONE call, only call)
|
||
…
|
||
14713141b b0 01 mov al,0x1 ; *** ALWAYS RETURNS SUCCESS ***
|
||
```
|
||
|
||
And the getter itself tolerates a missing attribute:
|
||
|
||
```
|
||
14713fe50 call [rax+0x50](name) ; attribute present?
|
||
14713fe68 je 0x14713febf ; -> `xor al,al; ret` -- dest left EMPTY, no error propagated
|
||
```
|
||
|
||
So a reply without a `value` attribute parses as **success with an empty `std::string`**.
|
||
That string is `LSXRequest+0xb8`, size at `+0xc8` — precisely what the impl reads back:
|
||
|
||
```
|
||
1470e6924 mov rbx,[rdi+0xc8] ; *out_len <- 0
|
||
1470e6965 mov [r15],rax ; *out_ptr <- non-NULL 1-byte alloc
|
||
1470e6968 mov [r12],rbx ; *out_len <- 0
|
||
returns 0 (SUCCESS)
|
||
```
|
||
|
||
`out_len == 0` → `0x1472373d7 je` → `+0x948` never written → `rbp == NULL` → **ARM 2** → state 10.
|
||
|
||
**What we actually sent this boot** (`/tmp/lsx.log`, ids 25–29, all five):
|
||
|
||
```
|
||
<LSX><Response id="26" sender=""><AuthCode Code="OPENFUT-000000000000000000000000" Return="OPENFUT-000000000000000000000000"/></Response></LSX>
|
||
```
|
||
|
||
No `value` attribute. **This is the input we control, and it is the whole cause.**
|
||
|
||
*(Why `Code=`/`Return=` were chosen: the responder's old comment cited "Return" parsers `0x1471351e0`
|
||
/ `0x147136af0`. Their real xrefs are `0x14713522f` / `0x147136b3f`, both of which clear a
|
||
`std::vector` at `[r14+0x20]` — list-response parsers, not `AuthCodeT` (a single `std::string`).)*
|
||
|
||
**Cross-validation that this machinery is understood correctly:** `GetGameInfo` uses the identical
|
||
chain (`0x1470da720` → matcher `0x1470e3090` → `0x147135800`) with attribute
|
||
`0x14394e088 = "GameInfo"` — and our `<GetGameInfoResponse GameInfo="true"/>` demonstrably passes the
|
||
classifier's `strncmp(buf,"true",8)` at `0x14717d68d` (otherwise we would be showing
|
||
`0x1439633c0 = "TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE"` from `0x14717d749`, not the underage label).
|
||
**Attribute names are per-response-type. For `AuthCode` it is `value`.**
|
||
|
||
### 1.7 What is definitively NOT involved
|
||
|
||
* **No age/DOB input anywhere.** `GetProfile`'s `IsUnderAge` is parsed correctly (`0x147136140` →
|
||
bool helper `0x14713ffa0`) and our `IsUnderAge="false"` lands as 0.
|
||
* **`QueryEntitlements` was never requested this boot** (`/tmp/lsx.log` verbs: `GetConfig`,
|
||
`GetSetting`×5, `IsProgressiveInstallationAvailable`, `GetInternetConnectedState`×2,
|
||
`GetGameInfo`×6, `GetProfile`×3, `GetAuthCode`×5, `SetPresence`×7, `SetDownloaderUtilization`).
|
||
* **Nucleus :42131 was never hit.**
|
||
* **`0xa2000012` (`ORIGIN_ERROR_AGE_RESTRICTED`) is a decoder-table string, never a produced value.**
|
||
|
||
---
|
||
|
||
## 2. THE FIX — ranked and minimal
|
||
|
||
### FIX 1 (the fix) — `lsx_responder_v2.py`, one attribute. **STATUS: APPLIED**
|
||
|
||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/lsx_responder_v2.py`, `build_reply()`,
|
||
`GetAuthCode` branch (`:344`), reply line `:385`:
|
||
|
||
```python
|
||
return resp(mid,
|
||
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
|
||
```
|
||
|
||
The load-bearing part is lowercase **`value`**; `Code=`/`Return=` are inert padding (the deserializer
|
||
makes exactly one attribute lookup and ignores everything else) and are kept only so the diff is
|
||
additive. The element name `AuthCode`, the `id` attribute and the **sender-echo**
|
||
(`sender` must byte-equal the request's `recipient` — hardcoded `strcmp` at `0x1470e2b49`) were
|
||
already correct and must not be touched.
|
||
|
||
Verified: `python3 lsx_responder_v2.py --selftest` emits
|
||
|
||
```
|
||
<LSX><Response id="42" sender=""><AuthCode value="OPENFUT-000000000000000000000000" Code="…" Return="…"/></Response></LSX>
|
||
[ok] selftest passed
|
||
```
|
||
|
||
The value only has to be **non-empty**: `0x1470e67f0` does no format validation — it `memcpy`s
|
||
`[req+0xb8]` of length `[req+0xc8]` into a fresh `len+1` allocation. Both fetchers
|
||
(`blazeServerClientId` → `+0x948`, `blazeSdkClientId` → `+0x140`) go through this same deserializer,
|
||
so one edit covers both.
|
||
|
||
### FIX 2 — `blaze_responder_v3b.py`: **NO CHANGE REQUIRED** (verified)
|
||
|
||
`Authentication::login` (1/0x0A) at `blaze_responder_v3b.py:1040`:
|
||
|
||
```python
|
||
if cmd == CMD_LOGIN:
|
||
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
||
```
|
||
|
||
It **stores and never validates** the `AUTH` string, and `get_auth_token_response_fields()` (`:906`)
|
||
echoes `sess.auth_code` back. So the `OPENFUT-0000…` placeholder is accepted as-is. Keep the LSX code
|
||
and the Blaze `AUTH` string identical (the classifier's success path hands `rbp` straight to
|
||
`subsys->vt[0x30]` at `0x14717d729`, which is what ends up in `LoginRequest.AUTH`) — that is already
|
||
guaranteed because the responder writes the code to `AUTHCODE_FILE`.
|
||
|
||
### FIX 3 (defensive, optional) — do not stop the heartbeat too early
|
||
|
||
`lsx_responder_v2.py` sets `conn.stop_events = True` on the first `GetAuthCode`. That is why the LSX
|
||
log dies at 15:36:57 while the game keeps re-entering the fetch (each later fetch now returns
|
||
`0xdeadbeef`). Harmless for the gate itself, but it removes our only wire-level visibility of the
|
||
retry. Consider gating `stop_events` on "we have seen a Blaze `Authentication::login`" instead of on
|
||
the first `GetAuthCode`.
|
||
|
||
### Success signals, in order
|
||
|
||
1. `/tmp/lsx.log` shows `>> …<AuthCode value="OPENFUT-…"…>` (the emitted frame carries `value=`).
|
||
2. `ebmg+0x948` becomes a **non-NULL pointer to ASCII**, and `ebmg+0x950` reads **0**.
|
||
Re-find `ebmg` from scratch: `mgr = *[0x144b86bf8]` → `map = [mgr+0x358]` →
|
||
`buckets = [map+0x50]`, `n = [map+0x58]` → bucket `0x65626d67 % n` → walk `[node+0x10]` for
|
||
`dword[node] == 0x65626d67` → `obj = [node+8]`; assert `[obj] == 0x143972550`.
|
||
3. The OSDK state object (re-find by vptr `0x14395c180`) leaves the label:
|
||
`+0x80` no longer points at `0x14395c9b0`, and `+0x7c != 10`.
|
||
4. `/tmp/blaze_responder.log` shows **`RX … Authentication::login`** (component `0x0001`, cmd
|
||
`0x000A`) instead of the current `Authentication::logout` (1/0x46) give-up.
|
||
5. On screen: the "not eligible … age restriction" dialog is gone.
|
||
|
||
---
|
||
|
||
## 3. Memory-forge A/B — what was tried, and the server-side equivalent
|
||
|
||
**A memory forge of the field was attempted and it did NOT confirm the fix — for a reason we fully
|
||
understand and have proven.** Report it honestly:
|
||
|
||
| A/B | What was poked | Result |
|
||
|---|---|---|
|
||
| A/B 1 | `ebmg+0x948` ← pointer to a valid 32-char C string forged into the `char[0x400]` at `ebmg+0x140` | 24 s of polling: `osdk+0x80` stayed `0x14395c9b0`, `+0x7c` stayed 10, no Blaze traffic |
|
||
| A/B 2 (tick detector) | the 4-byte RIP displacement at `0x14717d732` patched `48 8d 05 7a f2 7d fc` → `48 8d 05 da f2 7d fc` so the underage arm would store `0x14395ca10` (`"OSDK_INVALID_USER"`) instead | 10 s: `osdk+0x80` never changed ⇒ **the classifier did not execute even once** |
|
||
| A/B 3 | `ebmg+0x950` ← `0xdeadbeef` written deliberately, re-read 2 s later | still `0xdeadbeef` ⇒ nothing overwrote it ⇒ same conclusion |
|
||
|
||
Both pokes were reverted and verified byte-identical; pid 39211 is still alive and unchanged (I
|
||
re-read all of the above from it after the fact).
|
||
|
||
**Why the forge could not work:** the OSDK login status is **latched**. `0x147190d50` opens with
|
||
`movsxd rax,[rcx+0x260]; cmp eax,0x10; je <ignore>` and sets `0x10` on the first accepted write.
|
||
Live: `[0x43d189d8+0x260] = 0x10`. Once latched, the classifier is never re-invoked, so no field you
|
||
forge can be re-read. (`0x14717d5d0` also has **zero** direct `e8/e9` callers and zero qword vtable
|
||
slots — it is reached through image-relative jump-table entries at `0x143fef788 / 0x143fef79c /
|
||
0x143fef7ac`, dword `0x0717d5d0` — so we cannot cheaply hand-call it either.)
|
||
|
||
**The field itself is nonetheless confirmed by pure observation, without a forge:** `ebmg+0x948` is
|
||
NULL *and* `ebmg+0x950` has never held `0xa2000012` (it is provably restricted to
|
||
`OriginRequestAuthCodeSync`'s return set), and there is exactly **one** RIP-reference to
|
||
`0x14395c9b0` in `0x145000000-0x14a000000` — `0x14717d732`. There is no other producer of the label.
|
||
|
||
**Server-side equivalent of the forge** (this is what FIX 1 does, at boot, with no memory writes):
|
||
where the forge wrote a `char*` into `ebmg+0x948`, the responder makes the game write it itself, by
|
||
supplying a non-empty `value` attribute so that `out_len != 0` and `0x1472373d9
|
||
(mov [rbx+0x948],rax)` executes naturally. Same destination, same pointer semantics, but taken
|
||
through the real code path — which additionally sets `+0x950 = 0` and, crucially, happens **before**
|
||
the `+0x260` latch closes.
|
||
|
||
---
|
||
|
||
## 4. What still needs a live experiment
|
||
|
||
**This fix is boot-time and cannot be validated on pid 39211. FIFA 17 must be relaunched.**
|
||
Both preconditions are unrecoverable in the current process: the state latch
|
||
(`0x43d189d8+0x260 == 0x10`) and the dead LSX socket (last write `15:36:57`).
|
||
|
||
### Run procedure
|
||
|
||
1. Leave `lsx_responder_v2.py` as-is (fix already at `:385`) and `blaze_responder_v3b.py` untouched.
|
||
2. Rotate the logs (`mv /tmp/lsx.log /tmp/lsx.log.prev`, same for `/tmp/blaze_responder.log`) so the
|
||
`value=` frames and the `+0x948` transition are unambiguous. **Also keep the rotation** — see
|
||
open question O1.
|
||
3. Restart both responders, launch FIFA 17, `pgrep -x FIFA17.exe` for the new pid.
|
||
4. Watch signals 1–5 from §2 in order. Poll `ebmg` and the OSDK state object with the re-find recipes
|
||
in §2 (never reuse `0x43d317a8` / `0x43d189d8` — those are this-boot heap addresses).
|
||
|
||
### Open questions the run should settle
|
||
|
||
* **O1 — was `0xa2000012` ever genuinely observed?** We could not reproduce it live and the code
|
||
cannot produce it in this path. It may have been inferred from the `cmp` immediate rather than
|
||
read. If a rotated `/tmp/lsx.log*` or an older `+0x950` reading really shows it, then a different
|
||
run took the `0x1470e4fbf` path (`cmovne edx,[rsp+0x40]` — an integer read straight out of the
|
||
response), which would mean a stray numeric field in one of our replies. Worth one `grep` of the
|
||
older log rotations before the run.
|
||
* **O2 — does the *second* fetcher also need to succeed?** `0x147237440` (`blazeSdkClientId`,
|
||
`FIFA17PC`) caches into `ebmg+0x140` and is gated by `cmp BYTE PTR [rcx+0x140],0` (cache-once,
|
||
live value 0/empty). After the fix, confirm **both** `+0x948` (from `FIFA17PC-SERVER`) and `+0x140`
|
||
(from `FIFA17PC`) get populated — Blaze may want the server-scoped code specifically. Note the two
|
||
fetchers **share** the single error slot `+0x950`, so a failing `blazeSdkClientId` fetch can clobber
|
||
a successful `blazeServerClientId` one; if that becomes a problem, the codes may need to differ
|
||
per `ClientId` (the responder already logs `ClientId`, so this is a one-line change).
|
||
* **O3 — does Blaze `Authentication::login` accept the placeholder?** Statically it does
|
||
(`:1040` stores `AUTH` without validation), but the wall may move: watch for
|
||
`AUTH_ERR_INVALID_PERSONA (26)` / `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` /
|
||
`AUTH_ERR_PERSONA_NOT_FOUND` in the reply we build. If FIFA rejects it, the next candidate is
|
||
making the code look Nucleus-shaped (base64/JWT-ish) rather than `OPENFUT-0000…`.
|
||
* **O4 — `[OriginSDK+0x3b0]+0x120` (the expected `sender`) is an EMPTY string.**
|
||
(`OriginSDK = *[0x144b7c7a0] = 0x25c98c50`, size 0 / cap 15.) Real Origin presumably fills a
|
||
session id there during the LSX handshake. Our empty echo satisfies the `strcmp` today, but confirm
|
||
no later verb needs it non-empty.
|
||
* **O5 — audit every other LSX verb the same mechanical way.** `GetAuthCode` was attribute-*guessed*;
|
||
so were `QueryEntitlements`, `SetPresence` and `QueryUserId`. The audit is cheap and mechanical:
|
||
find the element-name string's single xref → follow the tail-jump thunk → list the
|
||
`0x14713fe50` (string) / `0x14713ffa0` (bool) attribute-name arguments. `GetGameInfo`
|
||
(`"GameInfo"`) and `GetProfile` (`UserId`, `PersonaId`, `Persona`, `AvatarId`, `Country`,
|
||
`IsUnderAge`, `IsSubscriber`, `GeoCountry`, `CommerceCountry`, `CommerceCurrency`) are already
|
||
verified correct; the rest are not.
|
||
* **O6 — who dispatches `0x14717d5d0`?** Reached only through the jump-table slots at
|
||
`0x143fef788 / 0x143fef79c / 0x143fef7ac`. Not needed for the fix, but knowing the dispatcher would
|
||
let us re-trigger classification live in a future session instead of relaunching.
|