fifa17-recon: package the working offline FUT backend

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
This commit is contained in:
funman300
2026-08-01 09:12:17 -07:00
parent 1fb664710a
commit edab23f04a
26 changed files with 7909 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Heavy / game-derived / volatile — never commit
*.asm
*.bin
*.strings
*.dll
*.exe
*.pem
*.key
*.log
__pycache__/
captures/
+67
View File
@@ -0,0 +1,67 @@
# FIFA 17 offline FUT — runbook
Brings FIFA 17 **Ultimate Team** up against a 100% offline, clean-room emulated backend
(no EA servers, no internet). Proven working end-to-end 2026-08-01 (auth → Blaze login →
device-trust → the FUT hub).
## One-command start
```bash
cd fifa17-recon/tools
./openfut-fut.sh start # arms the host + starts all 5 servers
```
`start` is idempotent and re-arms everything, so **just re-run it after a reboot**. It will pop a
graphical password prompt (via `pkexec`) the first time to arm the host, then skip it while armed.
Then, **in this order**:
1. Launch FIFA 17 **fresh** (a clean launch avoids the "FUT Squad Update"/live-DB error caused by
stale in-process state): `~/Desktop/launch-fifa17.sh`
2. In-game, select **Ultimate Team**.
3. At the **security question** ("system not trusted"): type **any answer** → Continue → OK.
(Our server accepts any answer and marks the device trusted.)
4. → the **FUT hub**.
`./openfut-fut.sh status` shows what's up; `stop` / `restart` do the obvious. The servers must be
up **before** launching FIFA — they bind the ports the game dials.
## What it stands up
| Component | Port(s) | Role |
|---|---|---|
| `lsx_responder_v2.py` | 4216 | Origin LSX (login, GetProfile, GetAuthCode, events) |
| `blaze_responder_v3b.py` | 42127 / 42130 / 42131 | Blaze redirector (TLS) / Blaze / Nucleus |
| `roster_server.py` | 8081 | FUT roster-update XML |
| `utas_server.py` | 8099 | FUT/UTAS (RS4) API: auth, device-trust, boot calls, hub |
| `autopatch.py` | — | patches FIFA17.exe's ProtoSSL cert-verify on launch |
Privileged host state (armed by `root_arm.sh` via `pkexec`): `kernel.yama.ptrace_scope=0`,
`net.ipv4.conf.lo.route_localnet=1`, iptables DNAT `159.153.51.20 → 127.0.0.1:42127`, and
`/etc/hosts: 127.0.0.1 easw.easports.com` (the last persists across reboot; the rest don't).
## Persistence / reboot
Sysctls, iptables and the TLS cert are volatile — `./openfut-fut.sh start` rebuilds them, so the
supported recovery is simply to re-run it after boot. (For hands-off auto-start you can wrap
`root_arm.sh` in a root `systemd` oneshot at boot and the servers in a user service, but the
one-command flow above is the sanctioned path.)
## Troubleshooting — the gate ladder (each fixed; if one regresses this is where)
Watch `/tmp/{lsx,blaze,roster,utas,autopatch}.log`. The screens you may see and their cause:
| Screen | Cause / fix |
|---|---|
| "log in to Origin" | LSX `GetInternetConnectedState``connected="1"` |
| "title version outdated" | LSX `GetGameInfo UPTODATE``"true"` |
| "Unable to retrieve account information" | LSX response `sender` must echo the request `recipient`; `AuthCode value=` |
| "not eligible … age restriction" | mislabeled — the `AuthCode` reply needed the `value=` attribute |
| "Unable to connect to the EA servers" | Blaze `CONF` durations must be unit-suffixed (`"30s"`, not `30000000`) |
| FUT loading spinner (forever) | Blaze `CensusData` subscribe reply needs non-zero `CNP/NTMT`; and `ROSTERUPDATE_URL` served + roster_server up |
| "error connecting to Ultimate Team" | `easw.easports.com` → 127.0.0.1 (`/etc/hosts`) + `utas_server` on :8099 |
| "error downloading the FUT Squad Update" | stale in-process state — **relaunch FIFA fresh** |
| Security question | type any answer → our `utas_server` `/phishing/validate` accepts it |
Full reverse-engineering write-ups: `login_dump/*.md`, `docs/*.md`. All findings are clean-room
(from binaries we own); nothing from any leak. The whole protocol maps to FIFA 23 (identical wire format).
+196
View File
@@ -0,0 +1,196 @@
# FIFA 17 Blaze Recon (Rosetta Stone for FIFA 23)
Clean-room reverse engineering: all findings derive from observing our own running
FIFA 17 client + static disassembly of the shipped binary we own. **No leaked EA
source is used or referenced.**
> ## ✅ WORKING: FIFA 17 Ultimate Team, 100% offline
> The full online + FUT stack is emulated. **Quick start → [`FUT-RUNBOOK.md`](FUT-RUNBOOK.md):**
> ```bash
> cd tools && ./openfut-fut.sh start # arm host + start all servers (re-run after reboot)
> ~/Desktop/launch-fifa17.sh # then launch FIFA FRESH and select Ultimate Team
> ```
> Proven end-to-end 2026-08-01: auth → Blaze login → device-trust → the FUT hub. The rest of this
> file is the reverse-engineering history that got there (see also `login_dump/*.md`, `docs/*.md`).
## Breakthrough — 2026-07-30: ProtoSSL cert pin DEFEATED, redirector handshake captured
FIFA 17 dials the **secure** Blaze redirector `winter15.gosredirector.ea.com` over
TLS 1.2 (RSA-kx). We MITM it with a self-signed cert and defeated DirtySDK/ProtoSSL's
cert pinning with two live `/proc/PID/mem` patches, then captured the **plaintext**
first-hop handshake.
### Key architectural finding
The secure redirector is **HTTPS + XML (ProtoHttp)**, NOT raw Fire2/Heat2:
```
POST /redirector/getServerInstance HTTP/1.1
Host: winter15.gosredirector.ea.com:42230
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Content-Type: application/xml
<serverinstancerequest>...</serverinstancerequest>
```
Fire2/Heat2 binary is the **second hop** — the redirector replies with a
`<serverinstance>` XML naming a Blaze server IP:port; the client then connects THERE
for the binary protocol. Full request body in `captures/getServerInstance_request.http`.
## Reproduce (after reboot — all live state is volatile)
Binary maps flat at base `0x140000000` under Wine/Proton (UMU-Proton-10.0-4,
prefix `~/Games/umu/fifa17`). VAs below are stable across launches.
### 1. Root arm (scratchpad/root_arm.sh via pkexec)
- `sysctl kernel.yama.ptrace_scope=0` (enables /proc/mem WRITES)
- `sysctl net.ipv4.conf.lo.route_localnet=1`
- `iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0.0.1:42127`
(winter15 resolves to 159.153.51.20; a /etc/hosts entry for winter15 would
short-circuit the DNAT and must be ABSENT)
### 2. TLS capture server
`scratchpad/blaze_tls_capture.py` on 127.0.0.1:42127, presents `redir_cert.pem`
(self-signed, CN+SAN=winter15.gosredirector.ea.com), ciphers `ALL:@SECLEVEL=0`.
### 3. The two cert-verify patches (via scratchpad/memtool.py)
The cert handler lives at ~`0x14613252x`. Two gates:
| VA | Role | Patch |
|---|---|---|
| `0x146132548` | Gate 1: `jne 0x1461326c4` (UNKNOWN_CA branch after chain-verify `call 0x146136410`) | 6 bytes → `90 90 90 90 90 90` (NOP) |
| `0x1461361b0` | **Gate 2: the real pin** — cert verify helper; returned `-51 (0xffffffcd)` live | 3 bytes → `31 c0 c3` (`xor eax,eax; ret`) |
Gate 2 (`0x1461361b0`) is the decisive one — a shared verify helper (also called from
`0x146131f86`). Forcing it to return 0 makes `r12d=0`, the `je 0x14613262d` at
`0x14613256c` is taken, and the accept path at `0x146132675` is reached (skips the
UNKNOWN_CA alert send at `0x146135250`).
NB: last session's patch of `0x146136410` (chain-verify callee) did NOT work — it was
not the function returning the live failure. gdb breakpoint on `0x1461361b0` proved
Gate 2 was the wall (`eax=0xffffffcd`).
## Breakthrough #2 — 2026-07-30: BOTH HOPS DEFEATED, Fire2/Heat2 decoded
Built `tools/blaze_responder.py`: answers `getServerInstance` over TLS with a
`<serverinstanceinfo>` that redirects the client to a local plain Blaze port, and
captures the second-hop Fire2 binary. The client **accepted the redirect and connected**,
sending its `Util::preAuth` handshake in binary Heat2. `tools/decode_fire2.py` decodes it.
### getServerInstance response schema (the redirect)
`ServerInstanceInfo.address` is a `ServerAddress` **union**; Heat2 XML encodes a union as
`<field member="N"><valu>...</valu></field>`. Working response (member=0 = ipAddress variant):
```xml
<serverinstanceinfo>
<address member="0"><valu>
<hostname>127.0.0.1</hostname><ip>2130706433</ip><port>42130</port>
</valu></address>
<secure>0</secure>
<trialservicename></trialservicename>
<defaultdnsaddress>0</defaultdnsaddress>
</serverinstanceinfo>
```
`<ip>` is a **decimal uint32** host-order (2130706433 = 127.0.0.1). `<secure>` 0/1 picks
plaintext vs TLS for the Blaze connection. (Schema cross-confirmed clean-room vs MEC
Catalyst private-server projects; response types reversed from the client's own TDF
reflection tables at ~0x143891xxx / 0x144873xxx.)
### Fire2 frame header (16 bytes, big-endian)
```
[0:4] u32 payloadLength [6:8] u16 component [8:10] u16 command
[10:12] u16 error/msgId [12] u8 msgType [13:16] reserved
```
First RPC observed: **component 0x0009 = Util, command 0x0007 = preAuth, msgType 0x02**.
Ping/pong keep-alives: Util command 0x0002, empty payload, msgType 0x01/0x03.
### Heat2 TDF encoding (decoded in decode_fire2.py)
Per field: 3-byte tag (4 chars, 6-bit packed, char = v?v+0x20:' ') + 1 type byte + value.
Types: 0x00 int(varint, first byte 6 data bits + continue@0x80), 0x01 string(varint len incl
null + bytes), 0x02 blob, 0x03 struct(nested, 0x00 terminator), 0x04 list, 0x05 map, 0x06 union.
### preAuth codebook (Util::preAuth PreAuthRequest) — captures/blaze/preauth_decoded.txt
```
CDAT{ IITO:int LANG:int SVCN:str='fifa-2017-pc' TYPE:int }
CINF{ BSDK='15.1.1.3.0' BTIM='Jun 9 2017 16:15:40' CLNT='FIFA17' CPFT:int=4
CSKU='FIFAPC' CVER='3175939' DSDK='15.1.2.1.0' ENV='prod' LOC:int PTVR='1.1' }
FCCR{ CFID='BlazeSDK' }
LADD:int
```
Same fields as the XML getServerInstance request → XML and Fire2 are the two encodings of
the same TDFs (the Rosetta mapping).
(Fire2 header was later CORRECTED: byte[12] is the low octet of a 24-bit msgNum, not msgType;
msgType lives in byte[13] high bits = (msgType<<5)|userIndex. REPLY=1→0x20, NOTIFICATION=2→0x40.
metadataLen is u16 at [4:6]. See tools/heat2.py / blaze_responder_v3b.py.)
## Breakthrough #3 — Origin/LSX layer defeated (PreAuthResponse + login flow work)
`tools/blaze_responder_v3b.py` answers preAuth, ping, fetchClientConfig, login (1/0x0A),
getAccount(1/0x1E)=AccountInfo, getPersona/listPersonas, and pushes UserAuthenticated (0x7802/8).
But Blaze isn't the online gate — **Origin is**, via its own in-process LSX layer:
- The Steampunks `stp-origin_emu.dll` serves **LSX** (length-prefixed, NUL-terminated XML) IN-PROCESS
on 127.0.0.1:4216. It's a blind fixed-script replayer that reports OFFLINE. **Replace it**:
bind 4216 BEFORE launching FIFA (`tools/lsx_responder_v2.py`; the stub has no SO_REUSEADDR and
stands down cleanly), serve real request-driven LSX.
- **LSX crypto (reversed + verified byte-exact):** server sends `<Challenge key="<32hex>">`; client
replies `<ChallengeResponse response="<96hex>" key="<32hex>">`; **H = hex(AES128-ECB(K=000102..0f,
PKCS7pad16(clientKey_ascii)))** (32 ASCII → 48 bytes/3 blocks); server sends `<ChallengeAccepted
response="H">`; session key = srand(7) LCG of H; later msgs = hex(AES-ECB(pkcs7(xml)))+NUL.
- **LSX verbs to answer:** GetProfile(PersonaId=33068179 Persona=CAGE US), GetSetting UPPERCASE
(ENVIRONMENT→"production", LANGUAGE→"en_US", else "false"), GetGameInfo (LANGUAGES→locales,
**UPTODATE→"true"** [else "title version outdated"], FREETRIAL→"false"),
**GetInternetConnectedState→connected="1"** [the online gate], etc.
- Gates cleared this way: "log in to Origin" ✓ and "title version outdated" ✓.
## Breakthrough #4 — 2026-07-30: repack fully reversed (LSX contract is a byte-exact oracle)
The Steampunks repack ships two UPX-packed helpers; we unpacked and clean-room reversed BOTH
(multi-agent workflow, adversarially verified — full report `docs/REPACK_INTEL.md`, emu disasm
`docs/emu.asm`). Unpack recipe: `upx -d stp-origin_emu.dll` and `upx -d _fifa17.exe` (emu base
0x180000000, loader base 0x140000000; both are NORMAL PEs — objdump works, unlike the encrypted
FIFA17.exe). Findings that matter:
- **`stp-origin_emu.dll` = the reference LSX server, offline BY CONSTRUCTION.** It is a blind
18-step straight-line script with NO parser and NO dispatch branch; its ONLY unsolicited frame is
the plaintext Challenge; it hardcodes `connected="0"` and has NO `<Login>` event / no auth vocab
anywhere in its 19,456 bytes. **Structural proof (not absence-of-evidence): nothing in the repack
can flip `m_isLoggedIn`.** The login mechanism lives ONLY in FIFA17.exe's live-decrypted code.
- **Our `lsx_responder_v2.py` is CONFIRMED byte-exact** on framing (NUL-terminated, NUL counted in
send len), crypto (AES-128 K_FIXED=000102..0f, PKCS7, srand(7)→61 session-key LCG), event shape,
sender values (EALS / EbisuSDK / ""), and encryption timing (plaintext through ChallengeAccepted
id=1, encrypted from id=2). Applied hardening C1C3 (emu-exact `challenge_response` + tail assert,
extract `response="`, partial-frame buffering). Selftest still green (session key unchanged).
- The loader is an offline keygen/launcher (no WS2_32, no injection, no Blaze/Nucleus strings); its
`.dlf` GameToken is a local ENTITLEMENT grant, not a session — will not help login. Shared build
constants: UserId/PersonaId **33068179**, MachineHash == LSX Challenge key **2b8ee7fa…e32** (fixed).
## CURRENT WALL — "Unable to retrieve account information" (m_isLoggedIn stays 0)
FIFA has two Origin flags — "internet reachable" (fed by GetInternetConnectedState, DONE) and
**"user LOGGED IN" = OriginMgr.m_isLoggedIn @[OriginMgr+0x13]**, whose only setter is dispatcher
case-2 @0x146f1e0ab, driven by a server-PUSHED `<Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/>`.
**Pushing it 90× did NOT flip the flag.** Breakthrough #4 RULED OUT three causes: framing, event
shape, and encryption timing are all confirmed correct. **Surviving hypotheses, narrowed:**
(1) **encrypted mid-session Events are dropped** — the emu's only Event is plaintext+pre-key, so
there is zero evidence FIFA routes an *encrypted* Event to the same parser (STRONGEST); (2) `sender`
name mismatch; (3) handler-registration timing. Deeper residual: LoginStatePCLogin @0x1471b58e0 may
gate on a session OBJECT [0x144b86bf8]->vtbl+0x60, not the flag.
### THE decisive next experiment (observe, don't guess) — new tooling ready
1. Relaunch harness+game (below), run responder with an UNBOUNDED heartbeat so pushes stay in flight:
`OPENFUT_LSX_EVENT_COUNT=100000 python3 -u tools/lsx_responder_v2.py`
2. `bash tools/trace_login.sh` — attaches gdb, traces the sender matcher (0x147102880), the <Login>
parser (0x147138660), and dispatcher case-2 (0x146f1e09e / set-1 0x146f1e0ab / set-0 0x146f1e0b8).
Answers the 3-question ladder in ONE run: does the frame reach the matcher? what sender does it
strcmp against (dumps the table entry)? does case-2 run and the flag flip?
3. If the trace shows the ENCRYPTED frame never reaches the matcher → run the A/B:
`OPENFUT_LSX_LOGIN_PLAINTEXT=1 …` pushes the Login Event in plaintext right after ChallengeAccepted.
4. `tools/dump_login_code.py` — dumps + disassembles the decrypted login machinery at true VAs for a
follow-up static pass if the trace points below the dispatcher.
## How to resume (rebuild the volatile harness)
1. `pkexec sh tools/../scratchpad/root_arm.sh` (ptrace_scope=0, route_localnet, DNAT 159.153.51.20→42127).
2. `python3 -u tools/lsx_responder_v2.py` — bind :4216 BEFORE launching FIFA.
3. `python3 -u tools/blaze_responder_v3b.py` — :42127 (redir TLS) / :42130 (blaze) / :42131 (nucleus).
4. `python3 tools/autopatch.py` — re-applies the two ProtoSSL cert patches to any relaunched FIFA17.exe.
5. Launch FIFA via `~/Desktop/launch-fifa17.sh`; go Online.
6. Watch /tmp/lsx.log (LSX) + /tmp/blaze_responder.log (Blaze); use tools/origin_login_probe.py to read
m_isLoggedIn. Everything is volatile across reboot; VAs are stable (base 0x140000000).
## Live-state note
Volatile across reboot: cert patches, responders, DNAT, ptrace_scope. `tools/autopatch.py`
re-applies both cert patches automatically to any relaunched FIFA17.exe (VAs are stable).
Everything ported here (framing, Heat2, LSX crypto, tags) applies to FIFA 23 (identical wire format).
+317
View File
@@ -0,0 +1,317 @@
# FIFA17 First-Party Auth Enqueue — Forge & Trigger Plan
Clean-room synthesis of four independent reverses of the `FifaOnline::FirstPartyAuthTokenRetriever::DoTick`
auth-code path. Every byte/offset below is backed by a decrypted-code VA or a live `/proc/13643/mem`
read. Live pid at time of writing: **13643** (`pgrep -x FIFA17.exe`).
## TL;DR — two blockers, not one
The brief assumed the only problem is `retriever+0x8 == NULL`. It is not. There are **two** hard
gates, both live-verified this run:
1. **Empty queue**`retriever+0x8` and `+0x10` are both `0x0` (nothing enqueued). *This is the one
the brief targets.*
2. **No Origin default user**`OriginSDK[+0x3a0] == 0x0`. `DoTick` calls `GetDefaultUser()` which
returns that slot, then `OriginRequestAuthCodeSync` **rejects any request whose user is NULL**
(`test rdx,rdx; je fail` → returns `0xa2000003`, nothing hits the wire). So even a perfectly forged
node produces only an "Origin Error(a2000003)" log line unless we also set `OriginSDK[+0x3a0]`.
**Forging the node alone is necessary but not sufficient. You must set BOTH.** Every plan below has
"set `OriginSDK[+0x3a0]` non-null" as step 0.
Live confirmation (this run):
```
OnlineMgr *[0x1448a3b20] = 0x43dc3e70
retriever = +0x4e98 = 0x43dc8d08
+0x00 vptr = 0x1438f5d50 (retriever vtable, 1 real slot = deleting dtor)
+0x08 queue slot 0 = 0x0 <-- write &node here
+0x10 queue slot 1 = 0x0
guard byte [0x1448a3ac3] = 0x01 (enqueue-wrapper guard PASSES, not the blocker)
OriginSDK *[0x144b7c7a0] = 0x25c98c50
+0x3a0 defaultUser = 0x0 <-- BLOCKER: must be non-null
node vtable 0x1438f5d58: [0]AddRef 0x147e8f160 [1]Release 0x147e1c480 [2]dtor 0x146f028d0
ret gadget [0x1470e3567] = c3
```
Note there are **two different "OriginMgr" singletons** — do not confuse them:
- `*[0x1448acf50]` → login-state OriginMgr (`m_isLoggedIn` @ +0x13; the one `force_login_flag.py` pins).
- `*[0x144b7c7a0]` → OriginSDK object (default-user @ +0x3a0; the one THIS path needs).
---
## 1. `FirstPartyAuthCodeFutureImpl` NODE STRUCT
Size **0xF0 (240 bytes)** — from the enqueue allocation constant
(`0x146f5b916 mov edx,0xc390a20f; lea edx,[rdx+0x3c6f5ee1]` = `0xF0`) and matched by the ctor
`0x146eeecd0`. The clientId capacity `0x40` comes from the same ctor (`r8d = 0xc390a20f + 0x3c6f5e31 = 0x40`).
| Offset | Type | Meaning | Ctor init | Read/written by | Forge value (minimal) |
|---|---|---|---|---|---|
| `+0x00` | `void**` | primary vtable | `0x1438f5d58` | DoTick calls `[vptr+8]`=Release at end | **`0x1438f5d58`** (real) |
| `+0x08` | `void**` | secondary vtable (base) | `0x1438f5d90` | dtor adjustor thunk only | `0x1438f5d90` (real) or `0` |
| `+0x10` | `u32` | atomic refcount | `0` (xchg) | AddRef/Release | **`2`** (see refcount note) |
| `+0x14` | `u32` | pad | — | — | `0` |
| `+0x18` | `char[0x40]` | **ClientId** (inline C-string) | `strncpy(+0x18,arg,0x40)` | DoTick `lea rdx,[rsi+0x18]` → passed as `const char*`; Origin deref's byte-wise, must be non-empty | **`"FIFA17PC\0"`** (any non-empty; see Q) |
| `+0x58` | `char[0x80]` | message/error buffer | `[+0x58]=0` | `SetError` vsnprintf's here (cap 0x80, ends at 0xD8) | `0` |
| `+0xD8` | `char*` | **authCode result** (heap) | `0` | DoTick success: `mov [rsi+0xd8],rax`; dtor frees it | `0` |
| `+0xE0` | `u32` | status/error code | `0` | `SetError``200 (0xC8)` on failure | `0` |
| `+0xE4` | `u32` | kind/userIndex | `= ctor arg2` | wrapper always passes `0` | `0` |
| `+0xE8` | `u8` | **isComplete / poll flag** | `0` | DoTick sets `1` on BOTH success and failure | `0` |
| `+0xE9`..`+0xEF` | pad | — | — | — | `0` |
There is **NO `next` pointer.** The "queue" at `retriever+0x8` is a **fixed 2-slot array** of
ref-counted node pointers, not a linked list. DoTick iterates the two slots with `lea rbx,[rcx+8];
mov ebp,2; ... add rbx,8; dec rbp; jne`. No node field is ever chased as a link. (Confirmed:
`0x146f199cd/d1/e0` and tail `0x146f19ae1/e5/e8`.)
**Node vtable `0x1438f5d58`** (real, live-read):
`[0]`AddRef `0x147e8f160` · `[1]`Release `0x147e1c480` · `[2]`dtor `0x146f028d0` ·
`[5]`GetResult `0x1466cc0d0` (`mov rax,[rcx+0xd8];ret`) · `[4]`GetStatus `0x1471a0630`
(`mov eax,[rcx+0xe0];ret`).
### Minimal forged node — exact 240 bytes (little-endian)
```
off bytes meaning
0x00 58 5d 8f 43 01 00 00 00 vptr = 0x1438f5d58
0x08 90 5d 8f 43 01 00 00 00 vptr2 = 0x1438f5d90
0x10 02 00 00 00 refcount = 2 (survives one Release, never freed)
0x14 00 00 00 00 pad
0x18 46 49 46 41 31 37 50 43 00.. clientId = "FIFA17PC", NUL, rest 0 (fills to 0x58)
0x58 00 * 0x80 message buffer = 0
0xD8 00 00 00 00 00 00 00 00 authCode = 0
0xE0 00 00 00 00 status = 0
0xE4 00 00 00 00 kind = 0
0xE8 00 isComplete = 0
0xE9 00 * 7 pad to 0xF0
```
**Refcount note (important).** DoTick unconditionally ends each processed slot with
`mov rcx,[rbx]; mov [rbx],0; mov rax,[rcx]; call [rax+8]` = **Release** (`0x147e1c480`, `lock xadd`
decrement of `[node+0x10]`; on reaching zero it invokes the dtor which `free()`s the node via the
game allocator `0x1453370b0`). If you forge with **refcount = 1**, DoTick decrements to 0 and tries to
**free your node** — safe only if the node lives in game-allocator memory, a crash otherwise. Forge
**refcount = 2**: after Release it is 1, never freed. Costs a ~240-byte leak, zero crash risk.
(Alternative: use a synthetic vtable whose slot `[1]` is the ret gadget `0x1470e3567` — then Release
is a no-op and refcount is irrelevant; but the real vtable + refcount=2 is simpler and keeps the
GetResult/GetStatus accessors valid if anything polls.)
**Unknowns (marked):**
- The **real ClientId string** the game would use is unrecovered (the natural enqueue never runs live).
For our local LSX responder any non-empty string is accepted by `<GetAuthCode>`. For a genuine EA
endpoint the correct Nucleus client_id would be required. Since OpenFUT answers LSX locally, `"FIFA17PC"`
(or whatever our responder keys on) is fine.
- Whether the deeper LSX marshalling inside `0x1470e67f0` dereferences **user** object fields beyond the
null/equality check. The traced send path builds the request from the SDK object + clientId and does
**not** deref the user, but this was not exhaustively followed past the dispatch. Mitigation: set
`OriginSDK[+0x3a0]` to a real readable pointer (the SDK object itself) rather than a bare `1`.
---
## 2. DoTick PROCESSING — end to end (`0x146f199c0`)
Per slot `i` in `{+0x08, +0x10}`:
1. `rsi = *slot`. If NULL → skip (`je 0x146f19ae1`). *(Live: both NULL → does nothing, forever.)*
2. Zero two stack out-slots `[rsp+0x60]` (authCode out) and `[rsp+0x58]` (length out).
3. `call OriginGetDefaultUser()` (`0x1470da6d0`, zero-arg) → returns `OriginSDK[+0x3a0]` or NULL.
Verified: `0x1470da6f4 call 0x1470e3560 (→ *[0x144b7c7a0]); 0x1470da6f9 mov rax,[rax+0x3a0]; ret`.
4. `call OriginRequestAuthCodeSync(user=rax, clientId=&node[0x18], &outAuthCode=r8, &outLen=r9, scope=0)`
(`0x1470db3c0`, `146f19a05 lea rdx,[rsi+0x18]`, `146f19a0c mov [rsp+0x20],r14`=0 scope). The wrapper
forwards to the real impl `0x1470e67f0`, which:
- `test rdx,rdx; je fail` and `cmp rdx,[rcx+0x3a0]; jne fail` — **user must be non-NULL and == the
SDK default user** (both are the same slot, so any non-null value is self-consistent). On failure
returns `0xa2000003`, **no send**.
- clientId must be non-empty (`cmp byte[r8],0`), copies it into `LSXRequest+0x10`.
- builds the `Origin::LSXRequest<lsx::GetAuthCodeT,...>`, **transmits it** (`call [0x148e219f8]`),
registers the pending future in the SDK reqId-keyed map (`0x1470e6540`), writes future→out, reqId→out.
*This is the point `<GetAuthCode ClientId Scope>` goes on the LSX wire.*
5. DoTick inspects the result:
- `rc != 0``SetError(node, 200, "[%s] Origin Error(%d)\n", ".::DoTick", rc)` → writes `node+0xE0=200`,
`node+0xE8=1`, message into `node+0x58`.
- `rc==0 && (outAuthCode==0 || outLen==0)``SetError(node,200,"[%s] Invalid authcode\n",...)`.
- success → alloc `outLen+1` from `*[0x1448a20b8]` (vt+0x38), `mov [node+0xD8]=buf`,
`strlcpy(buf,outAuthCode)` (`0x145e27a50`), `mov byte[node+0xE8]=1`.
6. **Dequeue + release (all paths):** `mov rcx,[rbx]; mov [rbx],0` (NULL the slot) then
`mov rax,[rcx]; call [rax+8]` = Release. Each enqueued request is consumed in exactly one tick;
there is no retry/pending state.
DoTick's only caller is the per-frame online-subsystem tick `0x146f7b279`
(`lea rcx,[rsi+0x4e98]; call 0x146f199c0`), so a forged node is picked up on the **next frame**.
---
## 3. THE PLAN (ranked by likelihood-of-success × safety)
### STEP 0 (all plans): set the Origin default user — REQUIRED
```
OriginSDK = *[0x144b7c7a0] # live 0x25c98c50
write 8 bytes at OriginSDK+0x3a0 = OriginSDK # a real, readable, self-consistent non-null pointer
```
Writing the SDK object's own address (rather than a bare `0x1`) satisfies the null + equality checks
**and** points at valid memory in case anything downstream deref's the "user". GetDefaultUser and the
impl both read the same slot, so equality always holds.
---
### (a) PRIMARY — FORGE a node via `/proc/mem` and set `retriever+0x8` ★ recommended
Pure memory writes, no code execution, no Win64/SysV ABI hazard. Matches the brief exactly.
**Steps**
1. Do STEP 0.
2. Pick a **scratch VA** inside FIFA to host the 240-byte node — a currently-zero, unreferenced,
writable region (see "live items", §4). Call it `NODE`.
3. Write the 240-byte forged node (bytes in §1) at `NODE`.
4. Write `NODE` (8 bytes) into `retriever+0x8` = `0x43dc8d10`.
5. Watch `/tmp/lsx.log` for the `<GetAuthCode ClientId="FIFA17PC" .../>` request on the next frame.
**Recipe (style of `force_login_flag.py`):**
```python
#!/usr/bin/env python3
# forge_node.py — forge a FirstPartyAuthCodeFutureImpl and enqueue it. ptrace_scope=0 required.
import struct, glob, os
ONLINEMGR_PP = 0x1448a3b20 # *-> OnlineManager
RETR_OFF = 0x4e98 # +retriever
SDK_PP = 0x144b7c7a0 # *-> OriginSDK
SDK_DEFUSER = 0x3a0 # OriginSDK default-user slot (BLOCKER)
VPTR = 0x1438f5d58
VPTR2 = 0x1438f5d90
CLIENTID = b"FIFA17PC"
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: pass
raise SystemExit("FIFA17.exe not found")
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 -> never freed
b[0x18:0x18+len(CLIENTID)] = CLIENTID # clientId, NUL-terminated (rest already 0)
return bytes(b)
def main():
p = pid(); f = open(f"/proc/{p}/mem","r+b")
rq = lambda va:(f.seek(va), struct.unpack('<Q', f.read(8))[0])[1]
onlinemgr = rq(ONLINEMGR_PP); retr = onlinemgr + RETR_OFF
sdk = rq(SDK_PP)
# STEP 0: default user
f.seek(sdk+SDK_DEFUSER); f.write(struct.pack('<Q', sdk))
print(f"[+] OriginSDK={sdk:#x} default-user set -> {sdk:#x}")
# NODE scratch VA — MUST be a validated unused writable region (see plan §4).
NODE = int(os.environ.get("NODE_VA","0"),16)
if not NODE: raise SystemExit("set NODE_VA=<hex scratch VA>")
f.seek(NODE); f.write(build_node())
print(f"[+] node forged @ {NODE:#x} (clientId={CLIENTID!r})")
# enqueue: retriever+0x8 = &node
f.seek(retr+0x08); f.write(struct.pack('<Q', NODE))
print(f"[+] retriever+0x8 ({retr+0x08:#x}) -> {NODE:#x}. Watch /tmp/lsx.log for <GetAuthCode>.")
if __name__=='__main__': main()
```
**Crash risks**
- *Scratch provenance*: if `NODE` overlaps live game memory, DoTick's writes to `+0xD8/+0xE8` (and any
poller) corrupt it. Mitigate by validating the region is zero + unreferenced (§4).
- *Refcount*: refcount=2 avoids the terminal free entirely — do **not** use 1 unless `NODE` is game-alloc.
- *Deeper user deref*: covered by pointing `+0x3a0` at the real SDK object.
- *Race*: DoTick runs every frame; write the node bytes **before** setting `retriever+0x8` (the script
does), so a mid-write tick never sees a half-built node.
**Success signal**: a single `<GetAuthCode ClientId="FIFA17PC" .../>` LSXRequest on `/tmp/lsx.log`
within one frame; on failure instead expect an "Origin Error(a2000003)" trace (means STEP 0 didn't take)
or "Invalid authcode" (means our LSX responder returned empty).
---
### (a) SAFE VARIANT — let the game allocate the node (hybrid forge) ★ safest memory-wise
Instead of hosting the node in scratch memory, call the game's own enqueue
`RequestFirstPartyAuthCode(clientId)` = **`0x146f57bf0`** (guard byte `[0x1448a3ac3]` already `1`, so it
resolves `retriever = mgr+0x4e98` correctly and stores into the first free slot). This allocates a
proper 0xF0 node from the game allocator, ctors it, AddRefs, and inserts it — DoTick then processes it
with the real vtable and correct refcount/free, and wires the future back into the retriever slot.
This removes the scratch-provenance problem entirely but requires a **call** (see (b) for the Win64 ABI
caveat). Signature: `void** RequestFirstPartyAuthCode(const char* clientId /*rcx*/)`. Still needs STEP 0.
---
### (b) DIRECT CALL via gdb — fire the send without forging
Two call targets, both Win64 `__fastcall`:
- **Enqueue** `0x146f57bf0` `RequestFirstPartyAuthCode(const char* clientId /*rcx*/)` — the (a) route;
correct, wires the future into the retriever.
- **Raw sync sender** `0x1470db3c0`:
```
int32 OriginRequestAuthCodeSync(
rcx void* user, // must be !=0 AND == OriginSDK[+0x3a0] (== GetDefaultUser())
rdx const char* clientId, // non-empty
r8 void** pOutFuture,// out, non-null
r9 uint64* pOutReqId, // out, non-null
[rsp+0x28] const char* scope // optional, pass 0
) -> 0 ok / 0xa2000003 (bad user) / 0xa2000004 (null out-ptr)
```
Direct-call recipe: STEP 0, then `clientId="FIFA17PC"`; zero `outFuture,outReqId`; `rcx=OriginSDK[+0x3a0]`,
`rdx=&clientId`, `r8=&outFuture`, `r9=&outReqId`, `[rsp+0x28]=0`.
**Crash / correctness risks**
- **ABI mismatch (the big one):** FIFA17.exe is a Win64 PE under Wine (args `rcx/rdx/r8/r9`+stack); host
gdb `call` uses SysV (`rdi/rsi/rdx/rcx`). A naive `call` passes args in the wrong registers → garbage
user/clientId → fault or `0xa2000003`. Use a **forged thread context** (stop a thread, set `rip` to the
target with Win64 regs + 5th arg pushed + a return trap) or a small written trampoline, not `call`.
- The **raw sync** call fires GetAuthCode but the future lands in **your** out-param, not the retriever
node — it validates the LSX path but does **not** advance FIFA login. The **enqueue** call (0x146f57bf0)
does advance it. Prefer the enqueue.
- Re-entrancy: calling on a paused thread mid-DoTick could double-process; run when the online tick is idle.
**Success signal**: same `<GetAuthCode>` on `/tmp/lsx.log`. For the enqueue call, also expect `node+0xE8`
to flip to `1` on the following frame.
---
### (c) FIX THE REAL SKIP REASON — make FIFA enqueue naturally (cleanest, hardest)
Why the game never enqueues, root-caused to three independent walls (all live-verified or high-conf):
1. **No default user** (`OriginSDK[+0x3a0]==0`). It is populated only by the SDK connect/user-query
round trip at `0x1470e5ad5` (guarded by `0x147118d80` after a ~15 s connect-wait loop; nearby literal
"EbisuSDK"). If that LSX exchange never yields a user, the slot stays NULL and neither the natural
enqueue nor the auth send can proceed. **Fixing this legitimately (our LSX responder answering the
user-query so `+0x3a0` gets set) would unblock BOTH gates at once — the cleanest of all outcomes.**
2. **LoginStatePCLogin sub-state 0 is a hardcoded stub.** `(*[0x144b86bf0])->vt[0x60]` = `0x146f82070`
= `xor eax,eax; ret` for the live class → the state always returns NULL and falls to the
`TXT_NOT_LOGIN_TO_EBISU` branch (`0x1471b5b64`, sets `TXT_NOT_LOGIN_TO_EBISU` @ `0x1439633e8`, sub-state 1).
*Confidence medium* — needs the jump-table decode at `0x141e7f55c` to confirm index-0 mapping.
3. **Blaze-SDK's own auth-code fetchers** (`0x147237340` "blazeServerClientId" / `0x147237440`
"blazeSdkClientId") both bail at `mov rcx,[rax+0x750]; test rcx,rcx; je` — the **client-config object**
our empty `fetchClientConfig` responses never populate. Populating client-config would let this second,
retriever-independent path call `OriginRequestAuthCodeSync` directly.
**Recommended natural-fix track**: make our LSX responder answer the Origin user-query so `0x1470e5ad5`
writes `OriginSDK[+0x3a0]`, then supply a non-empty `fetchClientConfig` so `[cfg+0x750]` is non-NULL.
That is a server-side change (no memory patching) and would let FIFA drive the whole flow itself.
**Risk**: highest reverse-effort; may reveal further downstream gates (Blaze login after GetAuthCode).
---
## 4. STILL NEEDS A LIVE DUMP / EXPERIMENT
1. **Scratch VA for plan (a).** Need a validated **unused, zero, writable** ≥0x100-byte region in FIFA's
maps to host the forged node (candidates: an anon `rw` mapping with a long zero run; verify it stays
zero across several frames = unreferenced). Or sidestep entirely with plan (a)/(b) using the game
allocator. This is the one blocker to running (a) as-is.
2. **Does the transmit fp `*0x148e219f8` write the LSX socket synchronously**, or does `0x1470e6540` only
register the future while a separate pump thread flushes it? Determines whether a one-shot forced
enqueue puts bytes on the wire in the same frame.
3. **Does `0x1470e67f0` deref user fields** past the null/equality guard (deeper marshalling at
`0x1470dbfa0/0x147117fe0/0x1471186f0`)? If yes, `OriginSDK[+0x3a0]` must point at a *shaped* user
object, not just the SDK. Dump those before relying on the self-pointer trick.
4. **Real ClientId** the game/our LSX handler expects — confirm our responder's `<GetAuthCode>` handler
accepts an arbitrary non-empty string (expected: yes) or keys on a specific value.
5. **Confirm the natural-fix chain**: after our LSX responder answers the user-query, verify
`OriginSDK[+0x3a0]` actually becomes non-NULL live (proves gate #1 is server-fixable) and that
`fetchClientConfig` content lands at `[cfg+0x750]`.
6. **reqId width** written to `pOutReqId` (`[req+0xc8]`, appears 64-bit) — needed so a forged/emulated
response correlates with the request.
+399
View File
@@ -0,0 +1,399 @@
# FIFA 17 Steampunks repack — consolidated RE intel
**Provenance:** every claim below derives ONLY from `stp_emu_unpacked.dll` (PE base
`0x180000000`) and `f17_loader_unpacked.exe` (PE base `0x140000000`) in this directory, plus
arithmetic re-derivation of constants. No leaked EA source was used or consulted. Where a
statement comes from our earlier *live* FIFA17.exe recon rather than these binaries, it is
tagged **[LIVE]** and must not be treated as repack-confirmed.
**Synthesis note:** this document adjudicates five independent sub-reports. Two of them
disagreed on the ChallengeAccepted construction and several loader VAs were wrong. All
conflicts were re-verified against the binaries by the synthesis pass; the adjudications are
recorded in-line in §0 so the wrong versions do not get re-adopted later.
Section-header ground truth (from `objdump -h`, used for every VA↔file-offset mapping here):
| Binary | Section | VMA | File off | Mapping |
|---|---|---|---|---|
| emu | .text | `0x180001000` | `0x400` | VA = 0x180001000 + (off 0x400) |
| emu | .rdata | `0x180004000` | `0x2e00` | VA = 0x180004000 + (off 0x2e00) |
| emu | .data | `0x180005000` | `0x3e00` | VA = 0x180005000 + (off 0x3e00) |
| loader | .text | `0x140001000` | `0x400` | VA = 0x140001000 + (off 0x400) |
| loader | .rdata | `0x140010000` | `0xf000` | VA = 0x140010000 + (off 0xf000) |
| loader | .data | `0x140018000` | `0x16200` | VA = 0x140018000 + (off 0x16200) |
| loader | .stp0 | `0x14001d000` | `0x18600` | still packed — disassembles as garbage |
---
## 0. Conflict adjudications (read this before trusting any single sub-report)
| # | Dispute | Verdict | Proof |
|---|---|---|---|
| A | ChallengeAccepted = 64 hex (2 blocks, no pad) **or** 96 hex (3 blocks, PKCS7)? | **96 hex. Our v2 is correct — do NOT truncate to 64.** One sub-report read only the two `movaps`/encrypt pairs and missed the trailing `strcat_s`. | `0x1800020a9 lea r8,[rsp+0x90]` / `mov edx,0x200` / `mov rcx,rbp` / `call [rip+0x1fa9] # 0x180004068` (strcat_s). `rsp+0x90` = response-buffer(`rsp+0x50`) + `0x40` = `clientResponse[64:]`. Emu emits 64 computed + 32 echoed = 96. |
| B | Is our 3-block PKCS7 formula equivalent to the emu's 2-block+echo? | **Yes, numerically identical**, because the echoed tail is the client's own 3rd block and the client PKCS7-pads. Verified today. | `AES(K_FIXED, b'\x10'*16) = 954f64f2e4e86e9eee82d20216684899`; for client key `18a70055a3541fb27ab8e0f47afad18c`, 3-block hex `[:64]` == 2-block hex and `[64:]` == that constant. |
| C | Session-key seed = `bx + rand()` or `bx + (rand()==61)`? | **`bx + rand()`, integer 61.** The BRIEF's `r0=(rand()==61)` was a mis-transcription of a comment. `lsx_responder_v2.py:169` is already correct. | `0x1800020de call rand` → eax; `0x1800020e4 movzx ecx,bx`; `0x1800020e7 add ecx,eax`; `0x1800020e9 call srand`. MSVCR LCG: 7·214013+2531011 = 0x3D7BAE, `>>16 & 0x7fff` = 61. |
| D | Loader `jsHym…` base64 VA | **`0x140015b10`** (file `0x14b10`), not `0x140014b10`. One sub-report was systematically 0x1000 low. | `.rdata` maps file `0xf000`→VA `0x140010000`. |
| E | Loader License XML template VA | **`.data 0x140019620`** (file `0x17820`), not `0x140017820` (that VA is in the gap between `.rdata` end `0x140017114` and `.data` start `0x140018000` — it does not exist). | Template dumped verbatim below. |
| F | "xor'd fragment containing blaZe" in the loader | **False lead — no cipher.** It is raw 24-bit RGB pixel data inside the keygen's 602×408 BMP resource. | Resource type 2 BITMAP at RVA `0x10a388`; `BITMAPINFOHEADER` biWidth=602 biHeight=408 biBitCount=24 biSizeImage=737666. Offset `0x110fff` falls ~row 26 of pixel data; the bytes already read `blaZe` untransformed. |
| G | Loader S-box / Rcon VAs | **`0x1400155b0` / `0x1400154b0` — correct as reported.** | Byte search confirms file `0x145b0` / `0x144b0`. |
---
## 1. LSX CONTRACT (definitive)
### 1.1 Transport & framing — CONFIRMED
* Server: IPv4/TCP `127.0.0.1:4216`. `WSAStartup(0x202)``getaddrinfo("127.0.0.1","4216", {AI_PASSIVE, AF_INET, SOCK_STREAM, IPPROTO_TCP})` → socket → bind → `listen(s, 0x7fffffff)``accept(s, NULL, NULL)`.
Strings: `"4216"` @ file `0x33f4`, `"127.0.0.1"` @ file `0x3400`. WS2_32 ordinals from the IAT: `0x180004198`=1 accept, `0x180004168`=2 bind, `0x180004180`=3 closesocket, `0x180004190`=13 listen, `0x180004160`=16 recv, `0x180004188`=19 send, `0x1800041a0`=22 shutdown, `0x180004170`=23 socket.
* **Exactly one connection, ever.** There is exactly **one** `accept` call site in the whole image (`grep -c '# 0x180004198' emu.asm` → 1), not in a loop; the listener is closed immediately after (`0x1800022bb closesocket`). Terminal path `shutdown(sock,1)` @ `0x180002e89``WSACleanup` @ `0x180002e9d` → thread returns.
* **Framing: NUL-terminated, and the NUL is INCLUDED in the send length.** Every send is `send(s, buf, strlen(buf)+1, 0)`. There is **no** length prefix, no newline, no XML-close sentinel scan.
Idiom repeated before each send: `or rax,-1` / `cmp BYTE PTR [rdx+rax*1+0x1],0` / `lea rax,[rax+1]` / `jne` (inlined strlen) then `lea r8d,[rax+0x1]` / `xor r9d,r9d` / `call [0x180004188]`. First instance `0x1800022c8`; identical at `0x18000235f`, `0x1800023ed`, `0x18000249d``0x180002e1b`.
* **Receive: one blocking `recv(sock, buf, 0x1000, 0)` per message**, into a 0x1000 stack buffer at `rbp+0x320`. No accumulation, no partial-frame reassembly. The shipped emu simply *assumes* one whole message per recv.
Thread buffer map: `rbp+0x120` = 16-byte session key, `rbp+0x320` = 0x1000 recv/format/decrypt buffer, `rbp+0x1320` = 0x200 ChallengeAccepted hex buffer.
* Message-size envelope: the emu's plaintext buffers are 0x1000. Replies should stay **under 4095 bytes** if we want to remain inside the envelope the shipped emu proved safe (`QueryEntitlements` is the one at risk of growing).
### 1.2 Crypto — CONFIRMED, with one hardening note
Primitive is genuine, hand-inlined **AES-128** (no CryptoAPI/BCrypt/OpenSSL; imports are only MSVCR120 / KERNEL32 / WS2_32):
| Item | VA | Bytes / detail |
|---|---|---|
| S-box | `0x180004330` | `63 7c 77 7b f2 6b 6f c5 30 01 67 2b fe d7 ab 76` |
| Inv S-box | `0x180004430` | `52 09 6a d5 30 36 a5 38 …` |
| Rcon | `0x180004230` | `8d 01 02 04 08 10 20 40 80 1b 36 6c` |
| Key expansion | `0x180001000` | loop `cmp r9d,0x2c` = 44 words = AES-128, 10 rounds |
| Encrypt block | `0x180001710` | MixColumns `0x1800011d0` (xtime `mov al,0x1b`) |
| Decrypt block | `0x180001930` | InvMixColumns `0x1800012c0` |
| `K_FIXED` | `0x180005038` (file `0x3e38`) | **`000102030405060708090a0b0c0d0e0f`** — verified byte-exact |
| Key-schedule input ptrs | `0x180005fd0` (block), `0x180005fd8` (key) | set before each `call 0x180001000` |
**Handshake sequence:**
1. Server pushes the **plaintext** Challenge, verbatim from `.data 0x180005590` — it is a *fixed constant*, never computed or randomized, and is sent with no `sprintf` and no call to the encryptor:
```
<LSX><Event sender="EALS"><Challenge key="2b8ee7faea76e8a34f5f5d20e5328e32" build="release" version="10,4,13,6637"/></Event></LSX>
```
2. Client replies **plaintext** `<ChallengeResponse response="<96hex>" key="<32hex>"/>`.
3. Parse function `0x180001f10` does the *only* string parsing in the entire DLL — two `strstr` calls, both here (`0x180001f5a` for `response="` @`0x1800045e0`, +0xA; `0x180001fa6` for `key="` @`0x1800045ec`, +5), each terminated by the next `"` (bare quote literal @`0x1800045d8`), `memcpy`'d into two 0x200 stack buffers (`rsp+0x50` = response value, `rsp+0x250` = client key) and NUL-terminated. **Only the first occurrence of each is used, and the client's `response=` value is NEVER VERIFIED.**
4. `H` = `hex(AES128ECB(K_FIXED, clientKey[0:16]))` ‖ `hex(AES128ECB(K_FIXED, clientKey[16:32]))` ‖ `clientResponse[64:]`
— two `movaps`/expand/encrypt pairs at `0x180001fee` and `0x180002024`; hex loop `0x180002070``0x1800020a7` bounded by `cmp rbx,0x20` (32 bytes → 64 chars) via `sprintf_s(tmp,3,"%02x")` (**lowercase**, fmt @`0x1800045d0`) + `strcat_s`; then the echo `strcat_s` at `0x1800020a9`.
**Our PKCS7-3-block formula produces the identical 96 hex** (§0-B). Keep it; see the diff list for the hardening.
5. Server sends **plaintext** `ChallengeAccepted` (id=1). **Encryption begins only with the id=2 response**; the first inbound decrypt is the frame received *after* ChallengeAccepted. Proof: after `sprintf_s` at `0x180002343` the code goes straight to inlined strlen + send at `0x180002370` with no call to the encryptor `0x180001dc0`; first encrypt call is `0x1800023d6`, first decrypt `0x1800023a4`.
6. Session key (`0x1800020bf``0x180002101`), derived from the **ASCII characters** of `H`, not its binary bytes:
```
srand(7); r = rand(); // r == 61 under the MSVCR120 LCG
bx = (uint16)((H[0] << 8) + H[1]); // imul bx,ax is a 16-bit multiply
srand(bx + r); // movzx ecx,bx ; add ecx,eax
key[i] = (uint8)rand() for i = 0..15; // loop bounded by cmp rdi,0x10
```
**Post-handshake messages (both directions):** PKCS#7 pad → AES-128-ECB under the session key → **lowercase** hex → NUL-terminated.
* Encrypt `0x180001dc0`: `mov ecx,ebx; and ecx,0xf; mov eax,0x10; sub eax,ecx` then `rep stos BYTE PTR [rdi],al` — pad value == pad count, and **a full 16-byte block is appended when the length is already aligned** (true PKCS#7).
* Decrypt `0x180001ce0`: hex2bin `0x180001c50` via `sscanf_s("%02x")` (so **uppercase hex is accepted on input**), per-block decrypt, then a *validating* PKCS#7 strip at `0x180001d6e``0x180001d94` (`cmp al,0x10; ja skip` / `test al,al; je skip` / run-length check) that zeroes the pad bytes.
### 1.3 There is NO parser and NO dispatch table
This is the single most important structural fact about the shipped emu, and it changes how the response table below should be read.
The emu is a **blind, fully-unrolled, straight-line 18-step scripted conversation**. After the ChallengeResponse it never inspects another request: it `recv`s, `decrypt`s into `rbp+0x320`, and then immediately **overwrites that same buffer** with the next `sprintf_s` — the decrypted plaintext is never read. The region `0x180002325``0x180002d6f` is 18 literal `lea r8,[template]; mov r9d,<ordinal>; call sprintf_s` blocks with **zero conditional branches on message content**; the only `cmp`/`jne` present are the inlined strlen loops and the `send()==-1` check at `0x180002e2e`. From message 19 the tail loop `0x180002db0``0x180002e7b` replies `ErrorSuccess` forever and **does not even call the decrypt routine**.
Exact send/recv accounting (whole image): **20 send sites, 19 recv sites, 1 accept site** — i.e. 1 unsolicited Challenge + 18 scripted + 1 loop send, versus 18 scripted + 1 loop recv. **Exactly one frame in the entire binary is unsolicited, and it is the plaintext Challenge.** The emu never sends an encrypted Event.
### 1.4 Complete response table (byte-exact templates, verified verbatim)
`id` is a **hard-coded counter**, never echoed. Immediates: `0x180002333`=1, `0x1800023b7`=2, `0x180002462`=3, `0x1800024fa`=4, `0x18000258d`=5, `0x180002628`=6, `0x1800026bb`=7, `0x180002758`=8, `0x1800027eb`=9, `0x180002888`=0xa, `0x18000291b`=0xb, `0x1800029bf`=0xc, `0x180002a58`=0xd, `0x180002aeb`=0xe, `0x180002b88`=0xf, `0x180002c1b`=0x10, `0x180002cb8`=0x11, `0x180002d4b`=0x12; then `0x180002d6a mov esi,0x13` with `inc esi` at `0x180002dfd`.
The 12 templates are the **complete** XML surface of the 19,456-byte DLL (`.data` string region exhaustively enumerated):
| VA | Template (verbatim) |
|---|---|
| `0x180005020` | `.\stp-origin_emu.ini` |
| `0x180005050` | `<LSX><Response id="%d" sender=""><GetSettingResponse Setting="%s"/></Response></LSX>` |
| `0x1800050b0` | `<LSX><Response id="%d" sender=""><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"/></Response></LSX>` |
| `0x180005170` | `<LSX><Response id="%d" sender=""><GetSettingResponse Setting="false"/></Response></LSX>` |
| `0x1800051d0` | `<LSX><Response id="%d" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>` |
| `0x180005230` | `<LSX><Response id="%d" sender=""><IsProgressiveInstallationAvailableResponse ItemId="" Available="false"/></Response></LSX>` |
| `0x1800052b0` | `<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>` |
| `0x1800053b0` | `<LSX><Response id="%d" sender=""><InternetConnectedState connected="0"/></Response></LSX>` |
| `0x180005410` | `<LSX><Response id="%d" sender="EALS"><ChallengeAccepted response="%s"/></Response></LSX>` |
| `0x180005470` | `<LSX><Response id="%d" sender="EbisuSDK"><GetConfigResponse Config="false"/></Response></LSX>` |
| `0x1800054d0` | `<LSX><Response id="%d" sender=""><GetGameInfoResponse GameInfo="false"/></Response></LSX>` |
| `0x180005530` | `<LSX><Response id="%d" sender=""><GetSettingResponse Setting="production"/></Response></LSX>` |
| `0x180005590` | `<LSX><Event sender="EALS"><Challenge key="2b8ee7faea76e8a34f5f5d20e5328e32" build="release" version="10,4,13,6637"/></Event></LSX>` |
**Ordinal → template script.** Because the emu never reads the request, this is simultaneously (a) the emu's whole behaviour and (b) **a recording of FIFA 17's first 18 boot-time LSX requests**. The verb column is therefore an *inference from the answer*, not a parsed fact — but it is a high-value regression oracle.
| # | Template VA | Response | Inferred request |
|---|---|---|---|
| — | `0x180005590` | Challenge (**plaintext, pushed**) | — |
| 1 | `0x180005410` | ChallengeAccepted (**plaintext**) | ChallengeResponse |
| 2 | `0x180005470` | `GetConfigResponse Config="false"` | GetConfig |
| 3 | `0x1800052b0` | GetProfileResponse | GetProfile |
| 4 | `0x180005170` | `Setting="false"` | GetSetting |
| 5 | `0x1800054d0` | `GameInfo="false"` | GetGameInfo |
| 6 | `0x1800050b0` | locale list | GetGameInfo(LANGUAGES) |
| 7 | `0x180005530` | `Setting="production"` | GetSetting(ENVIRONMENT) |
| 8 | `0x180005170` | `Setting="false"` | GetSetting |
| 9 | `0x180005230` | IsProgressiveInstallationAvailableResponse | IsProgressiveInstallationAvailable |
| 10 | `0x1800052b0` | GetProfileResponse | GetProfile |
| 11 | `0x1800050b0` | locale list | GetGameInfo(LANGUAGES) |
| 12 | `0x180005050` | `Setting="%s"` ← ini Language | GetSetting(LANGUAGE) |
| 13 | `0x1800054d0` | `GameInfo="false"` | GetGameInfo |
| 14 | `0x1800051d0` | `ErrorSuccess` | **UNKNOWN — a verb with no meaningful response (Set*/Notify?). Biggest gap in this table.** |
| 15 | `0x180005530` | `Setting="production"` | GetSetting(ENVIRONMENT) |
| 16 | `0x1800054d0` | `GameInfo="false"` | GetGameInfo |
| 17 | `0x1800053b0` | `connected="0"` | GetInternetConnectedState |
| 18 | `0x1800052b0` | GetProfileResponse | GetProfile |
| ≥19 | `0x1800051d0` | `ErrorSuccess` forever | anything |
**Useful positive corollary:** FIFA 17 tolerates `<ErrorSuccess Code="0" Description=""/>` as the answer to arbitrary unknown requests, indefinitely, without dropping the LSX connection. An ErrorSuccess catch-all fallback is provably safe.
### 1.5 id / sender echo rules — CONFIRMED GROUND TRUTH
* **`id` is never echoed.** The emu emits its own contiguous counter 1,2,3,… and the shipped repack works. This *proves* FIFA 17's request ids are a contiguous integer sequence starting at 1 (otherwise the emu's blind counter would desynchronize). Echoing the client's id — what v2 does — is therefore equivalent and strictly safer.
* **`sender` is never echoed.** It is hard-coded per template, and only three values exist in the entire image:
* `EALS` → Challenge, ChallengeAccepted
* `EbisuSDK` → GetConfigResponse, GetProfileResponse
* `""` (empty) → GetSettingResponse, GetGameInfoResponse, InternetConnectedState, IsProgressiveInstallationAvailableResponse, ErrorSuccess
* **Attribute casing** (lock this in verbatim): `connected` is **lowercase** inside `InternetConnectedState` while every sibling attribute is PascalCase; `response=` and `key=` on the challenge exchange are lowercase; `build`, `version`, `id`, `sender` lowercase; everything else (`Setting`, `GameInfo`, `Config`, `ItemId`, `Available`, `Code`, `Description`, `IsSubscriber`, `PersonaId`, `AvatarId`, `Country`, `CommerceCountry`, `GeoCountry`, `UserId`, `Persona`, `IsUnderAge`, `CommerceCurrency`) as written above.
### 1.6 Emu identity config
Init `0x180001b60` makes exactly three ini reads from `.\stp-origin_emu.ini` `[Globals]` — **this is the entire configuration surface of the DLL**:
| Key | Default | Destination |
|---|---|---|
| `Language` | `"en_US"` (@`0x180004570`) | buffer `0x180005bc0`, **also** `SetEnvironmentVariableA("EAGameLocale", …)` @`0x180001c04` — a WRITE, not a read |
| `PersonaId` | `0x1f89493` = **33068179** | u64 @`0x180005dc0` |
| `PersonaName` | `"STEAMPUNKS"` (@`0x1800045a0`) | buffer `0x180005dd0` — **the shipped ini overrides this to `CAGE`** |
`GetProfileResponse` uses `PersonaId` for **both** `PersonaId=` and `UserId=` (same `%llu` twice, slots `[rsp+0x20]`/`[rsp+0x28]` both loaded from `[0x180005dc0]` at `0x18000245d`/`0x18000246d`).
**Emu bug worth banking as behavioural evidence:** the **third** GetProfileResponse (ordinal 18) emits a **garbage PersonaId** — the runtime address of the language buffer. Ordinal 3 sets all three vararg slots; ordinal 12 clobbers `[rsp+0x20]` with `lea rax,[rip+0x320f] # 0x180005bc0` (`0x1800029aa`/`0x1800029ca`); ordinal 18 (`0x180002d3d``0x180002d56`) sets only `rcx`/`edx`/`r8`/`r9d` and never restores it. So `PersonaId="%llu"` prints a pointer while `UserId` and `Persona` stay correct. **The repack still boots to a playable game — therefore FIFA 17 does not latch account identity from `GetProfileResponse`.** That is a genuine negative constraint on where the account-info gate lives.
### 1.7 Corrections to `lsx_responder_v2.py` — concrete diff list
Ranked by risk. **Most of the responder is confirmed correct**; the confirmations are listed because they *eliminate hypotheses* for the current failure.
> **CONFIRMED — NO CHANGE (these rule out root causes):**
> * `:159 derive_session_key()` — exactly right. `imul bx,ax` is a 16-bit multiply so `& 0xFFFF` is correct; `movzx ecx,bx; add ecx,eax` is a 32-bit add of the zero-extended 16-bit `bx`. `r0 = next(msvcr_rand(7))` is correct — the BRIEF's `(rand()==61)` was a mis-transcription (§0-C). Only the **docstring** should change.
> * `:185 lsx_encrypt()` — exactly right: PKCS7 with a full 16-byte block when aligned, lowercase hex, single trailing NUL, NUL counted in the length.
> * `:194 lsx_decrypt()` — `bytes.fromhex` is already case-insensitive, matching the emu's `sscanf_s("%02x")`.
> * `:257/:261 Conn.send_plain/send_enc` — both append exactly one `b"\0"` and `sendall` the whole buffer. **Framing is correct**, which rules out "our pushed Event was mis-framed and the reader stalled".
> * `:294 resp()` default `sender=""` — correct. Do NOT echo the request's sender.
> * `:422 resp(1, ChallengeAccepted, "EALS")` — matches the emu's hard-coded id=1 exactly.
> * `:108-110 PERSONA_ID/USER_ID = 33068179` — matches both the emu ini default `0x1f89493` **and** the decrypted `.dlf` `<UserId>`. `PERSONA_NAME = "CAGE"` matches the shipped ini (the *code* default is `STEAMPUNKS`; `CAGE` is right for this install).
> * `:122-124 CHALLENGE_KEY/BUILD/VERSION` — byte-exact against `0x180005590`.
> * The pushed-Event *shape* `<LSX><Event sender="X"><Y/></Event></LSX>` with no `id` and no `<Response>` wrapper is confirmed consumable — the working Challenge uses exactly it.
**C1 — `:173 challenge_response()` — harden to the emu's exact algorithm (MEDIUM).**
Currently a 3-block PKCS7 encrypt. Numerically identical **only while the client PKCS7-pads its third block**. The emu never computes block 3 at all; it echoes the client's.
```diff
-def challenge_response(client_key_ascii: str) -> str:
- b = client_key_ascii.encode()
- pad = 16 - (len(b) % 16)
- b += bytes([pad]) * pad
- return AES.new(K_FIXED, AES.MODE_ECB).encrypt(b).hex()
+def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str:
+ """Emu-exact (0x180001f10): TWO blocks computed from the client key, then
+ the client's own response[64:] appended verbatim (strcat_s @0x1800020a9)."""
+ two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex()
+ if len(client_response_attr) >= 64:
+ tail = client_response_attr[64:]
+ # integrity check: FIFA's 3rd block must be AES(K_FIXED, 0x10*16)
+ assert tail == "954f64f2e4e86e9eee82d20216684899", f"unexpected tail {tail}"
+ return two + tail
+ return two + AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex()
```
The assert converts a silent future breakage (a client build that randomizes the tail) into a loud one. **Do not truncate the output to 64 hex** — one sub-report recommended that; it is wrong (§0-A).
**C2 — `:414 serve()` — also extract `response="` (LOW, enables C1).**
The emu parses `response="` **before** `key="`. We currently regex only `key=`.
```diff
- m = re.search(r'key="([^"]*)"', data.decode(errors="replace"))
- client_key = m.group(1) if m else CHALLENGE_KEY
- h = challenge_response(client_key)
+ txt = data.decode(errors="replace")
+ mk = re.search(r'key="([^"]*)"', txt)
+ mr = re.search(r'response="([^"]*)"', txt)
+ client_key = mk.group(1) if mk else CHALLENGE_KEY
+ client_resp = mr.group(1) if mr else ""
+ h = challenge_response(client_key, client_resp)
```
**C3 — `:429` — buffer partial frames across `recv` (MEDIUM, latent).**
`data.split(b"\0")` silently drops a trailing partial frame. The emu never had to handle this (it does one 4096-byte recv per message), but our 65536 recv can straddle. Symptom would be an unexplained protocol stall that looks like a logic bug.
```diff
- for chunk in filter(None, data.split(b"\0")):
+ buf += data
+ *frames, buf = buf.split(b"\0")
+ for chunk in filter(None, frames):
```
(initialize `buf = b""` before the loop).
**C4 — `:263 push_login_state` / `:277 heartbeat` — the free-running timer is a wire pattern the client has never seen (MEDIUM).**
The emu is **strictly lockstep**: 20 sends / 19 recvs, one send per recv, no exceptions. A heartbeat push landing between the client's request and our response is unprecedented from the shipped emu's perspective. Gate pushes to fire only immediately after a Response is written (the `PUSH_AFTER` path already does this) and **drop the free-running timer**, or at minimum serialize it behind the request loop rather than just behind the send lock.
**C5 — `:220 login_event_frames()` docstring — weaken the structural claim (DOC).**
The claim "unsolicited `<Event>` frames are consumable: the LSX handshake itself is one" over-reaches. The emu's only Event is the Challenge, sent **in plaintext, before the session key exists** (`0x1800022e5`, no call to the encryptor). There is **zero** evidence in this binary that an *encrypted mid-session* Event is routed to the same parser. "Encrypted Events are dropped or routed elsewhere" remains a live hypothesis — see §4.
**C6 — flag unverified responses in comments (DOC).**
`GetAuthCode` (`sender="EbisuSDK"`), `QueryEntitlements`, and `GetGameInfo UPTODATE="true"` have **no template in this binary**. The emu only ever emits `GameInfo="false"` or the locale list; there is no `GameInfo="true"` string in the image, and no AuthCode template exists. These are **[LIVE]**-derived only. Keep them (the offline emu never trips the online-path checks they satisfy), but mark them as such so they are not later mistaken for repack-confirmed.
**C7 — BRIEF.md line 46 is wrong (DOC).**
There is no single `GetSettingResponse Setting="%s|production|false"` template. There are **three separate** templates (`0x180005050` `"%s"`, `0x180005170` `"false"`, `0x180005530` `"production"`) selected purely by script position. Our SettingId-based dispatch is consistent with the recorded ordering — keep it.
**C8 — add a boot-order regression oracle (NEW, high value).**
Diff our live request log against the §1.4 script. If our answers cause FIFA to diverge from that sequence **before #17**, we changed its path *before the online decision was even asked for*. Also assert id contiguity: a gap is the signature of a frame being consumed as the wrong thing.
**C9 — `:467 main()` — log loudly on a second connection (LOW).**
The emu closes its listener after the first accept and can never serve a reconnect. Our multi-connection responder is strictly better, but a 2nd connection is behaviour the reference implementation never had to handle — and would signal a state reset we should be reacting to rather than papering over.
---
## 2. REPACK INTEL (`f17_loader_unpacked.exe`)
### 2.1 What the loader is
A GUI **keygen + launcher**. `WinMain` → `0x140003cc0` (`0x14000484f`) → `lea r9,[0x1400037c0]` (dlgproc), `edx=0x65` (template id) → `DialogBoxParamW` @`0x1400102c0`. Two dialog commands: **GENERATE** (mint the Origin License) and **PLAY** (launch the game).
**Strings of interest (all cleartext — there is no string-deobfuscation routine in this binary; see §0-F):**
| VA | String |
|---|---|
| `0x1400157f0` | `dbdata.dll` |
| `0x140015800` | `getTableData` |
| `0x140015a20` | `FIFA17.exe` (wide) |
| `0x140015b10` | `jsHymMvuB34nUXoH80eHcw==` (base64 → 16 bytes `8ec1f298cbee077e27517a07f3478773`) |
| ~`0x140015acf` | `Unable to find dbdata.dll! Please put keygen into the game folder.` |
| ~`0x140015938` | `License file sucessfully generated. Press Play!` (sic) |
| `0x1400154b0` / `0x1400155b0` | AES Rcon / S-box |
| `0x1400195c0` | **License AES key** `4132722dd082efb0dc6457c57668ca09` |
| `0x1400195d0` | fixed 46-byte DER-shaped signature header |
| `0x140019620` | License XML template (below) |
| `0x1400157b0` | base64 charset `ABC…XYZabc…0123456789-_` (URL-safe) |
Negative scan: imports are **KERNEL32 / ADVAPI32 / GDI32 / SHELL32 / USER32 only — no WS2_32**, so no sockets. Whole-file keyword counts: `nucleus`=0, `gosredirector`=0, `entitlement`=0, `blaze`=1 (the BMP pixels), `ea.com`=1 (the License XML namespace), `auth`=1 (the `AuthenticAMD` CPUID vendor check ~`0x14000b393`, *not* authentication).
### 2.2 How licensing / GameToken works
**Flow.** GENERATE → `LoadLibraryA("dbdata.dll")` (`0x140003996` / `0x1400039b9`) → on success `call 0x140003120`, a virtualized wrapper that resolves and calls `dbdata.dll!getTableData` — **the actual keygen** — which returns the identity fields. The loader `sprintf`s the License XML from the `.data` template, AES-encrypts it (`call 0x140003f20`, virtualized, lives in `.stp0`), and writes it via `0x1400031f0`: `SHGetSpecialFolderPathW(CSIDL_COMMON_APPDATA)` → three `CreateDirectoryW` calls → `CreateFileW` (`0x1400033b2`) → `WriteFile` (`0x1400033df`) to
`%ProgramData%\Electronic Arts\EA Services\License\1027460.dlf`.
**Template (verbatim, `.data 0x140019620`, file `0x17820`):**
```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?><License xmlns="http://ea.com/license"><CipherKey>%s</CipherKey><MachineHash>2b8ee7faea76e8a34f5f5d20e5328e32</MachineHash><ContentId>%d</ContentId><UserId>%d</UserId><GameToken>%s</GameToken><GrantTime>2017-01-01T00:00:00Z</GrantTime><StartTime>2017-01-01T00:00:00Z</StartTime></License>
```
**On-disk `.dlf` format (recovered by decrypting the live file):**
```
[0x00 .. 0x2D] 46-byte fixed signature header, copied verbatim from loader .data 0x1400195d0:
302c0214 520de8c2b6fcab8ed27ab6ab24c8db27b5453570 0214 8cb3edcffd4d1a5740e25ab5fa2e75a9793b7628 0000
= ASN.1 SEQUENCE(0x2c){ INTEGER(20) r, INTEGER(20) s } -- a static DSA/ECDSA-SHA1
signature REUSED FOR EVERY LICENSE (content differs, signature does not)
[0x2E .. 0x40] zero pad
[0x41 .. ] AES-128-CBC ciphertext, IV = 0, key = 4132722dd082efb0dc6457c57668ca09
(loader .data 0x1400195c0, loaded at the very first .text instruction
0x140001014 `movzx eax,[rip -> 0x1400195c0]` feeding the key schedule at 0x140001000),
plaintext PKCS7-padded
```
**Decrypted payload fields:**
| Field | Value |
|---|---|
| `CipherKey` | `jsHymMvuB34nUXoH80eHcw==` → `8ec1f298cbee077e27517a07f3478773` |
| `MachineHash` | `2b8ee7faea76e8a34f5f5d20e5328e32` |
| `ContentId` | `1027460` |
| `UserId` | **`33068179`** |
| `GameToken` | 1196-char base64url → 896 raw bytes, header `0100df00…` — Nucleus-format |
**The GameToken is NOT in any binary** (searched loader, emu, game — all `find == -1`). It is produced at runtime by `dbdata.dll!getTableData`.
**Three constants tie the loader and emu together into one coordinated identity:**
* `UserId` in the `.dlf` (**33068179**) == the emu's `PersonaId` default `0x1f89493` == the shipped ini `PersonaId`.
* `MachineHash` (**`2b8ee7fa…e32`**) == the emu's LSX `<Challenge key=…>`. A **fixed build constant**, not per-machine, not per-session.
* Note the two AES keys are **unrelated**: License `4132722d…` ≠ LSX `K_FIXED 000102…0f` ≠ License `CipherKey 8ec1f298…`. Do not conflate them; the License key does not derive the LSX session key.
### 2.3 Does the loader touch the game, or produce auth material?
**Touch: no.** PLAY launches the game via `CreateProcessW` (`0x14000361f`) from `0x1400034c0`: `GetCommandLineW` + `CommandLineToArgvW`, builds a command line around `"FIFA17.exe"` (`0x140015a20`), `lpApplicationName = NULL`, `bInheritHandles = 0`, `dwCreationFlags = 0x4000000` (`CREATE_DEFAULT_ERROR_MODE`) — **not** `0x4` `CREATE_SUSPENDED` — then `EndDialog`.
**There is no process-memory patching anywhere.** The import table has no `VirtualProtect` / `VirtualAllocEx` / `WriteProcessMemory` / `CreateRemoteThread` / `Nt*`; a binary search for those names finds nothing; and the only `GetProcAddress` loop (`0x1400063a8`/`0x1400063c8`) resolves CRT api-set helpers (`FlsAlloc`, `CreateThreadpoolTimer`, …), not FIFA addresses.
**Auth material: yes, but the wrong kind.** The `.dlf` carries a genuine Nucleus-format `GameToken` + `CipherKey` + identity. That is real auth material — and it is why the game clears its **ownership / entitlement** checks. But it is delivered **purely as an on-disk file** (no registry, env var, pipe, or shared memory) and is consumed by the **local license/entitlement path**, not by the LSX login state machine. **It will not flip `m_isLoggedIn`.**
**Caveat: `.stp0` is still packed.** This "unpacked" exe only had the outer UPX layer stripped. `.stp0` (VMA `0x14001d000`, size `0xec895`) disassembles as garbage (`movabs ds:0x6a04261ed6375c7b`, random `(bad)` opcodes), and the cleartext `.rdata`/`.data` strings above have **zero rip-relative xrefs** in the disassembled `.text` — meaning the code that consumes them lives inside that packed blob. Everything in §2 is therefore complete about *what the loader does*, but not about *how the virtualized helpers do it*.
---
## 3. LOGIN-GATE VERDICT
### **NO.** Nothing in this repack can flip `OriginMgr.m_isLoggedIn` or deliver account information.
This is a positive structural proof, not an absence-of-evidence argument. The emu is **offline by construction**, and the binary is small enough (19,456 bytes) to enumerate exhaustively:
1. **No login vocabulary exists.** `grep -aoib 'login|isloggedin|authcode|entitlement'` over the whole DLL returns **zero hits**. The `.data` string region is fully enumerated in §1.4 — the 12 templates plus the ini path are *all* of it. No `<Login>`, no `IsLoggedIn`, no `GetAuthCode`, no token, no session concept.
2. **`connected="0"` is a literal with no alternative.** The substrings `Internet` and `connected` occur at exactly two byte offsets in the entire file (`0x41d2`, `0x41e9`), both inside the single template at `0x1800053b0`. That template contains only one format specifier (`id="%d"`) — `connected="0"` is not parameterized. It is referenced by exactly **one** unconditional instruction (`0x180002caa lea r8,[rip+0x26ff]`), sitting in straight-line code with no `jcc` targeting it. There is no `connected="1"` string anywhere in the image.
3. **There is no code location where a Login event could ever be emitted.** The response path `0x180002325``0x180002d6f` is straight-line with **zero content-dependent branches** (§1.3). A dispatcher cannot make a decision it has no branch for.
4. **There is no configuration escape hatch.** The entire config surface is three ini keys (Language / PersonaId / PersonaName) and the only environment-variable call is a **write** (`SetEnvironmentVariableA("EAGameLocale", …)`).
5. **There is no hidden alternate template set.** A relocated 8-entry pointer table exists at `0x180004530` (file `0x3330``0x3368`: `0x1800050b0`, `0x1800051d0`, `0x1800052b0`, `0x180005170`, `0x180005530`, `0x1800054d0`, `0x1800052b0`, `0x1800054d0`) but it is **dead** — no instruction in `.text` references it, and even so it contains only the same offline templates.
6. **Exactly one unsolicited frame exists in the whole binary**, proven by exact call-site accounting (20 sends / 19 recvs / 1 accept), and it is the plaintext Challenge.
7. **The loader adds nothing.** No sockets (no WS2_32), no memory patching, no online/auth strings. Its `.dlf` GameToken is a local *entitlement* grant, not a session.
### The account-info failure is not what we assumed
`GetProfileResponse` is the only account-info feed in the protocol, and our v2 already matches the emu template **byte-for-byte** (§1.4). More decisively: the shipped emu emits a **garbage pointer** as `PersonaId` in its third GetProfileResponse (§1.6) and the repack still boots to a playable game. **FIFA 17 therefore does not latch account identity from `GetProfileResponse` at all.** "Unable to retrieve account information" is not a GetProfileResponse *shape* problem — and it is not a state the repack ever avoids, because the repack never enters the online path we are forcing FIFA down with `connected="1"`.
### What this binary DOES contribute: three eliminated root causes
The negative result is not worthless — it closes off three plausible explanations for why the 90× `<Login IsLoggedIn="true">` push did nothing:
* **Framing is confirmed correct.** `send(s, buf, strlen(buf)+1, 0)` — the NUL is transmitted. Our `send_plain`/`send_enc` do exactly this. "The event was mis-framed and FIFA's reader stalled" is **ruled out**.
* **The pushed-Event shape is confirmed correct.** The working Challenge is `<LSX><Event sender="EALS"><X/></Event></LSX>` with no `id` and no `<Response>` wrapper, and FIFA consumes it. Our push used exactly that shape. "Wrong element shape" is **ruled out**.
* **Timing/encryption ordering is confirmed correct.** Encryption begins only after ChallengeAccepted; anything pushed earlier must be plaintext. Our pushes are all encrypted post-handshake, which is right. "We encrypted something that should have been plaintext" is **ruled out**.
Combined, this narrows the failure to **either the `sender` name not matching FIFA's handler table, or handler-registration timing, or — the strongest remaining hypothesis — encrypted mid-session `<Event>` frames not being routed to the same parser as the plaintext handshake Event.** The emu never sends one, so this repack cannot adjudicate that last point.
### Where the mechanism actually lives
**Only in FIFA17.exe's live-decrypted code.** Concretely, our existing **[LIVE]** recon already names the machinery: the Origin event dispatcher at `0x146f1e060`, whose **case 2 @`0x146f1e0ab` sets `m_isLoggedIn`, clears `loginError`, and rebroadcasts on the FE bus**, and the sender `strcmp` at `0x147102880`. That is the target. These repack binaries contain **no** part of it and never will — their residual value is exactly as a byte-exact oracle for crypto, framing, sender attributes, template shapes, and FIFA's boot request ordering.
### The next LIVE experiment
**Instrument the dispatcher instead of guessing at the wire.** We have pushed 90 frames blind and learned nothing because we cannot see whether they even arrive. Convert this from guessing to observation:
> Using the `/proc/PID/mem` live-probe path (Wine maps the PE flat at `0x140000000`, no relaunch needed), breakpoint or trace-patch the Origin event dispatcher at `0x146f1e060` and the sender `strcmp` at `0x147102880`. Then push one `<Login IsLoggedIn="true">` Event and answer exactly three questions, in order:
> 1. **Does the frame reach the dispatcher at all?** If not, the failure is *below* the dispatcher — decryption, routing, or (most likely) encrypted-Event handling — and no amount of sender-name guessing will help.
> 2. **If it reaches, what sender string does the `strcmp` at `0x147102880` compare against?** Dump the handler table. That converts `LOGIN_EVENT_SENDERS` from a guessed list into a read one.
> 3. **If the sender matches, does case 2 at `0x146f1e0ab` execute, and does `m_isLoggedIn` change?** If it executes but the flag reverts, something re-clears it — find the writer.
This single experiment discriminates between all three surviving hypotheses in one run, which no wire-level A/B can do.
---
## 4. ACTIONABLE NEXT STEPS (ranked)
1. **[LIVE, highest value] Trace the Origin event dispatcher `0x146f1e060` + sender `strcmp` `0x147102880` while pushing one Login Event.** Answers the three-question ladder above and discriminates all remaining hypotheses in one run. Everything else is secondary to this. Method: `/proc/PID/mem` live probe (flat map at `0x140000000`, no relaunch).
2. **[LIVE, cheap, do alongside #1] A/B the encrypted-vs-plaintext Event hypothesis.** The repack proves only that a **plaintext, pre-session-key** Event is consumed. Push the Login (a) as a `<Response>`-shaped frame, and (b) in **plaintext immediately after ChallengeAccepted, before the session goes encrypted**. If (b) works and our current push does not, the whole 90× failure was "encrypted Events are dropped" — a hypothesis this binary explicitly cannot rule out.
3. **[Code, low risk] Apply corrections C1C3** (emu-exact `challenge_response` + tail assert; extract `response="`; buffer partial frames). Small, mechanical, and C1's assert turns a future silent breakage into a loud one.
4. **[Code, medium] Apply C4** — drop the free-running heartbeat timer and gate pushes strictly to post-Response. The emu is provably lockstep (20 sends / 19 recvs); an interleaved push is a wire pattern FIFA has demonstrably never seen from the shipped emu, and is a plausible source of divergence we introduced ourselves.
5. **[Instrumentation, high value/low cost] Implement C8, the boot-order oracle.** Diff our live request log against the §1.4 script and assert id contiguity. This immediately answers three open questions at once: it names the unknown verb at ordinal 14, pins which `SettingId` maps to `production`/locale/`false`, and — most importantly — tells us whether our answers push FIFA off its normal path **before** the online decision at ordinal 17 is even asked for.
6. **[Tooling] Build a `.dlf` regenerator.** AES-128-CBC, IV=0, key `4132722dd082efb0dc6457c57668ca09`, prepend the fixed 46-byte header `302c0214…762800` + zero-pad to `0x41`, PKCS7-pad the XML. Lets us mint/patch licenses directly instead of depending on the Steampunks loader GUI — and lets us test whether the game verifies that static DER signature at all (it is byte-identical across licenses whose content differs, so either it is ignored or verification is bypassed).
7. **[Experiment, one line] Corrupt one hex char of `H`** and see whether the session still works. The emu never verifies the client's `response=`, which hints the challenge exists only to establish the session key. If FIFA does not verify either, we can stop treating the handshake as load-bearing. Same class of test: change the `Challenge key` from `2b8ee7fa…e32` and see whether anything notices — that determines whether we may safely hardcode it across installs.
8. **[Recon, if #1 stalls] Obtain `dbdata.dll`.** Not in this directory. It is the keygen that mints the 896-byte Nucleus `GameToken`. Learning whether that token is a fixed blob, derived from `UserId`, or structurally parseable would tell us whether any of its fields are reusable for Blaze `preAuth`.
9. **[Recon, low priority] Trial-decrypt `.stp0`** (`0x14001d000`, `0xec895`) with the `CipherKey` `8ec1f298cbee077e27517a07f3478773`. Long shot, but if it unpacks it exposes the virtualized encrypt fn `0x140003f20` and the real `getTableData` call sequence, removing the last inference in §2.2.
10. **[Hygiene] Fix BRIEF.md** — line 46 (three separate GetSetting templates, not one alternation), line 56 (`r0 = rand()`, not `rand()==61`), and strike the "xor'd fragment containing blaZe" lead from line 16 as a false positive (BMP pixel data).
---
## Open questions (consolidated, deduplicated)
* **What verb occupies ordinal 14?** Answered with `ErrorSuccess`, so it is a verb needing no meaningful response (a `Set*`/`Notify`?). Single biggest unknown in the boot-order table; a live log fills it in for free (step 5).
* **Is an *encrypted mid-session* `<Event>` routed to the same handler as the plaintext handshake Challenge?** The repack gives zero evidence either way. Most likely explanation for the 90 no-op pushes (steps 12).
* **Does FIFA validate the `Response` `id` against the pending request, or just pop a queue head?** The emu proves ids happen to line up 1..N but cannot distinguish. Determines whether our id-less pushed Events desynchronize request/response pairing.
* **Does FIFA verify the ChallengeAccepted `response`?** The emu never verifies the client's side and blindly echoes 32 of 96 hex chars (step 7).
* **Is `2b8ee7fa…e32` build-fixed across all Steampunks FIFA 17 installs, or regenerated per repack?** Determines whether we may safely hardcode it (step 7).
* **Why three GetProfileResponses (ordinals 3, 10, 18)?** Given the ordinal-18 pointer bug, FIFA clearly does not latch identity from them — but instrumenting *which* GetProfile is the last one before the error screen is still worth doing.
* **Does the game verify the `.dlf`'s 46-byte DER header against an embedded EA public key?** It is byte-identical for every license, so either it is ignored or the check is bypassed. Affects whether we can freely edit `.dlf` contents (step 6).
* **What does EbisuSDK do with `CipherKey` `8ec1f298…`?** Likely an inner key to decrypt/validate the GameToken. Confirm from FIFA17.exe's license-parse to know whether a mismatched GameToken/CipherKey pair would be rejected.
* **Does FIFA 17 ever reconnect to 4216?** The emu could never serve a reconnect, so probably not — but our multi-connection responder may be papering over a reconnect that signals a state reset we should react to (C9).
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
import glob, time, struct
GATE2=0x1461361b0; GATE2_ORIG=bytes.fromhex("48895c"); GATE2_PATCH=bytes.fromhex("31c0c3")
GATE1=0x146132548; GATE1_ORIG=bytes.fromhex("0f8576010000"); GATE1_PATCH=bytes.fromhex("90"*6)
LOG="/tmp/autopatch.log"
def log(m):
line=f"[{time.strftime('%H:%M:%S')}] {m}"
print(line,flush=True); open(LOG,"a").write(line+"\n")
def find_pids():
out=[]
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d+'/comm').read().strip()=='FIFA17.exe': out.append(int(d.split('/')[-1]))
except: pass
return out
def rd(pid,va,n):
with open(f'/proc/{pid}/mem','rb') as f:
f.seek(va); return f.read(n)
def wr(pid,va,b):
with open(f'/proc/{pid}/mem','r+b') as f:
f.seek(va); f.write(b)
patched=set()
log("=== AUTOPATCH watching for FIFA17.exe ===")
while True:
for pid in find_pids():
if pid in patched: continue
try:
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
except Exception:
continue # code not mapped yet / no ptrace perm yet
if g2==GATE2_PATCH and g1==GATE1_PATCH:
log(f"pid {pid}: already patched"); patched.add(pid); continue
if g2==GATE2_ORIG and g1==GATE1_ORIG:
try:
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
v2=rd(pid,GATE2,3).hex(); v1=rd(pid,GATE1,6).hex()
log(f"pid {pid}: PATCHED gate2={v2} gate1={v1}")
patched.add(pid)
except Exception as e:
log(f"pid {pid}: patch write failed: {e}")
# else: partial/unknown state -> wait
time.sleep(1)
File diff suppressed because it is too large Load Diff
+478
View File
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""
heat2.py -- self-contained Blaze Heat2 TDF encoder/decoder + Fire2 framing.
CLEAN ROOM PROVENANCE
---------------------
Everything here was derived from:
* the wire bytes of our own FIFA17 client's first Blaze RPC
(fifa17-recon/captures/blaze/blaze_fire2_37161.bin), and
* our own decoder (decode_fire2.py) written against those bytes.
Complex-type layouts that the capture does NOT exercise (list/map/union/
varintlist/objtype/objid/float) are marked UNVERIFIED below; they are
consistent with independent third-party clean-room BlazeSDK-15.x
reimplementations (the `tdf` crate cloned in this scratchpad), which were used
only as a cross-check of *structure*, never copied.
NO EA/FIFA leaked source was consulted.
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
--------------------------------------------------------------------
Fire2 frame header, 16 bytes big-endian:
[0:4] u32 payload length (bytes after the header)
[4:6] u16 always 0 (observed)
[6:8] u16 component
[8:10] u16 command
[10:12]u16 error / msgId
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
[13:16]3 reserved bytes (observed 00 00 00)
Heat2 field = 3-byte packed tag + 1 type byte + value.
TAG PACKING (validated):
Take the 4-char label, right-pad with spaces to exactly 4 chars, truncate
to 4. Each char c -> 6-bit code (ord(c) - 0x20) & 0x3F (so ' ' -> 0).
The four 6-bit codes are concatenated MSB-first into 24 bits = 3 bytes:
b0 = c0<<2 | c1>>4
b1 = (c1 & 0x0F)<<4 | c2>>2
b2 = (c2 & 0x03)<<6 | c3
Decode is the exact inverse; code 0 decodes to ' ' and trailing spaces are
stripped, so "ENV" round-trips as "ENV" (encoded as "ENV ").
VARINT (validated):
First byte carries only 6 data bits (mask 0x3F); bit 0x80 = "more".
Bit 0x40 of the first byte is the sign/negative flag (UNVERIFIED - never
set in our capture; we encode non-negative values only by default).
Every following byte carries 7 data bits (mask 0x7F) with bit 0x80 = more.
Little-endian group order: first byte = least significant 6 bits, then
7 bits per byte at shifts 6, 13, 20, 27, ...
Canonical form: emit the shortest sequence; value < 0x40 is one byte.
e.g. LANG = 0x656E5553 ("enUS") -> 93 d5 f2 d6 0c
STRING (validated):
varint length INCLUDING the NUL terminator, then that many bytes, the last
of which is 0x00. Empty string = varint 1 + b"\\x00".
STRUCT / group (validated):
type byte 0x03, then the member fields, then a single 0x00 terminator
byte. No group-start marker byte. The top-level payload is NOT
terminated (it is delimited by the Fire2 length).
FIELD ORDER (validated):
Members are serialized in ascending order of the *packed 3-byte tag*
(equivalently ascending by the space-padded label under this 6-bit
packing). Observed: CDAT<CINF<FCCR<LADD, and inside CINF:
BSDK<BTIM<CLNT<CPFT<CSKU<CVER<DSDK<ENV<LOC<PTVR.
VALUE REPRESENTATION (python side)
----------------------------------
A struct is an ordered dict { "TAG": (type, value) }.
INT -> int
STRING -> str (no trailing NUL) or bytes
BLOB -> bytes
STRUCT -> dict as above
LIST -> (elem_type, [value, ...])
MAP -> (key_type, val_type, [(k, v), ...])
UNION -> (active_key:int, (tag, type, value) | None)
VARLIST -> [int, ...]
OBJTYPE -> (component, type)
OBJID -> (component, type, id)
FLOAT -> float
"""
from __future__ import annotations
import struct
from collections import OrderedDict
# ---------------------------------------------------------------- types
INT = 0x00
STRING = 0x01
BLOB = 0x02
STRUCT = 0x03
LIST = 0x04
MAP = 0x05
UNION = 0x06
VARLIST = 0x07
OBJTYPE = 0x08
OBJID = 0x09
FLOAT = 0x0A
TYPE_NAMES = {
INT: "int", STRING: "string", BLOB: "blob", STRUCT: "struct",
LIST: "list", MAP: "map", UNION: "union", VARLIST: "varintlist",
OBJTYPE: "objtype", OBJID: "objid", FLOAT: "float",
}
UNION_UNSET = 0x7F # UNVERIFIED (not present in capture)
# ---------------------------------------------------------------- tags
def encode_tag(label) -> bytes:
"""4-char label -> 3 packed bytes. Shorter labels are space padded."""
if isinstance(label, bytes):
label = label.decode("ascii")
s = (label + " ")[:4]
c = [(ord(ch) - 0x20) & 0x3F for ch in s]
return bytes((
(c[0] << 2) | (c[1] >> 4),
((c[1] & 0x0F) << 4) | (c[2] >> 2),
((c[2] & 0x03) << 6) | c[3],
))
def decode_tag(b: bytes) -> str:
"""3 packed bytes -> label with trailing padding stripped."""
a, b1, c = b[0], b[1], b[2]
v = (
(a >> 2) & 0x3F,
((a & 0x03) << 4) | ((b1 >> 4) & 0x0F),
((b1 & 0x0F) << 2) | ((c >> 6) & 0x03),
c & 0x3F,
)
return "".join(chr(x + 0x20) if x else " " for x in v).rstrip()
def tag_key(label) -> bytes:
"""Sort key enforcing Blaze's ascending-tag member ordering."""
return encode_tag(label)
# ---------------------------------------------------------------- varint
def encode_varint(value: int) -> bytes:
"""Heat2 varint: 6 data bits in byte 0 (0x80=more), 7 bits thereafter."""
neg = value < 0
v = -value if neg else value
first = v & 0x3F
v >>= 6
if neg:
first |= 0x40 # UNVERIFIED sign convention
if v == 0:
return bytes((first,))
out = bytearray((first | 0x80,))
while v >= 0x80:
out.append((v & 0x7F) | 0x80)
v >>= 7
out.append(v)
return bytes(out)
def decode_varint(buf: bytes, i: int):
"""-> (value, next_index)"""
b = buf[i]
i += 1
val = b & 0x3F
neg = bool(b & 0x40)
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 if neg else val), i
# ---------------------------------------------------------------- encoder
def _enc_value(typ: int, value, out: bytearray) -> None:
if typ == INT:
out += encode_varint(int(value))
elif typ == STRING:
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
raw = raw.rstrip(b"\x00")
out += encode_varint(len(raw) + 1)
out += raw
out += b"\x00"
elif typ == BLOB:
raw = bytes(value)
out += encode_varint(len(raw))
out += raw
elif typ == STRUCT:
_enc_struct_body(value, out)
out += b"\x00"
elif typ == LIST: # UNVERIFIED
etype, items = value
out.append(etype & 0xFF)
out += encode_varint(len(items))
for it in items:
_enc_value(etype, it, out)
elif typ == MAP: # UNVERIFIED
ktype, vtype, items = value
out.append(ktype & 0xFF)
out.append(vtype & 0xFF)
out += encode_varint(len(items))
for k, v in items:
_enc_value(ktype, k, out)
_enc_value(vtype, v, out)
elif typ == UNION: # UNVERIFIED
key, member = value
out.append(key & 0xFF)
if key != UNION_UNSET and member is not None:
mtag, mtype, mval = member
out += encode_tag(mtag)
out.append(mtype & 0xFF)
_enc_value(mtype, mval, out)
elif typ == VARLIST: # UNVERIFIED
out += encode_varint(len(value))
for n in value:
out += encode_varint(int(n))
elif typ == OBJTYPE: # UNVERIFIED
comp, t = value
out += encode_varint(comp)
out += encode_varint(t)
elif typ == OBJID: # UNVERIFIED
comp, t, oid = value
out += encode_varint(comp)
out += encode_varint(t)
out += encode_varint(oid)
elif typ == FLOAT: # UNVERIFIED
out += struct.pack(">f", float(value))
else:
raise ValueError("cannot encode unknown TDF type 0x%02x" % typ)
def _enc_struct_body(fields, out: bytearray) -> None:
"""Serialize members in ascending packed-tag order (Blaze requirement)."""
if isinstance(fields, dict):
items = list(fields.items())
else: # allow [(tag, (type, value)), ...]
items = list(fields)
items.sort(key=lambda kv: tag_key(kv[0]))
for tag, tv in items:
typ, val = tv
out += encode_tag(tag)
out.append(typ & 0xFF)
_enc_value(typ, val, out)
def encode_tdf(fields) -> bytes:
"""Serialize a top-level TDF struct body (no trailing 0x00 terminator)."""
out = bytearray()
_enc_struct_body(fields, out)
return bytes(out)
# convenient aliases
build_tdf = encode_tdf
encode_struct = encode_tdf
# ---------------------------------------------------------------- decoder
def _dec_value(buf: bytes, i: int, typ: int):
if typ == INT:
return decode_varint(buf, i)
if typ == STRING:
ln, i = decode_varint(buf, i)
raw = buf[i:i + ln]
i += ln
return raw.rstrip(b"\x00").decode("utf-8", "replace"), i
if typ == BLOB:
ln, i = decode_varint(buf, i)
return bytes(buf[i:i + ln]), i + ln
if typ == STRUCT:
return _dec_struct_body(buf, i, terminated=True)
if typ == LIST:
etype = buf[i]; i += 1
n, i = decode_varint(buf, i)
items = []
for _ in range(n):
v, i = _dec_value(buf, i, etype)
items.append(v)
return (etype, items), i
if typ == MAP:
ktype = buf[i]; i += 1
vtype = buf[i]; i += 1
n, i = decode_varint(buf, i)
items = []
for _ in range(n):
k, i = _dec_value(buf, i, ktype)
v, i = _dec_value(buf, i, vtype)
items.append((k, v))
return (ktype, vtype, items), i
if typ == UNION:
key = buf[i]; i += 1
if key == UNION_UNSET:
return (key, None), i
mtag = decode_tag(buf[i:i + 3]); mtype = buf[i + 3]; i += 4
mval, i = _dec_value(buf, i, mtype)
return (key, (mtag, mtype, mval)), i
if typ == VARLIST:
n, i = decode_varint(buf, i)
out = []
for _ in range(n):
v, i = decode_varint(buf, i)
out.append(v)
return out, i
if typ == OBJTYPE:
c, i = decode_varint(buf, i)
t, i = decode_varint(buf, i)
return (c, t), i
if typ == OBJID:
c, i = decode_varint(buf, i)
t, i = decode_varint(buf, i)
o, i = decode_varint(buf, i)
return (c, t, o), i
if typ == FLOAT:
return struct.unpack(">f", buf[i:i + 4])[0], i + 4
raise ValueError("cannot decode unknown TDF type 0x%02x at %d" % (typ, i))
def _dec_struct_body(buf: bytes, i: int, terminated: bool, end: int = None):
"""Read fields until 0x00 terminator (nested) or `end` (top level)."""
if end is None:
end = len(buf)
fields = OrderedDict()
while i < end:
if terminated and buf[i] == 0x00:
i += 1
break
tag = decode_tag(buf[i:i + 3])
typ = buf[i + 3]
i += 4
val, i = _dec_value(buf, i, typ)
fields[tag] = (typ, val)
return fields, i
def decode_tdf(payload: bytes):
"""Decode a top-level TDF payload -> OrderedDict {tag: (type, value)}."""
fields, _ = _dec_struct_body(payload, 0, terminated=False)
return fields
# ---------------------------------------------------------------- Fire2
FIRE2_HEADER_LEN = 16
MSG_PING = 0x01
MSG_REQUEST = 0x02
MSG_RESPONSE = 0x03 # also seen as pong
MSG_NOTIFY = 0x04 # UNVERIFIED
MSG_ERROR = 0x05 # UNVERIFIED
def build_fire2_frame(component: int, command: int, msgType: int,
msgId: int, tdf_bytes: bytes) -> bytes:
"""16-byte big-endian Fire2 header + TDF payload."""
tdf_bytes = bytes(tdf_bytes)
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
b"\x00\x00\x00")
return hdr + tdf_bytes
def parse_fire2_frame(data: bytes):
"""-> (dict header, bytes payload). Raises if the buffer is short."""
if len(data) < FIRE2_HEADER_LEN:
raise ValueError("short Fire2 frame")
(ln, zero, comp, cmd, msgid, mtype, reserved) = struct.unpack(
">IHHHHB3s", data[:FIRE2_HEADER_LEN])
payload = data[FIRE2_HEADER_LEN:FIRE2_HEADER_LEN + ln]
if len(payload) != ln:
raise ValueError("truncated Fire2 payload: want %d have %d"
% (ln, len(payload)))
hdr = {
"length": ln, "zero": zero, "component": comp, "command": cmd,
"msgId": msgid, "msgType": mtype, "reserved": reserved,
}
return hdr, payload
def decode_fire2(data: bytes):
"""-> (header dict, decoded TDF OrderedDict)"""
hdr, payload = parse_fire2_frame(data)
return hdr, decode_tdf(payload)
def encode_fire2(hdr: dict, fields) -> bytes:
"""Inverse of decode_fire2 (uses hdr's component/command/msgType/msgId)."""
return build_fire2_frame(hdr["component"], hdr["command"],
hdr["msgType"], hdr["msgId"], encode_tdf(fields))
# ---------------------------------------------------------------- pretty
def dump(fields, depth: int = 0) -> str:
pad = " " * depth
lines = []
for tag, (typ, val) in fields.items():
tn = TYPE_NAMES.get(typ, "0x%02x" % typ)
if typ == STRUCT:
lines.append("%s%s (struct) {" % (pad, tag))
lines.append(dump(val, depth + 1))
lines.append("%s}" % pad)
elif typ == BLOB:
lines.append("%s%s (blob[%d]) = %s" % (pad, tag, len(val), val.hex()))
else:
lines.append("%s%s (%s) = %r" % (pad, tag, tn, val))
return "\n".join(lines)
# ---------------------------------------------------------------- self-test
CAPTURE = ("/home/alex/Documents/OpenFUT/fifa17-recon/captures/blaze/"
"blaze_fire2_37161.bin")
def _selftest(path: str = CAPTURE) -> bool:
ok = True
# unit: tag packing
for lbl in ("CDAT", "CINF", "FCCR", "LADD", "ENV", "LOC", "BSDK", "PTVR"):
enc = encode_tag(lbl)
assert decode_tag(enc) == lbl, (lbl, enc.hex())
assert encode_tag("CDAT") == bytes.fromhex("8e4874"), encode_tag("CDAT").hex()
assert encode_tag("ENV") == bytes.fromhex("96ed80"), encode_tag("ENV").hex()
assert encode_tag("LADD") == bytes.fromhex("b21924"), encode_tag("LADD").hex()
# unit: varint
assert encode_varint(0) == b"\x00"
assert encode_varint(4) == b"\x04"
assert encode_varint(0x3F) == b"\x3f"
assert encode_varint(0x40) == bytes.fromhex("8001")
assert encode_varint(0x656E5553) == bytes.fromhex("93d5f2d60c")
for n in (0, 1, 63, 64, 127, 128, 8191, 0x656E5553, 2**40, 2**63 - 1):
v, j = decode_varint(encode_varint(n), 0)
assert v == n and j == len(encode_varint(n)), n
# round trip the real capture
original = open(path, "rb").read()
hdr, payload = parse_fire2_frame(original)
fields = decode_tdf(payload)
re_payload = encode_tdf(fields)
re_frame = encode_fire2(hdr, fields)
print("header:", hdr)
print(dump(fields))
print()
print("payload %d -> %d bytes" % (len(payload), len(re_payload)))
print("frame %d -> %d bytes" % (len(original), len(re_frame)))
if re_frame == original:
print("ROUND-TRIP: PASS (byte-identical, %d bytes)" % len(original))
else:
ok = False
print("ROUND-TRIP: FAIL")
n = min(len(re_frame), len(original))
for k in range(n):
if re_frame[k] != original[k]:
print(" first diff at 0x%04x: got %02x want %02x"
% (k, re_frame[k], original[k]))
print(" got %s" % re_frame[max(0, k - 8):k + 16].hex())
print(" want %s" % original[max(0, k - 8):k + 16].hex())
break
else:
print(" length differs only: %d vs %d" % (len(re_frame), len(original)))
return ok
if __name__ == "__main__":
import sys
p = sys.argv[1] if len(sys.argv) > 1 else CAPTURE
raise SystemExit(0 if _selftest(p) else 1)
+67
View File
@@ -0,0 +1,67 @@
# Agent brief — reverse the FIFA17 first-party auth-request ENQUEUE path (clean-room)
## Clean-room rule (HARD)
Derive everything ONLY from the decrypted `.bin`/`.asm` dumps in THIS directory (dumped
from our own running FIFA17.exe via /proc/PID/mem) + arithmetic. NEVER use leaked EA source.
## The goal
FIFA never completes online login. Root cause, localized to instruction level:
`FifaOnline::FirstPartyAuthTokenRetriever::DoTick` @ **0x146f199c0** runs every frame:
```
146f199cd lea rbx,[rcx+0x8] ; rbx = &authRequestQueueHead (rcx = the retriever object)
146f199e0 mov rsi,[rbx] ; rsi = queue head node ptr (ALWAYS 0 live)
146f199e3 test rsi,rsi
146f199e6 je 0x146f19ae1 ; head==NULL -> exit, does nothing
146f199f6 call 0x1470da6d0 ; else process the node
```
The queue head (`retriever+0x8`) is null forever -> no GetAuthCode -> no token -> no Blaze
login -> "Unable to retrieve account information". Live-confirmed: entering FUT / Online
Seasons NEVER enqueues a node (the slot stays 0). We can WRITE FIFA memory (ptrace_scope=0).
**DELIVERABLE: everything needed to FORGE a `FirstPartyAuthCodeFutureImpl` node, write it into
FIFA's memory, and set `retriever+0x8` to point at it — so DoTick processes it and fires
`GetAuthCode` over LSX.** Plus: identify what SHOULD enqueue it (and why FIFA skips that), as a
cross-check.
## Live object layout (this instance; pointers are per-run, offsets are stable)
- retriever object @ `*[0x1448a3b20] + 0x4e98` = live `0x43dc8d08`.
- `+0x00` = vtable ptr `0x1438f5d50`
- `+0x08` = auth-request queue HEAD (the null slot DoTick reads) [live addr 0x43dc8d10]
- `+0x10` = `0x0` [live 0x43dc8d18]
- retriever vtable @ `0x1438f5d50`, first 9 slots are methods (rest is .rdata RTTI/strings):
`[0]0x146f02960 [1]0x147e8f160 [2]0x147e1c480 [3]0x146f028d0 [4]<data> [5]0x1471a0630`
`[6]0x1466cc0d0 [7]0x147104dc0 [8]0x146f009e8` then `[28]0x146ceaeb0 [29]0x146f60060`
`[30]0x146f5f8b0 [31]0x146f3eea0`. RTTI strings embedded there decode to:
`"FifaOnline::FirstPartyAuthCodeFutureImpl"`, `"FifaOnline::FirstPartyAuthTokenRetriever::DoTick"`,
`"[%s] Invalid authcode"`, `"[%s] Origin Error(%d)"`.
## Dumps available here (objdump Intel, VMA==runtime VA; grep/Read these)
- `dotick_full_146f199c0.bin.asm` DoTick full (queue iteration + node field reads + call 0x1470da6d0)
- `origin_getdefaultuser_1470da6d0.bin.asm` the node PROCESSOR called by DoTick (0x1470da6d0)
- `origin_getdefaultpersona_1470da680.bin.asm`
- `authcode_sync_full_1470db3c0.bin.asm` OriginRequestAuthCodeSync (0x1470db3c0) — builds/sends LSX GetAuthCode
- `origin_authcode_req_1470da600.bin.asm`
- `vt00_*.asm``vt08_*.asm`, `vt28_*``vt31_*` the retriever's vtable methods (one ENQUEUES; find it)
- `dispatch_case2_*`, `event_matcher_*`, `login_parser_*` (Origin LSX event path, context)
- `auth_block_43dc8d08.bin` (raw retriever region snapshot), `manifest.txt`
## Method (these are decrypted normal x86-64; objdump works directly)
`objdump -D -b binary -m i386:x86-64 -M intel --adjust-vma=0xVA file.bin` for any window.
To dump MORE decrypted code from the LIVE game (pid in manifest.txt / `pgrep -x FIFA17.exe`),
read /proc/PID/mem (ptrace_scope=0): `f.seek(va); f.read(n)` then objdump with --adjust-vma.
Follow call targets you need (e.g. what 0x1470da6d0 calls, the ctor of FirstPartyAuthCodeFutureImpl).
Every claim needs a VA / byte / disasm excerpt.
## Key questions to answer
1. **Node struct (`FirstPartyAuthCodeFutureImpl`)**: exact field layout DoTick + 0x1470da6d0 read
— the `next` pointer (linked-list link), vtable, state/status field, ClientId/Scope inputs,
result/callback fields. Enough to FORGE a minimal valid node.
2. **DoTick processing**: after `rsi=head`, what does it read from `[rsi+...]`? Does it walk a
linked list (`next` at some offset)? What does 0x1470da6d0 do with the node + the out-params
at `[rsp+0x58]`/`[rsp+0x60]` (r9/r8)? Where does it call OriginRequestAuthCodeSync / send GetAuthCode?
3. **Enqueue method**: which retriever vtable slot (vt00-08/28-31) appends a node to `+0x8`?
What is its signature? Who calls it (the online-login-initiate path) and why is it skipped?
4. **OriginRequestAuthCodeSync (0x1470db3c0)**: its signature + what it needs to send the LSX
`<GetAuthCode ClientId Scope>` request. Could we call it DIRECTLY (gdb `call`) as a shortcut?
5. **The forge-and-trigger plan**: exact bytes to write, where to allocate the node, what to set
`retriever+0x8` to, and what we expect to observe (GetAuthCode on /tmp/lsx.log). Flag crash risks.
+55
View File
@@ -0,0 +1,55 @@
# Agent brief — reverse the FIFA17 Origin-SDK CONNECT handshake + Blaze login trigger (clean-room)
## Clean-room rule (HARD)
Derive ONLY from the decrypted `.bin`/`.asm` dumps here + live /proc/PID/mem (pid via
`pgrep -x FIFA17.exe`, currently 13643) + our own LSX/Blaze logs. NEVER leaked EA source.
## Story so far (established this session, all live-verified)
The online-login is a coordinated chain: **Origin SDK connect → default user → auth code → Blaze login → account info.** We forced individual pieces; each got us one layer deeper and revealed the next:
- `m_isLoggedIn` (OriginMgr+0x13) is NOT the gate (forcing it from boot changed nothing).
- `DoTick @0x146f199c0` (FirstPartyAuthTokenRetriever) polls a 2-slot queue at `retriever+0x8`
(`retriever = *[0x1448a3b20]+0x4e98`); empty → never requests auth.
- We FORGED a `FirstPartyAuthCodeFutureImpl` node (0xF0 bytes, see docs/ENQUEUE_PLAN.md) + set
`OriginSDK(*[0x144b7c7a0])+0x3a0` default-user, and `DoTick` processed it and called
`OriginRequestAuthCodeSync` (impl `0x1470e67f0`). **It DID send `<GetAuthCode ClientId="FIFA17PC">`
over LSX and we answered with an AuthCode** — BUT the node also logged Origin Error `0xa2080000`,
and crucially **Blaze `Authentication::login (1/0x0A)` STILL never fired (count 0); FIFA only ever
sends `Authentication::logout (1/0x46)`.**
- The `0xa2080000` originates inside `0x1470e67f0`: after the arg-guards pass, it looks up Origin
**service/interface id `0x1e8`** via thunk `0x1470dbfa0``[0x144b7c778]` (dispatcher `0x1470d9380`),
which returns NULL → **the Origin SDK is not truly CONNECTED.**
## The two questions to crack
1. **What makes the Origin SDK "connected"** — i.e. what writes `OriginSDK+0x3a0` (default user) and
registers service `0x1e8`? The writer is `0x1470e5ad5` (dump `sdk_connect_writer_1470e5980.asm`),
guarded by a ~15s connect-wait loop at `0x147118d80` (dump `connect_wait_guard_147118c00.asm`,
literal "EbisuSDK" nearby). **What LSX exchange / event does that wait poll for, that our responder
is not providing?** If we answer it, the SDK connects and +0x3a0 + service 0x1e8 populate naturally.
2. **Why does FIFA send Blaze `logout` not `login`** even when an auth code is available? Reverse the
Blaze LoginManager's decision to call `Authentication::login (1/0x0A)` — what precondition it checks
(likely a connected Origin session + a valid client-config `[cfg+0x750]`, which our empty
`fetchClientConfig` never populates — the Blaze-SDK auth fetchers `0x147237350`/`0x147237450`,
dumps `blaze_authfetch1/2`, bail on exactly that).
## Dumps here (decrypted; objdump Intel, VMA==runtime VA)
- `sdk_connect_writer_1470e5980.bin.asm` — the +0x3a0/+0x3a8 writer (0x1470e5ad5) + surrounding connect fn
- `connect_wait_guard_147118c00.bin.asm` — the ~15s connect-wait guard (0x147118d80)
- `svc_dispatch_1470d9380.bin.asm` — the service-lookup dispatcher (thunk target; id 0x1e8)
- `blaze_authfetch1_147237350.bin.asm`, `blaze_authfetch2_147237450.bin.asm` — Blaze-SDK auth fetchers ([cfg+0x750] bail)
- `authcode_impl_1470e67f0.bin.asm` — OriginRequestAuthCodeSync impl (guards + service lookup + send 0x1470e1ed0)
- `dotick_full_146f199c0.bin.asm`, plus earlier: origin_getdefaultuser, vt00-31, dispatch/matcher/parser, ENQUEUE_PLAN.md
You MAY dump more live decrypted code (ptrace_scope=0): `f.seek(va); f.read(n)` + objdump --adjust-vma.
Follow callers/callees as needed (e.g. what calls 0x1470e5ad5; what registers service 0x1e8; the Blaze login-state fn).
## Our current LSX answers (what FIFA sends → we reply) — for correlation
FIFA boot verbs on :4216: GetConfig, GetProfile(index=0), GetSetting(IS_IGO_ENABLED/ENVIRONMENT/
IS_IGO_AVAILABLE/LANGUAGE), GetGameInfo(FREETRIAL/LANGUAGES/UPTODATE), GetInternetConnectedState,
IsProgressiveInstallationAvailable, SetDownloaderUtilization. We answer all (see tools/lsx_responder_v2.py
build_reply). Our Blaze fetchClientConfig currently returns near-empty (SV_* keys only). **A verb or
response we're getting WRONG/EMPTY is the likely reason the SDK never connects — find it.**
## Deliverable
Concrete, ranked, SERVER-SIDE changes (LSX responder verbs/responses + Blaze fetchClientConfig content)
that make: (1) the Origin SDK connect (populate +0x3a0 + service 0x1e8), and (2) FIFA send Blaze
`login (1/0x0A)`. For each: the exact LSX/Blaze message + expected observable (service 0x1e8 non-null;
Authentication::login on /tmp/blaze_responder.log). Note anything still needing a live experiment.
+53
View File
@@ -0,0 +1,53 @@
# Agent brief — reverse the FIFA17 OSDK_UNDERAGE_ERROR (Origin error 0xa2000012) (clean-room)
## Clean-room rule (HARD)
Derive ONLY from decrypted dumps here + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 39211) +
our LSX/Blaze logs. NEVER leaked EA source.
## Where we are (this session's WIN)
The recipient-echo fix (lsx_responder_v2.py: response `sender` must byte-equal request `recipient`)
CASCADED the whole Origin login chain. Live-confirmed NATURAL (no forging): OriginSDK(*[0x144b7c7a0])
+0x3a0/+0x3a8 = 0x1f89493 (default user set), FIFA issued GetAuthCode ×3 on its own (FIFA17PC +
FIFA17PC-SERVER), SetPresence flowing ("In Menus"). The OSDK login state machine advanced
OSDK_INVALID_USER → **OSDK_UNDERAGE_ERROR**. On screen: "not eligible to use EA's online features due
to an age restriction". Blaze login (1/0x0A) still 0 — this age gate blocks before it.
## The exact gate
OSDK classifier **0x14717d5d0** (dump osdk_classifier_14717d5d0.bin.asm): reads the OSDK/Ebisu
manager's cached "last error" and maps it:
`cmp eax,0xa2000003 -> OSDK_INVALID_USER` (we cleared this — user now valid)
`cmp eax,0xa2000012 -> OSDK_UNDERAGE_ERROR` (the CURRENT error) <-- the target
The error code comes from the manager chain: manager `*[0x144b86bf8]` = live 0x43c46c70,
vptr **0x143959168**; classifier does `mov rcx,[0x144b86bf8]; mov rax,[rcx]; call [rax+0x60]` (=
**0x14719b1b0**, dump mgr_vt60_14719b1b0.bin.asm) -> rsi (a sub-object); then `rsi->vt[0x68]()` ->
the error code = 0xa2000012.
## What is ALREADY RULED OUT (do not re-chase)
- NOT from GetProfile: the IsUnderAge bool-parse (helper 0x14713ffa0: strconv result -> `setne al`,
so "false"->0 = not-underage) stores our IsUnderAge="false" correctly. GetProfile deserializer
0x147136140 reads only: UserId PersonaId Persona AvatarId Country IsUnderAge IsSubscriber
GeoCountry CommerceCountry CommerceCurrency — no DOB/age field.
- 0xa2000012 is NOT constructed by inline mov+lea/add arithmetic ANYWHERE in 0x146000000-0x1476f0000
(scanned, 0 sites). So it is a DATA-TABLE value (scan DATA regions for the raw dword `12 00 00 a2`
= an error-map table), or a value read from a Nucleus/HTTP/Blaze response, or a field.
- Nucleus :42131 was NOT hit this boot (no live conn) and Blaze login=0 — so the underage was cached
by an Origin/LSX operation during boot, before Blaze/Nucleus. (Confirm; don't assume.)
## The questions to answer
1. **Which sub-object does manager->vt[0x60] (0x14719b1b0) return, and where is its error field
(read by vt[0x68]) WRITTEN with 0xa2000012?** Find the writer = the operation that decided underage.
2. **What is 0xa2000012's source** — locate the raw dword in a data/error-map table (scan .rdata/.data
for `12 00 00 a2`), find the code that selects it, and what INPUT maps to it (an HTTP/Nucleus status?
a profile/account field? an entitlement? a hardcoded default when age is unverified?).
3. **What CONDITION makes FIFA underage** despite IsUnderAge="false"? Trace back from the writer to the
input we control (an LSX verb/field we answer wrong or omit, a Blaze reply, a missing DOB, an
entitlement/age-rating check). We own every server endpoint FIFA talks to.
4. **The fix**: concrete LSX/Blaze/Nucleus responder change (verb/field/value) that makes FIFA classify
the user as an adult, so OSDK advances past OSDK_UNDERAGE_ERROR toward Blaze login.
## Dumps / method
osdk_classifier_14717d5d0, mgr_vt60_14719b1b0, getprofile_deser_147136140, boolparse_ffa0,
authcode_impl_1470e67f0, sdk_connect_writer, + earlier auth/connect dumps. objdump Intel, VMA==runtime VA.
Dump more live: `f.seek(va); f.read(n)` + objdump --adjust-vma. Data-dword scan: read region, `d.find(b'\x12\x00\x00\xa2')`.
Every claim needs a VA/bytes/disasm/log line. We can WRITE /proc/mem too (A/B: poke a candidate field, watch
OSDK state 0x43d189d8+0x80 [re-find via vptr 0x14395c180] leave "OSDK_UNDERAGE_ERROR").
+58
View File
@@ -0,0 +1,58 @@
# Agent brief — reverse "Unable to connect to the EA servers" (post-login Blaze QoS/online gate) (clean-room)
## Clean-room rule (HARD)
Derive ONLY from decrypted dumps + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 50676) + our
LSX/Blaze logs. NEVER leaked EA source/headers. (Our own binary carries the RTTI symbols we need.)
## Where we are — the WIN, and the new gate
This session cracked the ENTIRE Origin+Blaze LOGIN chain (each gate = a 1-attribute LSX-responder fix,
last one `AuthCode value=`). FIFA now performs **Blaze Authentication::login (1/0x0A)** and the full
post-login handshake (postAuth, UserSessions, AssociationLists, Stats, etc.) — CONFIRMED in
/tmp/blaze_responder.log at 16:38. New on-screen error: **"Unable to connect to the EA servers at this
time."** (age/underage gate is GONE.)
## The exact behaviour (from /tmp/blaze_responder.log)
- **Session 1 (16:38, boot login):** getServerInstance('fifa-2017-pc') -> Blaze conn -> preAuth ->
**login -> FULL online handshake**, then STABLE ~2.5 min of pings. WORKS.
- **Session 2 (16:40:34, user goes online):** getServerInstance('fifa-2017-pc', SAME service) -> new
Blaze conn -> we send the SAME preAuth reply (772B) -> **FIFA CLOSES the connection IMMEDIATELY,
never sends login** -> "unable to connect". `[16:40:34] BLAZE (...): closed` right after
`TX #4009.0 Util::preAuth`.
- So a **post-preAuth check on the online-feature connection fails** (session 1's login path doesn't run
this check; session 2 does, and drops before login).
## Top suspect: QoS / ping sites (our preAuth QOSS is under-populated)
Our preAuth reply's `QOSS` (blaze_responder_v3b.py `qos_config()` ~:527) advertises:
- `BWPS { PSA="127.0.0.1", PSP=17502 }` — a QoS ping server at 127.0.0.1:**17502** we DO NOT serve.
- `LTPS` (latency ping-site map) = **EMPTY**. `LNP=10`, `TIME=5000000`.
CONF has enableQosBandwidthTest=false / enableQosFirewallTest=false, but the **ping-site LATENCY** path
may still run on the online connection. Blaze-SDK RTTI in our image (live-read) proves the subsystem:
`SetPingSiteLatency`@0x14395c880, `GetBestPingSiteAlias`@0x14398ab20, `SetBestPingSite`@0x14398ab38,
`RestorePingSiteLatencyValues`@0x14398ab48, `GetPingSiteAliasList`@0x143990d78,
`GetBestPingSiteAliasForClubs`@0x143990da8, `PinQosError_ReferenceEvent`@0x1439de5c8,
`POW:sConnectionManager`@0x143995b88. Hypothesis: with an EMPTY ping-site list FIFA cannot compute a
best ping site / QoS fails -> the connection is torn down before login -> "unable to connect".
## Questions to answer
1. **WHY does FIFA close the 2nd Blaze connection right after preAuth (before login)?** Reverse the
Blaze-SDK preAuth-RESPONSE handler and what it does with `QOSS` (parse ping sites, kick a QoS probe,
validate the ping server). Find the tear-down/"unable to connect" decision and its precondition.
Anchors: the RTTI names above (find their vtables/methods), the connection manager `POW:sConnectionManager`,
and `PinQosError`. The Blaze-SDK code region is ~0x146d00000-0x147000000 (login stub 0x146e15070,
sendRequest 0x146df0e80, LoginState vtables 0x14389f5a0/0x14389f828/938/a70/b98).
2. **Is it the QoS ping-site latency probe?** Does FIFA try to reach 127.0.0.1:17502 (TCP/UDP) or need a
non-empty `LTPS`? Trace `SetPingSiteLatency`/`GetBestPingSiteAlias` and where a missing ping site
aborts the connect. (Session 1 didn't hit it; session 2 does — WHY the asymmetry? maybe session 2
requests a QoS-gated component/service.)
3. **What does session 2 actually want** vs session 1 — re-check the getServerInstance requests and the
post-preAuth client behaviour. If it's a genuinely different connection purpose, name it.
4. **The fix**: concrete blaze_responder_v3b.py change — populate `LTPS` with >=1 reachable ping site
(and/or serve the QoS ping server on :17502), or whatever the reverse shows FIFA needs. Give the exact
QOSS/preAuth content + the observable success signal (session 2 proceeds to login instead of closing).
## Method / dumps
objdump Intel, VMA==runtime VA. Dump more live: f.seek(va);f.read(n) + objdump --adjust-vma. Our Blaze
responder: blaze_responder_v3b.py (preAuth builder, qos_config, fetchQosConfig cmd 0x15). Logs:
/tmp/blaze_responder.log (both sessions), /tmp/lsx.log. To reproduce session 2 live, the user would need
to re-enter online — but static RE of the preAuth handler + QoS path should answer it. Every claim needs
a VA/bytes/disasm/log line.
+48
View File
@@ -0,0 +1,48 @@
# Agent brief — implement Blaze Component 0x000A / cmd 0x0005 (FUT hang) (clean-room)
## Clean-room rule (HARD)
Derive ONLY from decrypted FIFA17.exe dumps + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 66672) +
our /tmp/blaze_responder.log + blaze_responder_v3b.py. NEVER leaked EA source/headers.
## Where we are — the WIN, and the new hang
This session cracked the ENTIRE FIFA17 login+connection gauntlet (each gate = a one-attr/one-value/one-type
responder fix). FIFA now: connects, Authentication::login (accepted), full post-login online handshake,
and the go-online connection STAYS ALIVE (the CONF-duration "30s" fix cleared "unable to connect").
**NEW HANG:** trying to enter FUT, FIFA spams **Component 0x000A / cmd 0x0005 — 11,459 times** in a tight
retry loop, because our Blaze responder has NO handler for component 0x000A and replies EMPTY (16-byte
REPLY, 0 payload) → FIFA is not satisfied → immediate resend → hang on a "connecting to FUT" spinner.
## The exact RPC (decoded from /tmp/blaze_rx/)
- Fire2 frame: component=**0x000A**, command=**0x0005**, msgType=MESSAGE, userIndex=0.
- Request payload (5 bytes) decodes via our heat2 to a single field: **`RSUB` (int) = 1**.
(RSUB tag = 6-bit-packed; value 1.)
- We reply: `TX ... msgType=REPLY 16B total (0 payload)` — EMPTY. FIFA rejects/retries.
- Our responder's known components (blaze_responder_v3b.py ~:193): 0x0001 Auth, 0x0004 GameManager,
0x0005 Redirector, 0x0007 Stats, 0x0009 Util, 0x000F Messaging, 0x0019 AssocLists, 0x001C
GameReporting, 0x7802 UserSessions. **0x000A is NOT handled.** (Blaze framework: 0x000A is commonly
CensusData; confirm from FIFA's own component registry, do not assume.)
## Questions to answer
1. **What is component 0x000A and cmd 0x0005?** Find FIFA's Blaze component/command REGISTRY (the
reflection tables mapping component id -> name and cmd id -> name + request/response TDF class). Name
the component (CensusData? a FIFA/OSDK component?) and cmd 0x0005 (subscribe? getData? a poll?).
Region hint: FifaOnline::* reflection names live ~0x143900000; the Blaze SDK component dispatch is
~0x146d00000-0x147000000. RSUB is a request field — find the request TDF class with an RSUB member.
2. **What RESPONSE TDF does FIFA's decoder for 0x000A/0x0005 expect**, and what field/value makes FIFA
STOP retrying (proceed into FUT) vs an empty reply? Reverse the client-side response handler /
the code that reacts to this RPC's reply. Is it a poll-until-a-flag, a subscription ack, a
count/list it needs non-empty, or an error-retry on our malformed/empty TDF?
3. **Why 11,459 immediate retries?** Is our EMPTY reply being decoded as an error (so FIFA retries), or
is FIFA polling a status until a specific value appears? Determine the minimal reply that ends the loop.
4. **The fix**: the exact blaze_responder_v3b.py handler — register component 0x000A, handle cmd 0x0005,
return the correct REPLY TDF (fields + values). Give the Heat2/TDF structure precisely (tags, types,
values) and the observable success signal (FIFA stops resending 0x000A/0x0005 and issues the NEXT
new RPC / reaches the FUT screen).
## Method / dumps
Our Blaze responder: blaze_responder_v3b.py (component dispatch, reply_to(), encode_tdf(), heat2.py).
Heat2 TDF codec: heat2.py (tags 4-char 6-bit packed; types 0 int,1 str,2 blob,3 struct,4 list,5 map,6 union).
Saved requests: /tmp/blaze_rx/rx_*_000a_0005.bin (21 bytes: 16 hdr + 5 payload). Log: /tmp/blaze_responder.log.
Dump FIFA live: /proc/PID/mem, objdump --adjust-vma. To find the response schema, locate cmd 0x0005's
response TDF class via FIFA's component reflection, or reverse the client handler that consumes the reply.
Every claim needs a VA/bytes/disasm/log line. CLEAN-ROOM: our binaries+logs only.
+63
View File
@@ -0,0 +1,63 @@
# Agent brief — FUT loading-screen QUIET STALL (what is FIFA waiting for?) (clean-room)
## Clean-room rule (HARD)
Derive ONLY from decrypted FIFA17.exe dumps + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 66672)
+ /tmp/blaze_responder.log + /tmp/blaze_rx/ + blaze_responder_v3b.py. NEVER leaked EA source/headers.
## Where we are — the WIN, and this stall
This session cracked the ENTIRE FIFA17 login+connection+FUT-service-init chain (8 gates, each a
one-attribute/value/type/RPC responder fix). FIFA now: logs in, holds a live Blaze session, and after
selecting FUT reached the **Ultimate Team LOADING screen** (stadium + "17", NO error). The last fix
(CensusData subscribe) stopped a 71k-retry storm and let FIFA run its FUT-init RPC burst. **NOW FIFA
IS QUIET-STALLED on the loading screen: idle-pinging Blaze every 20s, NOT storming, NOT reaching any
new endpoint (no SYN-SENT to UTAS/fut.ea.com), both LSX:4216 + Blaze:42130 conns STABLE.** It ran its
init burst, took our (mostly EMPTY) replies, and is WAITING for something to complete the load.
**KEY ADVANTAGE: FIFA is LIVE-FROZEN at the wait point right now (pid 66672).** The FUT/OSDK
loading-state object is inspectable in memory as-is — find the flag/counter it is polling.
## The FUT-init RPC burst FIFA sent (decoded from /tmp/blaze_rx/), and how we replied
| # | RPC | request (decoded) | our reply |
|---|---|---|---|
| listEntitlements | Auth 1/0x20 | **GNLS=['FIFA17PCBoxContent','FIFA16PC']**, FLAG=3 | entitlements_response NLST=[1 entitlement, **GNAM="FIFA17PC"**, TAG=ONLINE_ACCESS] — GROUP MISMATCH? |
| getInvitations | Clubs 0x000b/0x0640 | CLID=0 INVT=0 NSOT=0 | EMPTY |
| fetchMessages | Messaging 0x000f/0x0002 | FLAG=4, TYPE=... | EMPTY |
| userSettingsLoad | Util 0x0009/0x000a | KEY='FirstTimeFlag' UID=0 | EMPTY (?) |
| userSettingsLoadAll | Util 0x0009/0x000c | (empty) | EMPTY |
| fetchSettings/Groups | OSDKSettings 0x08c9/0x01,0x02 | (empty) | EMPTY |
| getStatGroupList/getKeyScopesMap/getPeriodIds | Stats 0x0007/0x03,0x0f,0x14 | (empty) | EMPTY |
| getEventsURL | SponsoredEvents 0x081c/0x0003 | (empty) | EMPTY |
| getClubsComponentSettings | Clubs 0x000b/0x0a28 | (empty) | EMPTY |
| getInvitations, listEntitlements were the LAST real RPCs (RX #32/#33) before it went quiet. |
Our entitlement reply (blaze_responder_v3b.py entitlement_fields ~:907): GNAM="FIFA17PC",
TAG="ONLINE_ACCESS", PJID/PRID="1027460", STAT=1, TYPE=1. **The request asked for group
"FIFA17PCBoxContent" — investigate whether FUT requires an entitlement in THAT group.**
## Questions to answer
1. **What is the FUT loading screen waiting for to complete?** FIFA is live-frozen at the wait — find
the loading/OSDK state object and the flag/counter/condition it polls or blocks on. What SETS it:
a specific RPC reply's data, a server-pushed NOTIFICATION, or a count of completed async loads?
(FUT init typically fans out N async "downloaders"/loaders and waits for all to complete.)
2. **Is it the entitlements?** Reverse the listEntitlements (1/0x20) client handler: does FUT gate on an
entitlement in group 'FIFA17PCBoxContent' (the box-content / FUT-access grant)? What must our NLST
contain (GNAM value, TAG, TYPE, count) so FUT considers the user entitled? Our GNAM="FIFA17PC" may
be wrong.
3. **Is FIFA waiting on a server NOTIFICATION** (UserSessions / a FUT-ready / a Messaging / a
downloader-complete push) that a real server would send but we don't? Find which notification the
loading state consumes.
4. **Which specific empty-replied RPC (if any) is the block** — Stats getStatGroupList, OSDKSettings
fetchSettings, Messaging fetchMessages, userSettingsLoad 'FirstTimeFlag', Clubs getInvitations — does
any of them feed a required loading input? Reverse the client response handler for the load-bearing one.
5. **The fix**: the minimal blaze_responder_v3b.py change (a non-empty reply TDF for the load-bearing
RPC, a corrected entitlement group, and/or a pushed notification) that advances the loading screen.
Give exact TDF (tags/types/values) + the observable success signal (FIFA leaves the loading screen /
issues the next new RPC / connects to a FUT service).
## Method / dumps
Our responder: blaze_responder_v3b.py (dispatch, entitlement_fields, notification()). Heat2: heat2.py.
Saved requests: /tmp/blaze_rx/rx_*.bin (16B hdr + payload; decode with heat2.decode_tdf). Log:
/tmp/blaze_responder.log. FIFA live pid 66672 (frozen at the wait — inspect the loading state NOW).
FifaOnline::* reflection ~0x143900000 (SeasonalPlayDownloader, ClubsDivCountDownloader, TournamentHandler,
etc. seen there — the FUT loaders live here). Blaze SDK ~0x146d00000-0x147000000. Every claim needs a
VA/bytes/disasm/log line. CLEAN-ROOM only.
@@ -0,0 +1,428 @@
# Component 0x000A / cmd 0x0005 — CensusData::subscribeToCensusDataUpdates
**Status:** RPC contract fully resolved (reflection + live disasm + live heap read). Fix is a ~15-line
handler in `blaze_responder_v3b.py`. Not yet run against the game.
**Clean-room:** every claim below is from decrypted `FIFA17.exe` / `/proc/66672/mem`, `/tmp/blaze_rx/`,
and `/tmp/blaze_responder.log`. No EA source or headers.
**Independently re-verified for this document** (live pid 66672, not just quoted from the reports):
component-id arithmetic at `0x147e639cd`, vtable `0x143b72330`, both TDF class descriptors + member
tables at `0x144c11d70` / `0x144c11dc0`, the reply handler `0x147e57050`, the resend functor
`0x147e57380`, the scheduler zero-delay branch `0x146dbae7c`, the live API object at `0x43c70ff0`,
and the exact wire bytes through `heat2.py`.
---
## 1. THE RPC CONTRACT
### 1.1 Component 0x000A = `Blaze::CensusData` ("CensusDataComponent")
The component constructor at `0x147e63970` materialises the id with FIFA17's usual split-immediate
obfuscation, then installs the component vtable:
```
147e639cd: b9 0f a2 ff e7 mov ecx,0xe7ffa20f
147e639d2: 8d 89 fb 5d 00 18 lea ecx,[rcx+0x18005dfb] ; 0xe7ffa20f+0x18005dfb = 0x1_0000000A
147e639d8: 66 89 48 10 mov WORD PTR [rax+0x10],cx ; componentId = 0x000A
147e639dc: 48 8d 0d 4d e9 d0 fb lea rcx,[rip+...] ; # 0x143b72330 (component vtable)
```
Live read of vtable `0x143b72330` and its strings:
| slot | VA | returns |
|---|---|---|
| +0x08 | `0x147e641d0` | `getComponentName``"CensusDataComponent"` @`0x143b72368` |
| +0x10 | `0x147e64170` | `getCommandName` |
| +0x18 | `0x147e645e0` | `getNotificationName` |
| +0x20 | `0x147e64250` | `getErrorName` |
| +0x30 | `0x147e64710` | notification dispatcher |
Allocation tag `"CENSUSDATAInstance"` @`0x143b72380`. Cross-check from `getErrorName` @`0x147e64250`:
`cmp edx,0x1000a``CENSUSDATA_ERR_PLAYER_ALREADY_SUBSCRIBED`, `cmp edx,0x2000a`
`CENSUSDATA_ERR_PLAYER_NOT_SUBSCRIBED`. Blaze error = `(index<<16)|componentId` ⇒ componentId `0x000A`.
Not an assumption from framework lore — read out of FIFA's own registry.
### 1.2 Command 0x0005 = `subscribeToCensusDataUpdates`
`getCommandName` @`0x147e64170` is a `movzx ecx,dx` + `dec/je` chain. Full CensusData command table:
| cmd | name |
|---|---|
| 0x0001 | `subscribeToCensusData` (`0x143b723c0`) |
| 0x0002 | `unsubscribeFromCensusData` (`0x143b723f8`) |
| 0x0003 | `getRegionCounts` (`0x143b723b0`) |
| 0x0004 | `getLatestCensusData` (`0x143b72398`) |
| **0x0005** | **`subscribeToCensusDataUpdates`** (`0x143b723d8`) |
Notification id `0x0001` = `NotifyServerCensusData` (`0x143b72228`, from `getNotificationName`
@`0x147e645e0`). FIFA never sends cmd 14; it goes straight to cmd 5.
### 1.3 Request TDF — `Blaze::CensusData::SubscribeToCensusDataUpdatesRequest`
Class descriptor `0x144c11d70`, tdfId `0x2975a3d9`, member table `0x144c11bc0`, 1 member (live dump):
| member | tag (dword) | wire tag | off | decl type |
|---|---|---|---|---|
| `resubscribe` | `0xcb3d6200` | `cb 3d 62` = **`RSUB`** | +0x10 | `0x0f` = `bool` |
Wire (`/tmp/blaze_rx/rx_0017_000a_0005.bin`, 21 B = 16 hdr + 5 payload):
`cb 3d 62 | 00 | 00` → tag `RSUB`, Heat2 wire type `0x00` (INT), varint `0`.
Every later capture: `cb 3d 62 00 **01**``RSUB=1`.
Note the wire type byte carries the **generic Heat2 scalar type**, not the declared C++ type — a
declared `bool` goes out as Heat2 INT. Same rule applies to our reply's `TimeValue` members.
### 1.4 Response TDF — `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse`
Class descriptor `0x144c11dc0` (header qword `0b000000 a3286174` ⇒ tdfId **`0x746128a3`**), member
table `0x144c11bf0`, count 3. Live dump, including the reflection default column:
| member | tag (dword) | wire tag | off | type | default |
|---|---|---|---|---|---|
| `censusNotificationPeriod` | `0x8eec0000` | `8e ec 00` = **`CNP `** | +0x10 | `0x0e` `TimeValue` | **0** |
| `notificationTimeout` | `0xbb4b7400` | `bb 4b 74` = **`NTMT`** | +0x18 | `0x0e` `TimeValue` | **0** |
| `resubscribeTimeout` | `0xcb4b7400` | `cb 4b 74` = **`RTMT`** | +0x20 | `0x0e` `TimeValue` | **0** |
That is the **entire** response: three int64 time values. **No status flag, no subscription id, no
list, no count.** This categorically rules out "poll until a ready flag appears" — there is nothing
in the reply that could carry readiness.
`TimeValue` is **microseconds**: `TimeValue::parse` @`0x1479b2d50` builds its internal value as
`days*365` then `*60 *60 *1000 *1000` (`imul rcx,rax,0x16d` / `imul rdx,rcx,0x3c` /
`imul rcx,rdx,0x3c` / `imul rdx,rcx,0x3e8` / `imul rax,rdx,0x3e8`) = seconds·1e6. Wire cross-check:
`Blaze::QosConfigInfo.timeout` (`'TIME'`) uses the **same** typedesc `0x14486d7b8` and its reflection
default is `0x004C4B40` = 5,000,000 — and we already send `("TIME", (INT, 5000000))` in `qos_config()`
and FIFA accepted it. So `TimeValue` on the wire = Heat2 INT varint of microseconds.
### 1.5 Why the empty reply hangs FIFA — the exact mechanism
Our 16-byte empty REPLY is **not** rejected and is **not** a decode error. It is a *successful*
zero-member struct, so the response object is left default-constructed with `CNP = NTMT = RTMT = 0`.
The reply callback is `0x147e57050`. Verified disassembly (live):
```
147e57055: f7 81 68 01 00 00 ff ff ff f7 test DWORD PTR [rcx+0x168],0xf7ffffff ; job already armed?
147e57062: 0f 85 9f 00 00 00 jne 0x147e57107 ; ...then bail
147e57075: 45 85 c0 test r8d,r8d ; r8d = BlazeError
147e57078: 75 2e jne 0x147e570a8 ; error path
; --- success path (err == 0) ---
147e5707a: 4c 8b 42 18 mov r8,QWORD PTR [rdx+0x18] ; NTMT
147e5707e: 48 8b 42 20 mov rax,QWORD PTR [rdx+0x20] ; RTMT
147e57082: 4c 03 42 10 add r8,QWORD PTR [rdx+0x10] ; += CNP
147e57086: 48 89 81 60 01 00 00 mov QWORD PTR [rcx+0x160],rax ; stash RTMT for the error path
147e5708d: 48 8d 05 ec 02 00 00 lea rax,[rip+0x2ec] ; # 0x147e57380 = resend functor
147e57099: 48 b8 cf f7 53 e3 a5 9b c4 20 movabs rax,0x20c49ba5e353f7cf ; magic /1000
147e570a3: 49 f7 e8 imul r8
; --- error path (err != 0) ---
147e570be: 48 f7 a9 60 01 00 00 imul QWORD PTR [rcx+0x160] ; saved RTMT /1000
; --- common tail ---
147e570ce: 48 c1 fa 07 sar rdx,0x7 ; => delay in MILLISECONDS
147e570ec: 89 54 24 28 mov DWORD PTR [rsp+0x28],edx ; delay arg (32-bit ms)
147e570fa: e8 e1 ee ff ff call 0x147e55fe0 ; alloc FunctorJob + schedule
147e570ff: 8b 08 mov ecx,DWORD PTR [rax]
147e57101: 89 8b 68 01 00 00 mov DWORD PTR [rbx+0x168],ecx ; store armed job id
```
So: **`delay_ms = (CNP + NTMT) / 1000`** on success; `RTMT / 1000` only on the error path.
With an empty reply that is `(0 + 0)/1000 = 0`.
A **zero** delay is special-cased into the *immediate/ready* job list rather than the timed heap —
scheduler `0x146dbae10`:
```
146dbae78: 8b 74 24 58 mov esi,DWORD PTR [rsp+0x58] ; delay ms
146dbae7c: 85 f6 test esi,esi
146dbae7e: 75 14 jne 0x146dbae94 ; nonzero -> now + delay on the timed heap
146dbae80: 49 8d 48 08 lea rcx,[r8+0x8]
146dbae84: 48 8d 57 18 lea rdx,[rdi+0x18] ; zero -> ready list
146dbae88: 40 38 77 48 cmp BYTE PTR [rdi+0x48],sil ; (mid-pump? -> next-idle list [rdi+0x38])
146dbae8e: 48 8d 57 38 lea rdx,[rdi+0x38]
```
The job that fires is `0x147e57380`, which clears the job id, constructs a fresh request and sets
**`RSUB = 1`** before re-sending through the cmd-5 proxy:
```
147e5739e: 89 b9 68 01 00 00 mov DWORD PTR [rcx+0x168],edi ; clear armed job id
147e573bf: e8 fc bf 00 00 call 0x147e633c0 ; request ctor
147e573c5: c6 84 24 80 00 00 00 01 mov BYTE PTR [rsp+0x80],0x1 ; request+0x10 = RSUB = 1
147e573cd: 48 8d 05 7c fc ff ff lea rax,[rip-0x384] ; # 0x147e57050 = reply cb
```
**Conclusion: this is a self-re-arming subscription-refresh timer with a zero interval, not an
error-retry storm.** Three independent confirmations:
1. **Wire evidence** — payload histogram over `/tmp/blaze_rx/rx_*_000a_0005.bin`:
`cb3d620000`**1** (the single initial subscribe), `cb3d620001` → all the rest. Only
`0x147e57380` writes `RSUB=1`, so the timer functor is provably the sole driver.
2. **Cadence**`/tmp/blaze_responder.log` per-second RX histogram is a flat **30/s** from
`[17:36:53]` to `[20:01:15]` (2 h 24 m and counting; min 25, max 32). That is the game idle-tick
rate, i.e. exactly one re-send per BlazeHub pump — mechanically impossible for a socket-speed
error retry, and no backoff, no teardown.
3. **Live state** — API object `0x43c70ff0` (vtable `0x143b6d6b0`, hub `+0x08` = `0x43c47330`,
component proxy `+0x20` = `0x079f25c0`) read out of `/proc/66672/mem` right now:
`+0x160` (saved RTMT) = **0**, `+0x168` (armed job id) = 0. And a gdb break on `0x147e57050`
showed `err = 0x0` with `resp+0x10 = resp+0x18 = resp+0x20 = 0` — the reply is accepted as
SUCCESS.
**The fix is therefore: make `CNP + NTMT` ≥ 1000 µs so the job goes on the timed heap.**
---
## 2. THE FIX
File: `/home/alex/Documents/OpenFUT/fifa17-recon/tools/blaze_responder_v3b.py`
### 2.1 Constants — after `COMP_USERSESSIONS` (line ~201)
```python
COMP_CENSUSDATA = 0x000A # id built at 0x147e639cd-0x147e639d8;
# "CensusDataComponent" @0x143b72368
# ---- CensusData (0x000A) command table, from getCommandName @0x147e64170.
CMD_SUBSCRIBETOCENSUSDATAUPDATES = 0x0005
CENSUSDATA_CMDS = {
0x01: "subscribeToCensusData", 0x02: "unsubscribeFromCensusData",
0x03: "getRegionCounts", 0x04: "getLatestCensusData",
0x05: "subscribeToCensusDataUpdates",
}
NOTIFY_SERVER_CENSUS_DATA = 0x0001 # getNotificationName @0x147e645e0
```
### 2.2 Logging names — `COMP_NAMES` (line 277) and `rpc_name()` (line 285)
```python
COMP_NAMES = {
...
COMP_CENSUSDATA: "CensusData",
0x000B: "Clubs", # id at 0x147e38b5e/0x147e38b71
0x081C: "SponsoredEvents", # cmd table 0x146f89130
0x08C9: "OSDKSettings", # cmd table 0x14725f9a0
}
```
and inside `rpc_name()`, next to the existing `COMP_UTIL` / `COMP_AUTH` arms:
```python
elif component == COMP_CENSUSDATA:
cmd = CENSUSDATA_CMDS.get(command, "cmd:0x%04x" % command)
```
(Log cosmetics only — no behaviour change, but it makes the success signal in §3 readable.)
### 2.3 Response builder — next to `qos_config()` / `preauth_response_fields()`
```python
def subscribe_census_data_updates_response_fields() -> "OrderedDict":
"""Blaze::CensusData::SubscribeToCensusDataUpdatesResponse
classinfo @0x144c11dc0, tdfId 0x746128a3, member table @0x144c11bf0, 3 members,
ALL TDF type 0x0e (TimeValue) -> Heat2 INT varint of MICROSECONDS.
The client's reply callback 0x147e57050 computes
delay_ms = (CNP + NTMT) / 1000 (magic-divide 0x20C49BA5E353F7CF, sar 7)
and arms a FunctorJob (0x147e57380) that re-sends this RPC with RSUB=1.
All three members default to 0 in reflection, so our old EMPTY reply gave
delay_ms = 0, which 0x146dbae7c pushes onto the *ready* job list -> one
re-subscribe per idle tick = the observed 30/s storm.
30s + 90s => the client re-subscribes every 120s instead of every 33ms.
Tags ascend (8eec00 < bb4b74 < cb4b74), which is also correct Heat2 order."""
return OrderedDict([
("CNP", (INT, 30 * 1000000)), # censusNotificationPeriod
("NTMT", (INT, 90 * 1000000)), # notificationTimeout
("RTMT", (INT, 300 * 1000000)), # resubscribeTimeout (error path only)
])
```
`"CNP"` encodes to `8e ec 00` — identical to the 4-char `"CNP "` — verified via
`heat2.encode_tag`, so the 3-char spelling is safe.
### 2.4 Dispatch arm — in `dispatch()`, before the `REPLY_EMPTY_TO_UNKNOWN` fallback (~line 1166)
```python
# ----------------------------------------------------------- CensusData
if comp == COMP_CENSUSDATA and cmd == CMD_SUBSCRIBETOCENSUSDATAUPDATES:
rsub = find_nested_int(fields, "RSUB") if fields is not None else None
log(" -> subscribeToCensusDataUpdates(RSUB=%s): "
"SubscribeToCensusDataUpdatesResponse{CNP=30s, NTMT=90s, RTMT=300s} "
"-> client re-subscribes in (CNP+NTMT)/1000 = 120000 ms" % rsub)
return [reply_to(hdr, encode_tdf(
subscribe_census_data_updates_response_fields()))]
```
### 2.5 Exact bytes on the wire (round-tripped through this repo's `heat2.py` + `reply_to`)
Payload — 25 bytes:
```
8e ec 00 00 80 8e ce 1c CNP tag 8eec00, type 00 (INT), varint 30000000
bb 4b 74 00 80 aa ea 55 NTMT tag bb4b74, type 00 (INT), varint 90000000
cb 4b 74 00 80 8c 8d 9e 02 RTMT tag cb4b74, type 00 (INT), varint 300000000
```
Full 41-byte REPLY frame (shown for msgNum 0x10, the first census request):
```
00 00 00 19 00 00 00 0a 00 05 00 00 10 20 00 00
8e ec 00 00 80 8e ce 1c bb 4b 74 00 80 aa ea 55
cb 4b 74 00 80 8c 8d 9e 02
```
(size `0x19` = 25, component `0x000a`, command `0x0005`, byte[13] `0x20` = REPLY + userIndex 0 —
produced by the existing `reply_to(hdr, payload)`, no header changes needed.)
### 2.6 CIDS / component list — **do NOT change**
`COMPONENT_IDS` (line 528) feeds only `CIDS` in `preauth_response_fields`, and `dispatch()` never
reads it. CIDS demonstrably does not gate anything: FIFA already sends **four** components that are
absent from our advertised CIDS `[1,4,5,7,9,15,25,28,30722]``0x000A`, `0x000B`, `0x081C`,
`0x08C9`. Adding `0x000A` would change nothing we can observe, and could only add risk (FIFA might
instantiate *more* components and issue new unhandled RPCs), which would break the one-variable
discipline that cracked every previous gate. Leave it alone for this experiment.
### 2.7 Adjacent components 0x000B / 0x08C9 / 0x081C — names only, no handlers
These are each called **exactly once**, get a 16-byte empty REPLY, and are **never retried** — so
none of them is currently a hang, and none has a reversed response schema yet. Adding invented reply
bodies would inject a second variable into the experiment. Add the `COMP_NAMES` entries from §2.2
(pure logging) and stop there. For reference when one of them becomes the next gate — all names read
from FIFA's own command tables:
| comp | cmd | name | cmd-table VA |
|---|---|---|---|
| 0x000B Clubs | 0x0A28 | `getClubsComponentSettings` | `0x147e40b00` (`0x143b69098`) |
| 0x000B Clubs | 0x0640 | `getInvitations` | `0x147e40b00` (`0x143b690f0`) |
| 0x08C9 OSDKSettings | 0x0001 | `fetchSettings` | `0x14725f9a0` |
| 0x08C9 OSDKSettings | 0x0002 | `fetchSettingsGroups` | `0x14725f9a0` |
| 0x081C SponsoredEvents | 0x0003 | `getEventsURL` | `0x146f89130` |
| 0x0007 Stats | 0x0003 / 0x000F / 0x0014 | `getStatGroupList` / `getKeyScopesMap` / `getPeriodIds` | `0x147e29040` |
| 0x0009 Util | 0x000C | `userSettingsLoadAll` | `0x146df6dc0` |
| 0x000F Messaging | 0x0002 | `fetchMessages` (×3) | `0x147e49780` |
### 2.8 Optional step 2 — only if a residual ~120 s beat is not enough
A real server pushes census content every `CNP`; we never will, so FIFA will keep re-subscribing
every 120 s. That is the correct steady state, not a bug. If FUT turns out to need actual content:
```python
notification(COMP_CENSUSDATA, NOTIFY_SERVER_CENSUS_DATA, encode_tdf(OrderedDict([
("CNP", (INT, 30 * 1000000)),
("NTMT", (INT, 90 * 1000000)),
("RTMT", (INT, 300 * 1000000)),
("TDFL", (LIST, (STRUCT, []))), # censusDataList -- EMPTY
])))
```
`Blaze::CensusData::NotifyServerCensusData`, tdfId `0x5b08e70d`, classinfo `0x144c11cd0`, member
table `0x144c11ad0`, 4 members: `TDFL` @+0x10 (`0xd249ac00`, list of `NotifyServerCensusDataItem`),
`CNP` @+0x58, `NTMT` @+0x60, `RTMT` @+0x68. Keep `TDFL` empty — its single element member `tdf`
(`0xd2498000`, typedesc `0x14486d860`) is TDF type **`0x07` = variable TDF**, a wire format `heat2.py`
does not implement. Do not attempt it until the variable-TDF encoding is reversed.
---
## 3. OBSERVABLE SUCCESS SIGNAL
**Primary (log, immediate):** the 30/s storm collapses.
```bash
grep -oE '^\[[0-9:]+\] RX #[0-9]+ CensusData' /tmp/blaze_responder.log \
| grep -oE '^\[[0-9:]+\]' | uniq -c | tail
```
Per-second count must go from a flat **30** to **0**, with at most one
`CensusData::subscribeToCensusDataUpdates` roughly every **120 s** (`RSUB=1`). Anything in between
(e.g. a 1/s or 10/s beat) means the reply decoded but with different values than intended — see §4.
**Secondary (the real prize):** a brand-new RPC on a component other than `0x000A`, at a msgNum
higher than the last census msgNum. Right now, from RX #43 onward for 2 h 24 m, the socket carries
**nothing but census** — not even `Util::ping`. Any new line here is forward progress:
```bash
tail -f /tmp/blaze_responder.log | grep -P 'RX #\d+ ' | grep -v CensusData
```
Expected candidates: `GameManager (0x0004)` (never contacted at all so far),
`Util::fetchQosConfig (9/0x15)`, `Stats`, `GameReporting (0x001C)`, or FUT/UTAS HTTP traffic.
**Direct in-process confirmation (proves the fields landed, not just that the spam stopped):**
```
gdb -p 66672
hbreak *0x147e57050
# at the hit:
p/d *(long long*)($rdx+0x10) # CNP -> must be 30000000, was 0
p/d *(long long*)($rdx+0x18) # NTMT -> must be 90000000, was 0
p/d *(long long*)($rdx+0x20) # RTMT -> must be 300000000, was 0
p/x $r8d # BlazeError -> 0
```
Or without gdb, straight from the live heap (the API object we already located):
```bash
python3 -c "
import struct;f=open('/proc/66672/mem','rb',0);f.seek(0x43c70ff0+0x160);b=f.read(12)
print('saved RTMT =',struct.unpack('<Q',b[:8])[0],' armed jobId = 0x%x'%struct.unpack('<I',b[8:])[0])"
```
Today this prints `saved RTMT = 0 armed jobId = 0x0`. After the fix it must print
`saved RTMT = 300000000` with a **stable non-zero jobId** persisting between sends (the job now sits
on the timed heap instead of being consumed every idle).
**UI signal:** the "connecting to FUT" spinner advances / the FUT hub loads.
---
## 4. WHAT STILL NEEDS A LIVE EXPERIMENT
1. **Does FUT actually gate on census at all?** The RPC already returns `err = 0` and the response
carries no readiness flag, so the fix provably ends the *storm* but is **not proven** to unblock
FUT. It may simply reveal the next gate. Watch §3's secondary signal: if the storm stops and
**nothing new** is ever sent, the FUT gate is elsewhere and this was noise removal only.
Corollary risk: if nothing new appears, also check whether `Util::ping` resumes — it has been sent
exactly once (pre-login) in 2 h 24 m, which could mean the census loop was starving the keepalive
scheduler, or could just be SDK ping suppression while traffic flows. A permanently missing
keepalive is a latent disconnect.
2. **TimeValue unit sanity at the top end.** Microseconds is well-evidenced (`TimeValue::parse`
`*1e6`, the `/1000`→ms divide, and QosConfigInfo's baked default 5,000,000). But the delay is
passed to the scheduler as a **32-bit ms int** (`mov DWORD PTR [rsp+0x28],edx`) and no clamp or
validation was found in either `0x147e57050` or `0x146dbae10`. `(CNP+NTMT)` = 120e6 µs → 120,000
ms is far inside range, but do not scale these values up by orders of magnitude without
re-checking. If FIFA instead re-subscribes on a *far* different period than 120 s, the observed
interval directly reveals the real unit.
3. **Does the client also watchdog on `NTMT` (no notification received)?** If a resubscribe beat
appears at ~90 s rather than ~120 s, the client is timing out on missing census pushes and we need
§2.8's `NotifyServerCensusData`. That in turn is blocked on reversing the **variable-TDF (type
0x07)** wire format for a non-empty `TDFL` — an empty `TDFL` list is untested on this client.
4. **Whether FUT wants census *content*, not just the ack.** There is a whole FIFA-side layer above
Blaze CensusData — `FifaOnline::CensusDataHandler` @`0x1438fb278`, `OSDK_CensusDataAdaptor`
@`0x143993768`, `FifaOnline::GetCensusDataRequest/Response` @`0x1438f7908`/`0x1438f7930`, event
`EVENT_CENSUSDATA_UPDATE` @`0x143984650`, plus per-domain payload classes
(`GameManagerCensusData` `0x1438bc618`, `ClubsCensusData` `0x143b689a0`, `UserManagerCensusData`
`0x14388c4a0`). If any of that blocks on a populated list, the ack alone will not be enough.
5. **Whether cmds 0x0003 / 0x0004 become the next gate.** FIFA has never sent `subscribeToCensusData`
(cmd 1) — it goes straight to cmd 5. Once cmd 5 succeeds it may follow up with `getRegionCounts`
(response `Blaze::CensusData::RegionCounts`, tdfId `0x610ec2da`, 1 member `CNOU`
`map<uint32_t,string>` `numOfUsersByRegion`, classinfo `0x144c11d20`) or `getLatestCensusData`.
RegionCounts is cheap to stub if it appears; `getLatestCensusData` is not (variable TDFs again).
6. **Which of the nine empty-replied RPCs bites next** is not determinable from this log — none was
retried even once, so there is no retry signature to read. A-priori favourites are
`OSDKSettings::fetchSettings` / `fetchSettingsGroups` (an OSDK-layer gate FIFA calls exactly twice,
matching that component's entire 2-command surface) and `Clubs::getClubsComponentSettings`. Three
*non*-empty but stubbed replies are also live risks once census unblocks:
`AssociationLists::getLists` returns `LMAP:[]` though FIFA explicitly asked for
`OSDKPreferredPlayerList` and `OSDKAvoidPlayerList`; `Util::fetchClientConfig` returns an empty map
for `OSDK_TICKER` / `OSDK_ARENA` / `OSDK_ROSTER`; `Util::userSettingsLoad` returns empty `DATA` for
`FirstTimeFlag` and `AchievementCache`.
7. **Restart discipline.** The fix changes only the reply to an RPC FIFA re-sends every idle tick, so
in principle restarting the responder mid-session is enough — the very next census request would
get the new reply. Whether the live client tolerates the dropped TCP connection is untested;
a full relaunch is the safe path, and it also gives a clean log to measure the cadence against.
@@ -0,0 +1,493 @@
# 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`.
@@ -0,0 +1,317 @@
# FIFA17 First-Party Auth Enqueue — Forge & Trigger Plan
Clean-room synthesis of four independent reverses of the `FifaOnline::FirstPartyAuthTokenRetriever::DoTick`
auth-code path. Every byte/offset below is backed by a decrypted-code VA or a live `/proc/13643/mem`
read. Live pid at time of writing: **13643** (`pgrep -x FIFA17.exe`).
## TL;DR — two blockers, not one
The brief assumed the only problem is `retriever+0x8 == NULL`. It is not. There are **two** hard
gates, both live-verified this run:
1. **Empty queue**`retriever+0x8` and `+0x10` are both `0x0` (nothing enqueued). *This is the one
the brief targets.*
2. **No Origin default user**`OriginSDK[+0x3a0] == 0x0`. `DoTick` calls `GetDefaultUser()` which
returns that slot, then `OriginRequestAuthCodeSync` **rejects any request whose user is NULL**
(`test rdx,rdx; je fail` → returns `0xa2000003`, nothing hits the wire). So even a perfectly forged
node produces only an "Origin Error(a2000003)" log line unless we also set `OriginSDK[+0x3a0]`.
**Forging the node alone is necessary but not sufficient. You must set BOTH.** Every plan below has
"set `OriginSDK[+0x3a0]` non-null" as step 0.
Live confirmation (this run):
```
OnlineMgr *[0x1448a3b20] = 0x43dc3e70
retriever = +0x4e98 = 0x43dc8d08
+0x00 vptr = 0x1438f5d50 (retriever vtable, 1 real slot = deleting dtor)
+0x08 queue slot 0 = 0x0 <-- write &node here
+0x10 queue slot 1 = 0x0
guard byte [0x1448a3ac3] = 0x01 (enqueue-wrapper guard PASSES, not the blocker)
OriginSDK *[0x144b7c7a0] = 0x25c98c50
+0x3a0 defaultUser = 0x0 <-- BLOCKER: must be non-null
node vtable 0x1438f5d58: [0]AddRef 0x147e8f160 [1]Release 0x147e1c480 [2]dtor 0x146f028d0
ret gadget [0x1470e3567] = c3
```
Note there are **two different "OriginMgr" singletons** — do not confuse them:
- `*[0x1448acf50]` → login-state OriginMgr (`m_isLoggedIn` @ +0x13; the one `force_login_flag.py` pins).
- `*[0x144b7c7a0]` → OriginSDK object (default-user @ +0x3a0; the one THIS path needs).
---
## 1. `FirstPartyAuthCodeFutureImpl` NODE STRUCT
Size **0xF0 (240 bytes)** — from the enqueue allocation constant
(`0x146f5b916 mov edx,0xc390a20f; lea edx,[rdx+0x3c6f5ee1]` = `0xF0`) and matched by the ctor
`0x146eeecd0`. The clientId capacity `0x40` comes from the same ctor (`r8d = 0xc390a20f + 0x3c6f5e31 = 0x40`).
| Offset | Type | Meaning | Ctor init | Read/written by | Forge value (minimal) |
|---|---|---|---|---|---|
| `+0x00` | `void**` | primary vtable | `0x1438f5d58` | DoTick calls `[vptr+8]`=Release at end | **`0x1438f5d58`** (real) |
| `+0x08` | `void**` | secondary vtable (base) | `0x1438f5d90` | dtor adjustor thunk only | `0x1438f5d90` (real) or `0` |
| `+0x10` | `u32` | atomic refcount | `0` (xchg) | AddRef/Release | **`2`** (see refcount note) |
| `+0x14` | `u32` | pad | — | — | `0` |
| `+0x18` | `char[0x40]` | **ClientId** (inline C-string) | `strncpy(+0x18,arg,0x40)` | DoTick `lea rdx,[rsi+0x18]` → passed as `const char*`; Origin deref's byte-wise, must be non-empty | **`"FIFA17PC\0"`** (any non-empty; see Q) |
| `+0x58` | `char[0x80]` | message/error buffer | `[+0x58]=0` | `SetError` vsnprintf's here (cap 0x80, ends at 0xD8) | `0` |
| `+0xD8` | `char*` | **authCode result** (heap) | `0` | DoTick success: `mov [rsi+0xd8],rax`; dtor frees it | `0` |
| `+0xE0` | `u32` | status/error code | `0` | `SetError``200 (0xC8)` on failure | `0` |
| `+0xE4` | `u32` | kind/userIndex | `= ctor arg2` | wrapper always passes `0` | `0` |
| `+0xE8` | `u8` | **isComplete / poll flag** | `0` | DoTick sets `1` on BOTH success and failure | `0` |
| `+0xE9`..`+0xEF` | pad | — | — | — | `0` |
There is **NO `next` pointer.** The "queue" at `retriever+0x8` is a **fixed 2-slot array** of
ref-counted node pointers, not a linked list. DoTick iterates the two slots with `lea rbx,[rcx+8];
mov ebp,2; ... add rbx,8; dec rbp; jne`. No node field is ever chased as a link. (Confirmed:
`0x146f199cd/d1/e0` and tail `0x146f19ae1/e5/e8`.)
**Node vtable `0x1438f5d58`** (real, live-read):
`[0]`AddRef `0x147e8f160` · `[1]`Release `0x147e1c480` · `[2]`dtor `0x146f028d0` ·
`[5]`GetResult `0x1466cc0d0` (`mov rax,[rcx+0xd8];ret`) · `[4]`GetStatus `0x1471a0630`
(`mov eax,[rcx+0xe0];ret`).
### Minimal forged node — exact 240 bytes (little-endian)
```
off bytes meaning
0x00 58 5d 8f 43 01 00 00 00 vptr = 0x1438f5d58
0x08 90 5d 8f 43 01 00 00 00 vptr2 = 0x1438f5d90
0x10 02 00 00 00 refcount = 2 (survives one Release, never freed)
0x14 00 00 00 00 pad
0x18 46 49 46 41 31 37 50 43 00.. clientId = "FIFA17PC", NUL, rest 0 (fills to 0x58)
0x58 00 * 0x80 message buffer = 0
0xD8 00 00 00 00 00 00 00 00 authCode = 0
0xE0 00 00 00 00 status = 0
0xE4 00 00 00 00 kind = 0
0xE8 00 isComplete = 0
0xE9 00 * 7 pad to 0xF0
```
**Refcount note (important).** DoTick unconditionally ends each processed slot with
`mov rcx,[rbx]; mov [rbx],0; mov rax,[rcx]; call [rax+8]` = **Release** (`0x147e1c480`, `lock xadd`
decrement of `[node+0x10]`; on reaching zero it invokes the dtor which `free()`s the node via the
game allocator `0x1453370b0`). If you forge with **refcount = 1**, DoTick decrements to 0 and tries to
**free your node** — safe only if the node lives in game-allocator memory, a crash otherwise. Forge
**refcount = 2**: after Release it is 1, never freed. Costs a ~240-byte leak, zero crash risk.
(Alternative: use a synthetic vtable whose slot `[1]` is the ret gadget `0x1470e3567` — then Release
is a no-op and refcount is irrelevant; but the real vtable + refcount=2 is simpler and keeps the
GetResult/GetStatus accessors valid if anything polls.)
**Unknowns (marked):**
- The **real ClientId string** the game would use is unrecovered (the natural enqueue never runs live).
For our local LSX responder any non-empty string is accepted by `<GetAuthCode>`. For a genuine EA
endpoint the correct Nucleus client_id would be required. Since OpenFUT answers LSX locally, `"FIFA17PC"`
(or whatever our responder keys on) is fine.
- Whether the deeper LSX marshalling inside `0x1470e67f0` dereferences **user** object fields beyond the
null/equality check. The traced send path builds the request from the SDK object + clientId and does
**not** deref the user, but this was not exhaustively followed past the dispatch. Mitigation: set
`OriginSDK[+0x3a0]` to a real readable pointer (the SDK object itself) rather than a bare `1`.
---
## 2. DoTick PROCESSING — end to end (`0x146f199c0`)
Per slot `i` in `{+0x08, +0x10}`:
1. `rsi = *slot`. If NULL → skip (`je 0x146f19ae1`). *(Live: both NULL → does nothing, forever.)*
2. Zero two stack out-slots `[rsp+0x60]` (authCode out) and `[rsp+0x58]` (length out).
3. `call OriginGetDefaultUser()` (`0x1470da6d0`, zero-arg) → returns `OriginSDK[+0x3a0]` or NULL.
Verified: `0x1470da6f4 call 0x1470e3560 (→ *[0x144b7c7a0]); 0x1470da6f9 mov rax,[rax+0x3a0]; ret`.
4. `call OriginRequestAuthCodeSync(user=rax, clientId=&node[0x18], &outAuthCode=r8, &outLen=r9, scope=0)`
(`0x1470db3c0`, `146f19a05 lea rdx,[rsi+0x18]`, `146f19a0c mov [rsp+0x20],r14`=0 scope). The wrapper
forwards to the real impl `0x1470e67f0`, which:
- `test rdx,rdx; je fail` and `cmp rdx,[rcx+0x3a0]; jne fail` — **user must be non-NULL and == the
SDK default user** (both are the same slot, so any non-null value is self-consistent). On failure
returns `0xa2000003`, **no send**.
- clientId must be non-empty (`cmp byte[r8],0`), copies it into `LSXRequest+0x10`.
- builds the `Origin::LSXRequest<lsx::GetAuthCodeT,...>`, **transmits it** (`call [0x148e219f8]`),
registers the pending future in the SDK reqId-keyed map (`0x1470e6540`), writes future→out, reqId→out.
*This is the point `<GetAuthCode ClientId Scope>` goes on the LSX wire.*
5. DoTick inspects the result:
- `rc != 0``SetError(node, 200, "[%s] Origin Error(%d)\n", ".::DoTick", rc)` → writes `node+0xE0=200`,
`node+0xE8=1`, message into `node+0x58`.
- `rc==0 && (outAuthCode==0 || outLen==0)``SetError(node,200,"[%s] Invalid authcode\n",...)`.
- success → alloc `outLen+1` from `*[0x1448a20b8]` (vt+0x38), `mov [node+0xD8]=buf`,
`strlcpy(buf,outAuthCode)` (`0x145e27a50`), `mov byte[node+0xE8]=1`.
6. **Dequeue + release (all paths):** `mov rcx,[rbx]; mov [rbx],0` (NULL the slot) then
`mov rax,[rcx]; call [rax+8]` = Release. Each enqueued request is consumed in exactly one tick;
there is no retry/pending state.
DoTick's only caller is the per-frame online-subsystem tick `0x146f7b279`
(`lea rcx,[rsi+0x4e98]; call 0x146f199c0`), so a forged node is picked up on the **next frame**.
---
## 3. THE PLAN (ranked by likelihood-of-success × safety)
### STEP 0 (all plans): set the Origin default user — REQUIRED
```
OriginSDK = *[0x144b7c7a0] # live 0x25c98c50
write 8 bytes at OriginSDK+0x3a0 = OriginSDK # a real, readable, self-consistent non-null pointer
```
Writing the SDK object's own address (rather than a bare `0x1`) satisfies the null + equality checks
**and** points at valid memory in case anything downstream deref's the "user". GetDefaultUser and the
impl both read the same slot, so equality always holds.
---
### (a) PRIMARY — FORGE a node via `/proc/mem` and set `retriever+0x8` ★ recommended
Pure memory writes, no code execution, no Win64/SysV ABI hazard. Matches the brief exactly.
**Steps**
1. Do STEP 0.
2. Pick a **scratch VA** inside FIFA to host the 240-byte node — a currently-zero, unreferenced,
writable region (see "live items", §4). Call it `NODE`.
3. Write the 240-byte forged node (bytes in §1) at `NODE`.
4. Write `NODE` (8 bytes) into `retriever+0x8` = `0x43dc8d10`.
5. Watch `/tmp/lsx.log` for the `<GetAuthCode ClientId="FIFA17PC" .../>` request on the next frame.
**Recipe (style of `force_login_flag.py`):**
```python
#!/usr/bin/env python3
# forge_node.py — forge a FirstPartyAuthCodeFutureImpl and enqueue it. ptrace_scope=0 required.
import struct, glob, os
ONLINEMGR_PP = 0x1448a3b20 # *-> OnlineManager
RETR_OFF = 0x4e98 # +retriever
SDK_PP = 0x144b7c7a0 # *-> OriginSDK
SDK_DEFUSER = 0x3a0 # OriginSDK default-user slot (BLOCKER)
VPTR = 0x1438f5d58
VPTR2 = 0x1438f5d90
CLIENTID = b"FIFA17PC"
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: pass
raise SystemExit("FIFA17.exe not found")
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 -> never freed
b[0x18:0x18+len(CLIENTID)] = CLIENTID # clientId, NUL-terminated (rest already 0)
return bytes(b)
def main():
p = pid(); f = open(f"/proc/{p}/mem","r+b")
rq = lambda va:(f.seek(va), struct.unpack('<Q', f.read(8))[0])[1]
onlinemgr = rq(ONLINEMGR_PP); retr = onlinemgr + RETR_OFF
sdk = rq(SDK_PP)
# STEP 0: default user
f.seek(sdk+SDK_DEFUSER); f.write(struct.pack('<Q', sdk))
print(f"[+] OriginSDK={sdk:#x} default-user set -> {sdk:#x}")
# NODE scratch VA — MUST be a validated unused writable region (see plan §4).
NODE = int(os.environ.get("NODE_VA","0"),16)
if not NODE: raise SystemExit("set NODE_VA=<hex scratch VA>")
f.seek(NODE); f.write(build_node())
print(f"[+] node forged @ {NODE:#x} (clientId={CLIENTID!r})")
# enqueue: retriever+0x8 = &node
f.seek(retr+0x08); f.write(struct.pack('<Q', NODE))
print(f"[+] retriever+0x8 ({retr+0x08:#x}) -> {NODE:#x}. Watch /tmp/lsx.log for <GetAuthCode>.")
if __name__=='__main__': main()
```
**Crash risks**
- *Scratch provenance*: if `NODE` overlaps live game memory, DoTick's writes to `+0xD8/+0xE8` (and any
poller) corrupt it. Mitigate by validating the region is zero + unreferenced (§4).
- *Refcount*: refcount=2 avoids the terminal free entirely — do **not** use 1 unless `NODE` is game-alloc.
- *Deeper user deref*: covered by pointing `+0x3a0` at the real SDK object.
- *Race*: DoTick runs every frame; write the node bytes **before** setting `retriever+0x8` (the script
does), so a mid-write tick never sees a half-built node.
**Success signal**: a single `<GetAuthCode ClientId="FIFA17PC" .../>` LSXRequest on `/tmp/lsx.log`
within one frame; on failure instead expect an "Origin Error(a2000003)" trace (means STEP 0 didn't take)
or "Invalid authcode" (means our LSX responder returned empty).
---
### (a) SAFE VARIANT — let the game allocate the node (hybrid forge) ★ safest memory-wise
Instead of hosting the node in scratch memory, call the game's own enqueue
`RequestFirstPartyAuthCode(clientId)` = **`0x146f57bf0`** (guard byte `[0x1448a3ac3]` already `1`, so it
resolves `retriever = mgr+0x4e98` correctly and stores into the first free slot). This allocates a
proper 0xF0 node from the game allocator, ctors it, AddRefs, and inserts it — DoTick then processes it
with the real vtable and correct refcount/free, and wires the future back into the retriever slot.
This removes the scratch-provenance problem entirely but requires a **call** (see (b) for the Win64 ABI
caveat). Signature: `void** RequestFirstPartyAuthCode(const char* clientId /*rcx*/)`. Still needs STEP 0.
---
### (b) DIRECT CALL via gdb — fire the send without forging
Two call targets, both Win64 `__fastcall`:
- **Enqueue** `0x146f57bf0` `RequestFirstPartyAuthCode(const char* clientId /*rcx*/)` — the (a) route;
correct, wires the future into the retriever.
- **Raw sync sender** `0x1470db3c0`:
```
int32 OriginRequestAuthCodeSync(
rcx void* user, // must be !=0 AND == OriginSDK[+0x3a0] (== GetDefaultUser())
rdx const char* clientId, // non-empty
r8 void** pOutFuture,// out, non-null
r9 uint64* pOutReqId, // out, non-null
[rsp+0x28] const char* scope // optional, pass 0
) -> 0 ok / 0xa2000003 (bad user) / 0xa2000004 (null out-ptr)
```
Direct-call recipe: STEP 0, then `clientId="FIFA17PC"`; zero `outFuture,outReqId`; `rcx=OriginSDK[+0x3a0]`,
`rdx=&clientId`, `r8=&outFuture`, `r9=&outReqId`, `[rsp+0x28]=0`.
**Crash / correctness risks**
- **ABI mismatch (the big one):** FIFA17.exe is a Win64 PE under Wine (args `rcx/rdx/r8/r9`+stack); host
gdb `call` uses SysV (`rdi/rsi/rdx/rcx`). A naive `call` passes args in the wrong registers → garbage
user/clientId → fault or `0xa2000003`. Use a **forged thread context** (stop a thread, set `rip` to the
target with Win64 regs + 5th arg pushed + a return trap) or a small written trampoline, not `call`.
- The **raw sync** call fires GetAuthCode but the future lands in **your** out-param, not the retriever
node — it validates the LSX path but does **not** advance FIFA login. The **enqueue** call (0x146f57bf0)
does advance it. Prefer the enqueue.
- Re-entrancy: calling on a paused thread mid-DoTick could double-process; run when the online tick is idle.
**Success signal**: same `<GetAuthCode>` on `/tmp/lsx.log`. For the enqueue call, also expect `node+0xE8`
to flip to `1` on the following frame.
---
### (c) FIX THE REAL SKIP REASON — make FIFA enqueue naturally (cleanest, hardest)
Why the game never enqueues, root-caused to three independent walls (all live-verified or high-conf):
1. **No default user** (`OriginSDK[+0x3a0]==0`). It is populated only by the SDK connect/user-query
round trip at `0x1470e5ad5` (guarded by `0x147118d80` after a ~15 s connect-wait loop; nearby literal
"EbisuSDK"). If that LSX exchange never yields a user, the slot stays NULL and neither the natural
enqueue nor the auth send can proceed. **Fixing this legitimately (our LSX responder answering the
user-query so `+0x3a0` gets set) would unblock BOTH gates at once — the cleanest of all outcomes.**
2. **LoginStatePCLogin sub-state 0 is a hardcoded stub.** `(*[0x144b86bf0])->vt[0x60]` = `0x146f82070`
= `xor eax,eax; ret` for the live class → the state always returns NULL and falls to the
`TXT_NOT_LOGIN_TO_EBISU` branch (`0x1471b5b64`, sets `TXT_NOT_LOGIN_TO_EBISU` @ `0x1439633e8`, sub-state 1).
*Confidence medium* — needs the jump-table decode at `0x141e7f55c` to confirm index-0 mapping.
3. **Blaze-SDK's own auth-code fetchers** (`0x147237340` "blazeServerClientId" / `0x147237440`
"blazeSdkClientId") both bail at `mov rcx,[rax+0x750]; test rcx,rcx; je` — the **client-config object**
our empty `fetchClientConfig` responses never populate. Populating client-config would let this second,
retriever-independent path call `OriginRequestAuthCodeSync` directly.
**Recommended natural-fix track**: make our LSX responder answer the Origin user-query so `0x1470e5ad5`
writes `OriginSDK[+0x3a0]`, then supply a non-empty `fetchClientConfig` so `[cfg+0x750]` is non-NULL.
That is a server-side change (no memory patching) and would let FIFA drive the whole flow itself.
**Risk**: highest reverse-effort; may reveal further downstream gates (Blaze login after GetAuthCode).
---
## 4. STILL NEEDS A LIVE DUMP / EXPERIMENT
1. **Scratch VA for plan (a).** Need a validated **unused, zero, writable** ≥0x100-byte region in FIFA's
maps to host the forged node (candidates: an anon `rw` mapping with a long zero run; verify it stays
zero across several frames = unreferenced). Or sidestep entirely with plan (a)/(b) using the game
allocator. This is the one blocker to running (a) as-is.
2. **Does the transmit fp `*0x148e219f8` write the LSX socket synchronously**, or does `0x1470e6540` only
register the future while a separate pump thread flushes it? Determines whether a one-shot forced
enqueue puts bytes on the wire in the same frame.
3. **Does `0x1470e67f0` deref user fields** past the null/equality guard (deeper marshalling at
`0x1470dbfa0/0x147117fe0/0x1471186f0`)? If yes, `OriginSDK[+0x3a0]` must point at a *shaped* user
object, not just the SDK. Dump those before relying on the self-pointer trick.
4. **Real ClientId** the game/our LSX handler expects — confirm our responder's `<GetAuthCode>` handler
accepts an arbitrary non-empty string (expected: yes) or keys on a specific value.
5. **Confirm the natural-fix chain**: after our LSX responder answers the user-query, verify
`OriginSDK[+0x3a0]` actually becomes non-NULL live (proves gate #1 is server-fixable) and that
`fetchClientConfig` content lands at `[cfg+0x750]`.
6. **reqId width** written to `pOutReqId` (`[req+0xc8]`, appears 64-bit) — needed so a forged/emulated
response correlates with the request.
@@ -0,0 +1,584 @@
# FUT loading-screen QUIET STALL — completion condition, fix, and experiment plan
Clean-room. Every claim below is backed by a VA + bytes/disasm read from the **live frozen
process (pid 66672)** via `/proc/66672/mem`, or by a log/capture line. Nothing from leaked
EA source or headers. All disassembly in this document was re-dumped and re-verified by the
synthesis pass, not copied from the reverser reports.
---
## 0. Verdict in one paragraph
The FUT loading screen is **not** waiting on a Blaze RPC reply, **not** waiting on a
server-pushed notification, and **not** waiting on entitlements. It is sitting in the
data-defined UI state `CheckFUTRosterUpdateXML`, which fires the native condition
`isFUTRosterXMLAvailable` and then leaves **only** on `advance` (roster XML downloaded) or
`back` (download failed). Neither event can ever fire, because the roster-update **URL
resolves to an empty string** and the fetcher takes a silent early-return that never issues
a request and never arms a callback. The URL has exactly three sources; two are ini keys
that do not exist on disk, and the third is the Blaze client-config store — which we answer
with an **empty map**. So the missing input is a single **config string**:
`ROSTERUPDATE_URL`, deliverable through `Util::fetchClientConfig`.
---
## 1. THE COMPLETION CONDITION
### 1.1 The state machine, read live out of FIFA's heap
FUT entry is driven by the flow `checkFUTRostersFlow` (`data/ui/nav/checkFUTRostersFlow.nav`,
path string live @0x1b8fc38). The full JSON is resident at **0x41abd000-0x41abe400**. Verified
live (84 non-image copies of the literal `checkFUTRostersFlow`, 17 of `CheckFUTRosterUpdateXML`;
zero copies of either inside the image range 0x140000000-0x151359000 — i.e. these are runtime
data, not string constants).
```
LoadFUTDatabase → LoadFUTSquad → CheckFUTRosterUpdateXML → CheckFUTSquadBinFile
│ │
│ back ├─ "false" → EnterFUT ← GOAL
▼ └─ "true" → FUTLiveDBPopup → downloadLiveDB
FailFUTRosterXMLDownloadPopup
│ advance
unloadFUTDatabaseOnFail (dead end — FUT aborts)
```
The blocked state, verbatim from live memory:
```json
"name":"CheckFUTRosterUpdateXML"
,"onEnter":
[
["sendAction",["condition", "isFUTRosterXMLAvailable"]]
,["sendScreenEvent", ["ShowLoadingIcon"]]
]
, "onExit":
[
["sendScreenEvent", ["HideLoadingIcon"]]
]
,"transitions":
[
{"event":"advance" ,"targets":["CheckFUTSquadBinFile"]}
,{"event":"back" ,"targets":["FailFUTRosterXMLDownloadPopup"]}
,{"event":"evt_online_disconnected","targets":["unloadFUTDatabaseOnFail"]}
,{"event":"evt_invite_accepted" ,"targets":["unloadFUTDatabaseOnFail"]}
]
```
**There is no timeout transition.** `ShowLoadingIcon` on entry and `HideLoadingIcon` on exit
is exactly the symptom: stadium + "17" + spinner, no error, forever.
Two of the four exits (`evt_online_disconnected`, `evt_invite_accepted`) route to
`unloadFUTDatabaseOnFail` — they *abort* FUT, they do not advance it. So the only forward
exit is `advance`, and the only backward exit is `back`; both are raised by the roster-XML
download callbacks (`futRosterXMLDownloadSuccess` @0x143b54400 / `futRosterXMLDownloadFail`
@0x143b54420).
### 1.2 Good news downstream — the next state is already satisfied
Also read live, and **not previously reported**: the state *after* the block short-circuits
straight to `EnterFUT` for a fresh offline account.
```json
"name":"CheckFUTSquadBinFile"
,"onEnter":[["sendAction",["condition", "isFUTSquadAvailable"]]]
,"transitions":
[
{"event":"true" ,"actions":[["sendScreenEvent",["SetLiveDbDownloadState","4"]]],"targets":["FUTLiveDBPopup"]}
,{"event":"false","actions":[["sendScreenEvent",["EnterFUT",""]]] ,"targets":["advance"]}
]
```
`isFUTSquadAvailable == false` (no squad bin file yet) → **`EnterFUT` fires immediately**.
So `CheckFUTRosterUpdateXML` is very likely the **last** gate in this flow.
### 1.3 The exact blocked input — verified disassembly
Chain, all re-dumped live this session:
**(a)** `isFUTRosterXMLAvailable` handler = **0x147d989d0** (dispatcher string-compares the
condition name against `"isFUTRosterXMLAvailable"` @0x143b543b8 — literal confirmed live).
Five skip predicates; if all return false:
```
147d98a57: c6 87 38 01 00 00 01 mov BYTE PTR [rdi+0x138],0x1 ; "downloading" flag
147d98a5e: 48 89 d9 mov rcx,rbx
147d98a61: e8 3a 43 cd ff call 0x147a6cda0 ; start roster-XML fetch
```
**(b)** `0x147a6cda0` — the "issue if not already issued" wrapper:
```
147a6cda5: 48 8b 59 08 mov rbx,QWORD PTR [rcx+0x8] ; this = *[obj+8] (the downloader)
147a6cda9: 83 bb a0 02 00 00 00 cmp DWORD PTR [rbx+0x2a0],0x0
147a6cdb0: 75 1d jne 0x147a6cdcf ; already issued -> return
147a6cdb6: c6 83 a4 02 00 00 00 mov BYTE PTR [rbx+0x2a4],0x0
147a6cdbd: e8 1e a3 08 00 call 0x147af70e0
147a6cdca: e9 41 a6 00 00 jmp 0x147a77410 ; -> URL resolver, this=rbx
```
**(c)** `0x147a77410` — the URL resolver. **This is where it dies.** Full verified path:
```
147a7744d: mov DWORD PTR [rbx+0x2a0],0x0 ; clear "request issued"
147a77457: test rdi,rdi / je 0x147a775c5 ; bail #1: no download manager
147a77460: cmp DWORD PTR [rbx+0x2a8],0x5
147a77467: jb 0x147a77476
147a77469: cmp BYTE PTR [rbx+0x2b9],0x1
147a77470: jne 0x147a775c5 ; bail #2: >=5 attempts and flag != 1
; --- source 1: game-ini key "FUT/ROSTERUPDATE_URL" -------------------------
147a774b5: lea rdx,[rip+...] # 0x143af1810 ; "FUT/ROSTERUPDATE_URL"
147a774bf: call 0x147763ac0 ; ini->HasKey
147a774c4: test al,al / je 0x147a774f8 ; MISS in our setup
147a774f1: call 0x145e27ff0 ; strncpy(url, val, 0x100)
; --- source 2: game-ini key "ROSTERUPDATE_URL", wrapped "https://%s" -------
147a774fd: lea rdx,[rip+...] # 0x143af1828 ; "ROSTERUPDATE_URL"
147a77507: call 0x147763ac0 ; ini->HasKey
147a7750e: je 0x147a77541 ; MISS in our setup
147a77524: lea r8, [rip+...] # 0x143af1840 ; "https://%s"
147a77538: call 0x145e24d70 ; sprintf(url, 0x100, "https://%s", val)
; --- source 3: Blaze CLIENT-CONFIG store ----------------------------------
147a77541: mov rax,QWORD PTR [rsi]
147a77544: lea r9, [rsp+0x30] ; out buffer
147a77549: lea r8, [rip+...] # 0x14354b5f0 ; default = ""
147a77550: lea rdx,[rip+...] # 0x143af1828 ; key = "ROSTERUPDATE_URL"
147a77557: mov rcx,rsi ; cfg object
147a7755a: mov DWORD PTR [rsp+0x20],0x100 ; out size
147a77562: call QWORD PTR [rax+0x30] ; cfg->getString(key, "", out, 0x100)
; --- THE BAIL --------------------------------------------------------------
147a77565: lea rcx,[rsp+0x30]
147a7756a: call 0x145e27b10 ; strlen(url)
147a77577: test rax,rax
147a7757a: 74 49 je 0x147a775c5 ; <<<<<< EMPTY URL -> SILENT RETURN
; --- the code that is being skipped ---------------------------------------
147a775a5: call QWORD PTR [r10+0x50] ; issue the HTTP download
147a775b2: call 0x147173690 ; attach job handle to [rbx+0x290]
147a775b7: mov DWORD PTR [rbx+0x2a0],0x1 ; "request issued"
147a775c1: mov BYTE PTR [rbx+0x48],0x0
147a775c5: <stack-cookie check; ret> ; bare epilogue
```
`0x147a775c5` is the bare epilogue. Taking it means: **no socket, no callback registered,
no success event, no fail event, no timeout, no popup.** Exactly the observed quiet stall.
All string literals re-read live from 0x143af1810 / 0x143af1828 / 0x143af1840 and confirmed
as `FUT/ROSTERUPDATE_URL`, `ROSTERUPDATE_URL`, `https://%s`.
### 1.4 Why all three sources are empty — verified
| Source | Status | Proof |
|---|---|---|
| ini `FUT/ROSTERUPDATE_URL` | absent | `grep -ral 'ROSTERUPDATE_URL' "/mnt/games/FIFA 17"` matches only FIFA17.exe / FIFA17_Trial.exe — not `default_startup.nsi`, not `Documents/FIFA 17/settings/` |
| ini `ROSTERUPDATE_URL` | absent | same grep |
| Blaze client-config store | **empty** | FIFA requested `fetchClientConfig(CFID='OSDK_ROSTER')` at RX #22 (`/tmp/blaze_rx/rx_0022_0009_0001.bin` decodes to `{'CFID': (1,'OSDK_ROSTER')}`); `blaze_responder_v3b.py` has **no** `OSDK_ROSTER` key in `CLIENT_CONFIGS` (line 511-524) → `client_config_for()` returns `[]` → EMPTY MAP |
**Live confirmation that the store really lacks the key.** Full-address-space scan of pid 66672
(`scratchpad/scan.py`), counting image (0x140000000-0x151359000) vs non-image hits:
```
ROSTERUPDATE_URL image=2 nonimage=0 <-- NOT in the runtime config store
FUT_RS4_BASE_URL image=1 nonimage=0
OSDK_ROSTER image=2 nonimage=3 (request-side CFID string @0x43c4a287 etc.)
CheckFUTRosterUpdateXML image=0 nonimage=17 (flow is loaded and live)
checkFUTRostersFlow image=0 nonimage=84
```
For contrast, every key we **do** serve is present in the heap store; the live config-store
region holds literal `key=value` strings:
```
0x7b5a710 OSDK_CLUBS_INCOME_SEARCH_MAX=100 (from CFID OSDK_CLIENT)
0x7b5a779 OSDK_CLUBS_MAX_SEARCH_RESULT=50
0x7b5adf0 OSDK_ANTIGRIEFING_MAX_COUNT=0 (from CFID OSDK_CORE)
0x7b5ae84 SV_SERVER_VERSION=0
```
### 1.5 RESOLVED: the config store is MERGED, not CFID-scoped
This was the single biggest open question across the reports (the reader at 0x147a77550
passes **no CFID**). Resolved by following the accessor live:
```
0x1471995b0: mov rax,[0x144b86bf8]; ret ; global service registry -> 0x43c46c70
vtable 0x143959168, slot 0x118 -> 0x147199d90
0x147199d90: mov rax,[rcx]
mov edx,0xe7ffa20f
lea edx,[rdx-0x749c3ba8] ; = 0x73636667 = 4CC 'scfg'
call QWORD PTR [rax+0x60] ; services('scfg')
mov edx,0xc34f050f
lea edx,[rdx-0x63ed98a3] ; = 0x5f616c6c = 4CC '_all'
jmp QWORD PTR [r8+0x8] ; scfg->getSection('_all')
```
(FIFA 17 obfuscates immediates as `mov r32,A; lea r32,[r32+B]`; both constants decoded and
checked arithmetically.)
The object the roster resolver reads from is the config service's **`_all`** section — an
all-sections merged view. **Therefore it does not matter which CFID we put `ROSTERUPDATE_URL`
in.** Any CFID FIFA fetches before FUT entry will do. (Cross-check: the heap store above
contains keys from `OSDK_CORE` *and* `OSDK_CLIENT` — two different CFIDs — and the same
`vt[0x118]` accessor shape is used by `LoadRosterConfig` (0x14723bc50) to read
`ROSTER_URL`/`ROSTER_VER`/`ROSTER_CSUM`.)
### 1.6 Corroborating live state (all re-verified this pass)
* Sockets: exactly two, `127.0.0.1:51238→4216` (LSX, fd 55) and `127.0.0.1:43967→42130`
(Blaze, fd 300). **Zero SYNs, zero HTTP, in ~4 h of uptime.** The fetch was never issued.
* `grep -c CardsDLL /proc/66672/maps` = **0** — `CardsDLL_Win64_retail.dll` (the FUT DLL,
strings `LOAD_FUT_DLL` @0x143aebde8, `CardsDLL` @0x143aebe00) is **not mapped**, i.e.
`EnterFUT` never ran. This is the definitive "FUT booted" tripwire.
* Blaze link healthy and idle: `/tmp/blaze_responder.log` shows only 20 s PINGs plus the
120 s CensusData re-subscribe, all answered.
### 1.7 What is NOT the block (ruled out, with evidence)
**Not an RPC reply.** Heat2 decode is tag-driven, so a 0-byte REPLY decodes as "all members
at default" and the SDK completes the job with ERR_OK. Behavioural proof: TX #19, 23, 24,
25, 27, 28, 29, 32 were all 16-byte empty REPLYs, and FIFA issued the *next* RPC immediately
after each (RX #20#33, all inside one second at 20:47:17), and **never retried any of
them**. No Blaze job is pending.
**Not a notification.** Of the 22 Blaze components the client links, **14 share the stub
`0x145bf3580`** (`lea rax,[0x14354b5f0]; ret`, and `[0x14354b5f0] == 0` → empty string) as
their `getNotificationName` — including every FUT/OSDK-flavoured component (Easfc, FifaCups,
OSDKSettings, OSDKTournaments, OsdkArena, SponsoredEvents, CoopSeason, VProSPManagement,
EaAccess, Mail, GpsContentController) plus Authentication and Util. **There is no
FUT/OSDK "data ready" notification in this client.** Independently, nothing in
`checkFUTRostersFlow` consumes a Blaze notification — the only exits are the two local
HTTP callbacks plus two abort events.
**Not entitlements.** They *are* broken (see §2.2) but they are not this gate: an unentitled
user takes the `ENTER_FUT2_POPUP_GO_TO_STORE` / `FUT_ENABLE_MENU` path (0x147c86d85-0x147c86df7)
and never reaches `checkFUTRostersFlow` at all — yet we are demonstrably *inside* that flow.
Also `FifaOnline::NotifyEntitlementsUpdated` (0x146f3f630) is broadcast on the common tail
regardless of entitlement count (gated on the message error field, which is 0), so nothing
is blocked waiting for it.
---
## 2. THE FIX — ranked and minimal
### FIX 1 (THE GATE) — serve `ROSTERUPDATE_URL` in `Util::fetchClientConfig`
**File:** `/home/alex/Documents/OpenFUT/fifa17-recon/tools/blaze_responder_v3b.py`
No new TDF work is needed: `fetch_config_response_fields()` already emits the correct
`Blaze::Util::FetchConfigResponse{ CONF : map<string,string> }`, and `client_config_for()`
already `sorted()`s the pairs (Heat2 map ordering). The change is **data only**.
**(a)** Next to `OSDK_TICKER = []` (line 497) add:
```python
# --- FUT LOADING GATE ---------------------------------------------------
# CFID FIFA 17 fetches at FUT entry (RX #22, rx_0022_0009_0001.bin).
# ROSTERUPDATE_URL is the ONLY remaining source for the FUT roster-XML URL:
# the two ini keys FUT/ROSTERUPDATE_URL (@0x143af1810) and ROSTERUPDATE_URL
# (@0x143af1828) are absent on disk, so the resolver at 0x147a77410 falls
# through to cfg->getString("ROSTERUPDATE_URL", "", out, 0x100) @0x147a77562.
# An empty result makes 0x147a77577 test rax,rax / je 0x147a775c5 return
# WITHOUT issuing the request and WITHOUT arming a callback, so flow
# checkFUTRostersFlow state CheckFUTRosterUpdateXML never gets advance/back
# -> the silent loading-screen hang.
# The config store is the MERGED '_all' section (services('scfg')
# ->getSection('_all'), decoded at 0x147199d90), so any fetched CFID works;
# we put it in the CFID FIFA actually asks for at FUT entry.
# NOTE: this branch does NOT wrap the value (unlike the ini branch, which
# applies "https://%s" @0x143af1840) -> serve an ABSOLUTE url.
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "http://127.0.0.1:8081/fifa17/fut/rosterupdate.xml"),
("ROSTER_URL", "http://127.0.0.1:8081/fifa17/roster/"), # literal @0x143973aa0
("ROSTER_VER", "0"), # literal @0x143973ab0
("ROSTER_CSUM", ""), # literal @0x143973ad0
]
```
**(b)** Register it in `CLIENT_CONFIGS` (line 511-524):
```python
"OSDK_ROSTER": OSDK_ROSTER,
```
**(c)** Belt-and-braces (free, and it front-loads the value to *login* time rather than
FUT-entry time — useful because `OSDK_CORE` is fetched much earlier): append the same pair
to `OSDK_CORE`:
```python
("ROSTERUPDATE_URL", "http://127.0.0.1:8081/fifa17/fut/rosterupdate.xml"),
```
`LoadRosterConfig` (0x14723bc50) builds its CFID with the format `"OSDK_ROSTER%s"`
(@0x1439740a0, site 0x14723bc88), so a suffixed variant (`OSDK_ROSTERPC`, `OSDK_ROSTER1`, …)
may also be requested. Because the store is merged, serving the key in `OSDK_CORE` covers
every such variant — that is the main reason to do (c).
**(d)** Stand up a trivial HTTP listener on `127.0.0.1:8081` answering that path. Start with
`200 OK` + a minimal XML body.
#### Observable success signals, strongest first
1. **A third socket.** `ss -tanp | grep FIFA17` shows a connection to `127.0.0.1:8081`.
This alone proves the diagnosis — it is FIFA's **first ever** non-LSX/non-Blaze socket.
2. **`HideLoadingIcon`** fires and the flow leaves `CheckFUTRosterUpdateXML`.
3. **`CardsDLL_Win64_retail.dll` appears in `/proc/$(pgrep -x FIFA17.exe)/maps`.** This is
the definitive "FUT booted" signal — `EnterFUT` ran. Per §1.2, with no squad bin file the
flow goes `CheckFUTSquadBinFile → (false) → EnterFUT` **immediately** after the advance,
so signal 3 should follow signal 1 within seconds.
4. Failing that: the `FUT_SQUAD_DOWNLOAD_FAIL` popup appears. **Still a win** — it proves the
state machine moved and tells us the XML *content* is the next problem. (Be aware the fail
popup dead-ends into `unloadFUTDatabaseOnFail`, so it aborts FUT; it is a diagnostic, not
a path forward.)
Live pointer for probing the downloader after the fix — the `this` for the resolver is
`*[obj+8]` (from `0x147a6cda5 mov rbx,[rcx+0x8]`), and `[this+0x2a0] == 1` means the request
was issued. (My live chase of `obj` via `*[0x144bfb910] → +0x80` reproduced the earlier
reverser's failure: it lands on 0x7c17308, a string pool containing `MES_TAB_DP` /
`MATCHDAY_SETTING`, so that pointer path is wrong. Use a breakpoint on 0x147a6cda0 instead —
see §4.)
---
### FIX 2 (real bug, ship together, but it is NOT the gate) — entitlement group
**Live-confirmed broken, re-verified this pass on pid 66672:**
```
base = *[0x1448a3b20] = 0x43dc3e70 (online flag byte[0x1448a3ac3] = 1)
entMgr = *[base+0x2b10] = 0x43f15fd0
byte[entMgr+0x88] = 0 <-- "entitlements loaded" flag NOT set
qword[entMgr+0x90/0x98/0xa0] = 0 / 0 / 0 <-- record vector begin=end=cap: store EMPTY
```
Cause: the response callback `EntitlementComponent::onListEntitlements` (0x146f27440) keeps
an entitlement only if **all three** hold:
* `strstr(GNAM, "FIFA17PCBoxContent") != NULL` **OR** `strstr(GNAM, "FIFA16PC") != NULL`
(`0x146f27528` / `call 0x145e28fa0`, needles from the const array @0x144334030 =
`{0x1438e4d20 "FIFA17PCBoxContent", 0x1438e4d38 "FIFA16PC"}` — both literals re-read live);
* `strlen(TAG) != 0` (`0x146f2753e`);
* `STAT == 1` (`0x146f2754c cmp DWORD PTR [rbx+0xb0],0x1`) — `EntitlementStatus::ACTIVE`.
We send `GNAM = "FIFA17PC"` (`blaze_responder_v3b.py:106`). `strstr("FIFA17PC","FIFA17PCBoxContent")`
is NULL (needle longer than haystack) and `strstr("FIFA17PC","FIFA16PC")` is NULL → **zero
survivors** → the serialiser emits a zero-length blob → the consumer 0x146f4da20 bails at
`cmp QWORD PTR [rdx+0x18],0 / jbe 0x146f4dd9a` and never executes
`0x146f4dd8a mov BYTE PTR [rdi+0x88],0x1`.
**Change (a)** — line 106:
```python
ENTITLEMENT_GROUP = "FIFA17PCBoxContent" # was "FIFA17PC" -> matched NEITHER strstr needle
```
**Change (b)** — parameterise `entitlement_fields` (line 907) and emit a multi-element NLST.
**Keep the existing field order** (`DEVI GDAY GNAM ID ISCO PID PJID PRCA PRID STAT STRC TAG
TDAY TYPE UCNT VER`) — it is already the ascending-tag order Heat2 requires and is proven on
the wire (TX #33.0, 152 payload bytes):
```python
def entitlement_fields(now, group=None, tag=None, eid=1):
group = group or ENTITLEMENT_GROUP
tag = tag or ENTITLEMENT_TAG
return OrderedDict([
("DEVI", (STRING, "")),
("GDAY", (STRING, "2016-09-01T00:00:00Z")),
("GNAM", (STRING, group)), # MUST contain "FIFA17PCBoxContent" or "FIFA16PC"
("ID", (INT, eid)),
("ISCO", (INT, 0)),
("PID", (INT, PERSONA_ID)),
("PJID", (STRING, CONTENT_ID)),
("PRCA", (INT, 2)),
("PRID", (STRING, CONTENT_ID)),
("STAT", (INT, 1)), # MUST be exactly 1 (ACTIVE)
("STRC", (INT, 0)),
("TAG", (STRING, tag)), # MUST be non-empty
("TDAY", (STRING, "")),
("TYPE", (INT, 1)), # entitlementType — NOT checked client-side
("UCNT", (INT, 0)),
("VER", (INT, 1)),
])
def entitlements_response_fields():
now = int(time.time())
return OrderedDict([("NLST", (LIST, (STRUCT, [
entitlement_fields(now, "FIFA17PCBoxContent", "ONLINE_ACCESS", 1),
entitlement_fields(now, "FIFA16PC", "ONLINE_ACCESS", 2),
])))])
```
Hard constraints from the disassembly:
* `PRID`/`GNAM`/`TAG` must contain **no `|` and no `/`** — survivors are re-serialised as
`"PRID|GNAM|TAG|UCNT/"` (formats `%s|` @0x1438fa858, `%u/` @0x1438fa85c) and re-parsed with
`strtok` on those delimiters (0x146f4db93 / 0x146f4dbd3). `CONTENT_ID = "1027460"` is fine.
* Keep the list small — the compressor buffer is `0x1000` bytes (`0x146f27681 mov r8d,0x1000`).
* Stored records are matched later with fixed-width `strncmp` on the **exact** group string
(0x146ee9080, `r8d=0x41`), which is why both literals are sent verbatim.
**Deliberately deferred:** the `CC*` DLC tags (`CCALLINONE`, `CCTEAMS1`, `CCTEAMS2`,
`CCTOURNAMENTS`, `CCCAREERMODE`, table @0x1444049e8). `0x148198340` calls `HasEntitlement`
with `group = NULL` (`xor edx,edx`), which would feed NULL into the `strncpy` at
0x145e27ff0. That path is currently unreachable *only because* `byte[mgr+0x88] == 0`;
flipping the flag to 1 makes it live. Ship the two `ONLINE_ACCESS` entries first, confirm no
crash, then add the DLC tags.
**Success signal** (no restart needed once FIFA re-issues `listEntitlements`):
`byte[0x43f15fd0+0x88]` flips `0 → 1`, and `qword[mgr+0x90] != qword[mgr+0x98]` with
`(end-begin)/0x118 == 2`. Record layout: `+0x08` groupName, `+0x49` entitlementTag,
`+0xca` productId, `+0x10c` useCount.
---
### FIX 3 (DO NOT ship yet) — CensusData notification
`CensusData::subscribeToCensusDataUpdates(RSUB=1)` is the one outstanding subscription
(re-issued every 120 s: RX #40, 46, 52, 58, 64, 70, 76, 82, 88, 94), we have never pushed
`NotifyServerCensusData` (component 0x000A, notify id 0x0001), and the delivery path has a
live registered FIFA-side listener (CensusData component obj @0x79f25c0; CensusDataAPI
@0x43c70ff0; listener vector holds exactly one entry, 0x7810a58, whose vtable 0x1439742a8
sits beside the strings `CensusData` @0x1439742d0 and `SubscribeToCensusData` @0x143974320).
**But** `checkFUTRostersFlow` provably consumes no notification, and the census path is
currently *stable* (the 120 s cadence is healthy, not a storm). Changing it now adds a
variable to the experiment that fixes the gate. **Hold this until after FIX 1 is evaluated.**
If you do ship it later, the encoding is (ascending tag order, `TDFL` **empty** on purpose —
its element member is TDF type 0x07 *variable TDF*, which `heat2.py` cannot encode):
```python
def notify_server_census_data_fields():
return OrderedDict([
("CNP", (INT, 30 * 1000000)),
("NTMT", (INT, 90 * 1000000)),
("RTMT", (INT, 300 * 1000000)),
("TDFL", (LIST, (STRUCT, []))),
])
```
pushed alongside the existing reply via
`notification(COMP_CENSUSDATA, NOTIFY_SERVER_CENSUS_DATA, encode_tdf(...))` — both constants
already exist in the responder (lines 202, 211).
---
### FIX 4 (DO NOT ship) — non-empty Stats / OSDKSettings / userSettings replies
Ruled out as the gate by §1.7 (no pending jobs, no retries, chain continued past every empty
reply). Two ideas from the reports are worth *recording* but not shipping now, because each
risks perturbing a currently-working login:
* `Util::userSettingsLoad` — the real server answers a missing key with an **error**
(`UTIL_USS_RECORD_NOT_FOUND`, in the Util error-enum string block @0x143895a40+), whereas we
answer OK + `DATA=""`. Note there are **two** keys, `FirstTimeFlag` (RX #30) and
**`AchievementCache`** (RX #31, `rx_0031_0009_000a.bin`) — not a duplicate.
* Stats config trio (`getStatGroupList` 0x03, `getKeyScopesMap` 0x0f, `getPeriodIds` 0x14)
left empty means `STATS_ERR_CONFIG_NOTAVAILABLE` for downstream stat calls.
Neither can produce a *silent, timeout-free* hang, which is what we observe.
---
## 3. ORDERING — what ships together
| Order | Fix | Ship with FIX 1? | Rationale |
|---|---|---|---|
| 1 | **FIX 1**`ROSTERUPDATE_URL` + local HTTP stub | — | The gate. Ships alone or with FIX 2. |
| 2 | **FIX 2** — entitlement group | **YES** | Independent code path; live-confirmed broken; zero interaction with the roster flow (its only shared surface is `NotifyEntitlementsUpdated`, which already fires today). Both fixes require the same FIFA relaunch, so bundling costs one experiment instead of two. |
| 3 | FIX 3 — CensusData push | **NO** | Unproven; perturbs a currently-stable path; would confound FIX 1's signal. |
| 4 | FIX 4 — non-empty Stats/OSDKSettings/userSettings | **NO** | Ruled out as the gate; regression risk on a working login. |
**Ship FIX 1 + FIX 2 together in one relaunch.** They are separable in the log: FIX 1's signal
is a new socket to :8081, FIX 2's signal is `byte[entMgr+0x88] == 1`. If FIX 2 causes a crash
(the `group=NULL` DLC path, §2.2), back out FIX 2 only and re-run FIX 1 alone.
Keep FIX 2 to the two `ONLINE_ACCESS` entries on the first run; add the `CC*` DLC tags only
after confirming no crash.
---
## 4. WHAT STILL NEEDS A LIVE EXPERIMENT
### 4.1 Restart requirements — important
**FIX 1 requires a FIFA relaunch.** `fetchClientConfig(CFID='OSDK_ROSTER')` is issued **once**,
during FUT init (RX #22). A responder-only restart cannot re-deliver it — and worse, killing
the responder drops the Blaze socket, which raises `evt_online_disconnected`, which
transitions `CheckFUTRosterUpdateXML → unloadFUTDatabaseOnFail` and aborts FUT.
**FIX 2 may not require a relaunch** — if FIFA re-issues `listEntitlements` on its own, the
new reply is consumed immediately and `byte[entMgr+0x88]` flips. But since FIX 1 forces a
relaunch anyway, do not spend an experiment on this.
**Sequence for the combined run:**
1. Edit `blaze_responder_v3b.py` (FIX 1 a/b/c + FIX 2 a/b).
2. Start the HTTP stub on `127.0.0.1:8081`.
3. Restart the responder.
4. Relaunch FIFA 17, log in, enter FUT.
5. Watch, in order: `ss -tanp | grep FIFA17` for a `:8081` socket → HTTP stub access log →
`grep -c CardsDLL /proc/$(pgrep -x FIFA17.exe)/maps``/tmp/blaze_responder.log` for any
new RPC beyond PING / census re-subscribe.
**Preserve the current frozen process (66672) until the new run reproduces the stall or
passes it** — it is the only known-good snapshot of the exact wait state.
### 4.2 Free control experiment on the *current* frozen process (do this FIRST)
Before relaunching, kill the Blaze responder and watch pid 66672. Per the flow JSON,
`evt_online_disconnected` must transition `CheckFUTRosterUpdateXML → unloadFUTDatabaseOnFail`
and tear FUT down. If the loading screen visibly reacts, that **proves live** that the flow SM
is (a) alive and (b) parked in this state — closing the last inferential gap in §1.1 without
any code change. If nothing happens, the state-machine identification is wrong and the whole
plan needs re-examination. This is the highest-value/lowest-cost probe available right now,
and it costs only a process we were going to relaunch anyway.
### 4.3 Open items that need a breakpoint (not just a memory read)
1. **Resolve the downloader `this`.** The pointer chase `*[0x144bfb910] → +0x80` is wrong
(verified: yields 0x7c17308, a string pool). Break on `0x147a6cda0`, read `rcx`, then
`this = *[rcx+8]`, then read `[this+0x2a0]` (0 = never issued), `[this+0x2a8]` (attempt
counter, compared against 5 at 0x147a77460) and `[this+0x2b9]` (the retry-override flag).
This turns "the bail-out branch is taken" from a strong inference into a direct observation.
2. **The XML wire format required for `advance`.** The parser is reachable from
`RosterXMLDownloadedSuccess` @0x143af17f0 (site 0x147af03be) / `RosterXMLDownloadedFail`
@0x143af16d8 (site 0x147afd0b2); the compared fields are `.dbFUTVer` / `.dbFUTCRC` /
`.dbFUTLoc` (0x143af1750 / 0x143af1760 / 0x143af1770). Not yet reversed. **This is the
likely next gate** — a 404 produces `back`, which dead-ends at `unloadFUTDatabaseOnFail`,
so the XML must actually *succeed*. Cheapest route: serve `200 OK` with a minimal document,
capture what the parser rejects, iterate. Aim for an XML that reports "no update available"
so the flow advances with no further download.
3. **The five skip predicates** in `isFUTRosterXMLAvailable` (`0x147ad5410(obj)`, then
`sess->vt[0x100]` / `[0xd0]` / `[0xe8]` / `[0x108]` at 0x147d98a1d / 2d / 3d / 4d). Any one
returning true takes the 0x147d98a6b path, which raises an event and skips the download
entirely. One of these is plausibly an "already checked this session / offline"
short-circuit — **a zero-network way to advance the flow.** Worth identifying as a
fallback if the XML format proves expensive.
4. **`FUT_RS4_BASE_URL`** (@0x1438dbe88; `FutServerCall` builds `<base> + "ut/game/fifa17/"`,
format `"ut/game/%s/"` @0x1438dbe58, game id `"fifa17"` @0x1438dac20). Confirmed live to
have **zero** non-image copies — the UTAS layer has no base URL either. This is not the
current gate (that layer has not started), but it is the **next** one after `EnterFUT`.
Since the store is merged (§1.5), pre-seeding it into `OSDK_CORE` alongside FIX 1(c) is
free insurance — the reader sites are 0x146ec98e8 / 0x146f3b2b4.
5. **Which group string FUT passes to `HasEntitlement`.** Recovered literals are `"FIFA16PC"`
(formatted from `"FIFA16%s"` @0x143b03708 + `"PC"` @0x1438fe5fc at 0x147bb78b7) and
NULL/empty (0x148198340). Sending both group literals is a hedge, not a proof. The
script-callable natives (table 0x144311d10..0x144312050; slot 0x144311e80 → `HasEntitlement`
thunk 0x146d0f620, slot 0x144311e88 → loaded-flag thunk 0x146d0f630) have data-driven
callers not visible in the disassembly.
### 4.4 Closed by this synthesis pass — do not re-investigate
* **Config store scoping** — MERGED, via the `'scfg'``'_all'` section lookup decoded at
0x147199d90 (§1.5). CFID choice is not load-bearing.
* **URL wrapping** — the client-config branch (0x147a77550) does **not** wrap the value;
only the ini branch applies `"https://%s"` (@0x143af1840). **Serve an absolute URL.**
* **Whether `LoadFUTDatabase` / `LoadFUTSquad` completed** — they must have. Only
`CheckFUTRosterUpdateXML` sends `ShowLoadingIcon`, and the spinner is up.
* **Whether the flow reaches `EnterFUT` after the advance** — yes, immediately, via
`CheckFUTSquadBinFile`'s `"false"` transition (§1.2). No squad bin file means no live-DB
popup and no second download.
@@ -0,0 +1,640 @@
# FUT / UTAS (RS4) CONNECT PLAN — FIFA 17, clean-room
Synthesis of four independent reversals of `CardsDLL_Win64_retail.dll` (PE base `0x180000000`,
live-mapped at `0x6ffffc140000`, slide `0x6FFE7C140000`) plus live `/proc/932452/mem` reads and
`/tmp/blaze_responder.log` / `/tmp/roster_server.log`.
Scope: get FUT to **open a socket to us, authenticate, and stop showing "error connecting to
FIFA 17 Ultimate Team"**. Not full playable FUT.
**Headline correction to BRIEF8.** The brief's hypothesis ("we serve the base-URL keys EMPTY, so
CardsDLL can't build a URL") is **wrong**. The URL is built fine. CardsDLL falls back to a
**hardcoded default base URL** and that host is dead:
```
default base = "http://easw.easports.com:8099/" .rdata file 0x21d680 = VA 0x18021e280
default ptr = VA 0x1802caa08 -> live 0x6ffffc35e280 -> same string
live descriptors: Counter({(b'http://easw.easports.com:8099/', ks=0): 126}) # ALL 126 entries
$ getent hosts easw.easports.com -> rc=2 (NXDOMAIN), no /etc/hosts entry
```
So resolution succeeds, DNS fails, `connect()` is never reached, no SYN — exactly the observed
symptom. The in-process telemetry ring confirms the flow ran and died with no HTTP status at all:
`{"type":"utas","status":"start_flow"}` then `{"type":"utas","status":"error","status_code":"0"}`.
This changes the fix from "make a URL exist" to "point the URL at us" — and it opens a **zero-config
fallback** (§2.0): just make `easw.easports.com` resolve to `127.0.0.1` and listen on `:8099`.
---
## 1. THE URL + AUTH CHAIN
### 1.1 Two tables drive everything
**Module (group) table — 48 entries, VA `0x18021df80`, stride 0x10 = `{char* pathTemplate; char* NAME}`**
(dumped from the file; entries 4547 have a NULL path):
| idx | path template | NAME | idx | path template | NAME |
|---|---|---|---|---|---|
|0|`ut/%s/auctionhouse`|AUCTIONHOUSE|24|`ut/%s/season/%%s/reset`|SEASONRESET|
|1|`ut/%s/clubUser`|CLUB_USER|25|`ut/%s/season/friendly`|FRIENDLYSEASON|
|2|`ut/%s/user/list`|CLUB_INFO|26|`ut/%s/purchased`|PURCHASED|
|3|`ut/%s/club`|CLUB|27|`ut/%s/store`|STORE|
|4|`ut/%s/defid`|DREAM|28|`ut/%s/watchList`|WATCHLIST|
|5|`ut/%s/squad`|SQUAD|29|`ut/delete/%s/watchList`|DELETEWATCHLIST|
|6|`ut/delete/%s/squad`|DELETE_SQUAD|30|`ut/%s/tradePile`|TRADEPILE|
|7|`ut/%s/leaderboards/options`|LBOPTIONS|31|`ut/%s/trade`|TRADE|
|8|`ut/%s/leaderboards`|LBDEFAULT|32|`ut/delete/%s/trade`|DELETETRADE|
|9|`ut/%s/activeMessage`|PAFPRACTICE|33|`ut/%s/marketdata`|MARKETDATA|
|10|`ut/%s`|**UT**|34|`ut/%s/clientdata`|CLIENTDATA|
|11|`ut/%s/user`|**USER**|35|`ut/auth`|**AUTH**|
|12|`ut/delete/%s/user`|DELETEUSER|36|`ut/delete/auth`|DELETE_AUTH|
|13|`ut/%s/item`|ITEMS|37|`ut/%s/phishing`|PHISHING|
|14|`ut/%s/item/resource`|ITEMS_BY_RES|38|`ut/%s/captcha`|CAPTCHA|
|15|`ut/delete/%s/item`|DELETEITEMS|39|`ut/%s/tfa`|TFA|
|16|`ut/%s/match`|MATCH|40|`ut/%s/squad/mode`|SQUADMODE|
|17|`ut/%s/sbs`|SBC|41|`ut/%s/draft/mode`|DRAFT|
|18|`ut/%s/tournament`|TOURNAMENT|42|`ut/%s/champion`|CHAMPIONS|
|19|`ut/%s/tournament/user`|TOURNAMENTUSER|43|`ut/v2/%s/store`|V2STORE|
|20|`ut/delete/%s/tournament/user`|TOURNAMENTQUIT|44|`ut/%s/livemessage`|LIVEMESSAGE|
|21|`ut/%s/season`|SEASON|45|*(null)*|ADMIN|
|22|`ut/%s/season/user`|SEASONUSER|46|*(null)*|DEBUG|
|23|`ut/%s/season/%%s/user`|SEASONUSER_ALTER|47|*(null)*|MAINTENANCE|
**Call (endpoint) descriptor table — 125 live entries + terminator, VA `0x1802caa20`, stride 0x30:**
```
+0x00 char* callName e.g. "GetSettings"
+0x08 u64 moduleIdx index into the 48-entry table above
+0x10 char* CALL_TAG e.g. "GETSETTINGS" (uppercase, used in the config key)
+0x18 char* baseUrl <-- what resolve() writes; live = the easw default for all 126
+0x20 u32 preAuthFlag 1 for GetSettings + the market/IS* calls, 0 otherwise
+0x21 u8 killSwitch live = 0 for all 126
+0x28 fnptr setKillSwitch one thunk per call, e.g. 0x180124020 writes 0x1802cae31
```
Verified entries relevant to boot: `21 GetSettings mod=10 UT flag=1`, `22 Authentication mod=35 AUTH`,
`23 Login mod=11 USER`, `24 Logout mod=36 DELETE_AUTH`, `27 CreateUser mod=11 USER`,
`28 GetUserInfo mod=11 USER`, `35 GetUserCredits mod=11`, `36 GetUserData mod=11`,
`14 LoadActiveSquad mod=5 SQUAD`, `54 KeepAlive mod=16 MATCH`, `100 GetHubData mod=10 UT`,
`101 GetUserMassInfo mod=10 UT`, `5 GetClubInfo mod=2 CLUB_INFO`.
### 1.2 Base-URL resolution — `RS4::ServerSettings::resolve` @ `0x180124270`
Runs **once, at EnterFUT**, after all `fetchClientConfig` traffic (blaze log: whole CFID sweep at
21:58:11, resolver object at `0x1802e6408` already populated). Three passes, **last non-empty wins**:
1. **Hardcoded default**`mov r14,[rip+0x1a672c] # 0x1802caa08` @ `0x1801242d5`, splashed into every
descriptor's `+0x18` by the loop @ `0x180124350`.
2. **`FUT_RS4_APIURL_<MODULE_NAME>`** — loop @ `0x180124390`, `edi < 0x30` (48), `rbx` walks the module
table's NAME field, key format string `0x18021fa70`.
3. **`FUT_RS4_URL_<CALL_TAG>`** — loop @ `0x180124440`, `r12 = 0x87`, `rbx` walks descriptor `+0x10`,
key format string `0x18021faa0`.
Each pass requires **HasKey (`vt+0xe8`) == true AND GetString (`vt+0x30`) non-empty**
(`lea r8,[rip+0xc58bb] # 0x1801e9caf` is the default = a NUL byte; `cmp BYTE PTR [rbp-0x80],0x0; je skip`
@ `0x1801243ff`). Out buffer 0x200 bytes.
**`FUT/MODULE_BASEURL_%s` (`0x18021fa58`) and `FUT/SINGLE_BASEURL_%s` (`0x18021fa88`) are DEAD CODE**
in this build — independently confirmed by three reversers and re-verified here: both are `snprintf`'d
into `[rbp+0x180]` (`0x18012439f`, `0x18012444f`) and that buffer is **never** passed to `HasKey`/
`GetString`; the lookups take `lea rdx,[rsp+0x40]`, which holds the `FUT_RS4_*` form. Serving them is
harmless but useless.
**`FUT_TARGET_HOSTNAME` / `FUT_TARGET_PORT` are NOT the API base** — they belong to a separate
ICMP/traceroute latency probe (@`0x180180876`, siblings `FIFA_FUT_ALLOW_PING`, `FUT_DNS_TIMEOUT`,
`FUT_PING_TIMEOUT`, `FUT_MAX_HOPS`, `FUT_PUMP_RATE_MILLSEC`). **Do not serve `FUT_TARGET_PORT`**: a
copy/paste bug @`0x1801808e8` makes its `GetInt` read the key `FUT_MAX_HOPS` instead, so its presence
poisons the port with the hop count.
### 1.3 Value format and URL assembly (@ `0x180177a89``0x180177b62`, re-verified)
```
scan value for ':' cmp al,0x3a @0x180177aa0
bytes[colon+1] == '/' && [colon+2] == '/' -> strcpy_s verbatim @0x180177acf
else -> sprintf("http://%s") fmt @0x18022dc38
force trailing '/' mov BYTE PTR [rbp+rax-0x60],0x2f @0x180177b28
strcat_s(path) @0x180177b62
buffer 0x200 everywhere
```
So `http://127.0.0.1:8099/`, `http://127.0.0.1:8099` and `127.0.0.1:8099` all end up identical.
**Scheme: plain HTTP is native** (EA's own default is `http://…:8099/`); there is no scheme rewrite.
HTTPS would also be accepted verbatim (ProtoSSL verify is patched) but adds a variable — use http.
### 1.4 The platform token — `game/fifa17`
`BuildEndpointPath` @ `0x180123da0`:
```
movsxd rdi,[r14+rcx*8+0x2caa28] ; moduleIdx from the descriptor
call QWORD PTR [rax+0x250] ; engine/config service -> const char* token
lea r8,[rip+0xfbc7e] # 0x18021fac8 ; "game/%s" -> snprintf into a 0x40 buffer
mov r8,[r14+r8*8+0x21df80] ; moduleTable[moduleIdx].pathTemplate
snprintf(out, len, pathTemplate, "game/<token>")
```
The `%s` in `FUT_RS4_APIURL_%s` / `FUT_RS4_URL_%s` is a **module/call NAME, not a platform token**
that was the brief's second wrong assumption. The only path-level token is this one, and the evidence
says `fifa17`: live FIFA17.exe `.rdata` `0x1438dac20 = b'fifa17\0\0 2017\0'`, adjacent
`0x1438dbe58 = 'ut/game/%s/'`, `0x1438dbe88 = 'FUT_RS4_BASE_URL'`; process env `GAMEID=fifa17`;
our own roster server is already serving `/fifa17/fut/rosterupdate.xml`. **Not proven** (the getter
lives in the encrypted exe) — mitigate by wildcarding the segment. Final shapes:
```
ut/auth (AUTH — no platform segment at all)
ut/delete/auth (LOGOUT)
ut/game/fifa17 (UT -> + "/settings", "/hub", "/userMassInfo")
ut/game/fifa17/user (USER -> + "/credits", …)
ut/game/fifa17/squad (SQUAD -> + "/active?active=true" | "/active/user/<personaId>")
ut/game/fifa17/user/list (CLUB_INFO -> + "?personaIdList=…")
ut/game/fifa17/match (MATCH -> + "/keepalive")
```
**In the JSON body the platform is the literal `"pc"`** (VA `0x18021fe9c`, the only `\0pc\0` in the
whole DLL) with `"sku":"FFA17PCC"` (VA `0x18021fe90`). No runtime platform formatting exists.
### 1.5 The auth handshake
**Request** — module 35 `ut/auth`, JSON body ⇒ POST (the literal method string is set through a
ProtoHttp enum, not a `"POST"` literal — treat method as unproven and accept anything).
Headers (template @ `0x180220150`, file 0x21f550): `Accept: application/json` + `Content-Type:
application/json` + **`Hash: %s` only when POW is on** (feature flag `"POW"` @ `0x180235ce0`, read via
config `vt+0x50`, cached to `0x1802ef5d0/…d8` — we simply never serve the key, so no POW).
**No `X-UT-SID` on this first request** (there is no sid yet).
Body, built by `0x180125900` in this exact key order, then `0x1801a26d0` appends the identification:
```json
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc",
"clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,
"nucleusPersonaDisplayName":"CAGE","locale":"en-us","regionCode":"…",
"deviceId":"…","macAddress":"…",
"method":"authcode","identification":{"authCode":"OPENFUT-000…"}}
```
Key literals verified: `isReadOnly`0x1802201c0, `priorityLevel`0x1802201d0 (from global 0x1802cc528,
live 6), `sku`0x1802201e0, `nucleusPersonaPlatform`0x1802201e8, `clientVersion`0x180220200 (immediate 3),
`nuc`0x180220210, `nucleusPersonaId`0x180220218, `nucleusPersonaDisplayName`0x180220230 (fallback
literal `"mememe"`0x18022024c), `locale`0x180220254, `regionCode`0x1802201b0, `deviceId`0x180220260,
`macAddress`0x180220270, `method`0x18023641c = `authcode`0x180236428, `identification`0x180236438,
`authCode`0x180236448. The optional `"fat"`0x180236454 is **never** emitted (its gate `vt+0xb0` =
`0x1800cd820` = `xor al,al; ret`), and the derived-extra-fields hook `vt+0xa0` = `0x180122420` = `ret`.
Persona values come from FIFA17.exe via import thunks — with our Blaze LoginResponse they will be
`33068179` / `"CAGE"` (blaze log SESS.BUID / PDTL.DSNM).
**Response parser** @ `0x1801a2880` — a rapidjson member walk that recognises **exactly three keys**:
| key | VA | action |
|---|---|---|
|`sid`|0x180236458|`strlen` + string-assign into **ServerCall+0x1e8** (`0x1801a2ae8`) |
|`serverTime`|0x180236460|six `atoi` at char offsets 0,5,8,11,14,17 → `+0x208/+0x210` |
|`lastOnlineTime`|0x180236470|same → `+0x218/+0x220` |
Everything else is skipped. Return value is `setne` on the document terminating cleanly — **there is no
status-code check in the parser**, but the layer above requires a parseable body (see 1.7).
Datetime shape is `YYYY-MM-DD HH:MM:SS` (a `T` separator parses identically — `atoi` at fixed offsets).
**`sid` is the whole ballgame.** Header builder `0x180126080` (vtable slot 0x110) copies template
`0x180220100` = `"Accept: application/json\r\nContent-Type: application/json\r\nX-UT-SID: %s\r\n"`
(file 0x21f500 — one string; `strings(1)` split it, which is why the brief lists `X-UT-SID: %s` alone),
fills `%s` from `[this+0x1e8]` (`0x18012611b`) and appends it with fourCC `'apnd'` (`0x61706e64`)
via `[this+0x18]->vt[0x70]` (`0x180126231`). A getter for the same field is slot 0x108 = `0x180125520`.
The field is an eastl::string initialised empty in the ctor `0x1801a2210`.
**401/403 handling** @ `0x1801a33a0` / `0x1801a3447` — on 401 or 403 for a normal API call the client
**silently re-auths** (max 3, counter `[rcx+0x1e0]`); a 401 on `ut/auth` itself **truncates the stored
sid** and re-auths (max 2, counter `[rbx+0x350]`). Never answer 401/403 or we burn retries and then error.
`X-UT-VALIDATE: true` (VA `0x18022b398`) exists in the DLL but is not part of this path.
### 1.6 What the brief's other strings actually are (all four reversers agree)
* `%s/pow/mm/` (0x18021fab0) + `/fut/` (0x18021fabc) — a **content/CDN base** built at `0x18012450e`
from a *different* vtable slot (`vt+0x3f8`); live it is EMPTY (`obj+0x30 = 0`, `obj+0x38 = ""`) and
the code skips the whole block when empty. Production shape (leftover in memory):
`http://content.lt.easfc.ea.com:8080//fifa/fltOnlineAssets/2013/pow/mm/…`. Not the auth flow.
* `TRANSACTION_STATE_WAIT_FOR_AUTH_CODE` (0x1ef360) — the **FIFA-Points store purchase** state machine
(contiguous enum with `_WAIT_FOR_CART`, `_WAIT_FOR_CHECKOUT`, `_WAIT_FOR_CONSUME_SERVER`).
* `/user/hub`, `/user/registration` — **FUT Champions** suffixes (module CHAMPIONS →
`ut/game/fifa17/champion/user/hub`), not initial load.
* `/active/user/%lld`, `/active?active=true` — the **active-squad** loader (`0x18014DAD0`,
`RS4:FutSquadLoadServerResponse`).
* `http-rs4` (0x18022b4a8) — a **telemetry tag** passed next to `fut-rs4-server` (0x18022b4b8), not a scheme.
* There is a **second, exe-side FUT client** (`FutServerCall`, `FifaFutServiceImplementation`) with a
single key **`FUT_RS4_BASE_URL`** (no `%s`), path `ut/game/%s/`, sub-paths `users/club?sku=`,
`utStats?sku=`, `user/accountinfo`, and a *different* auth header:
`Easw-Session-Data-Nucleus-Id: %lld` (no X-UT-SID). Serve that key too.
### 1.7 Generic response pipeline and where "error connecting" is raised
`OnResponse` @ `0x18016D230`:
```
HTTP 204 -> skip parsing entirely (accepted)
empty body -> err = 0x63
JSON parse failure -> err = 0x3E6
else -> err = call->vt[0x60](resp, body) ; per-class error extractor
epilogue: [resp+0x1c] = err [resp+0x20] = httpStatus
```
`[resp+0x1c] == 0` is the universal success test.
The popup itself: **`FUT::WebServiceImpl` @ `0x180122440`** fires
`FireEvent(target="_global" (0x1801ed8a8), event="ServerFatalError" (0x1801ed890),
param="OSDK_LOST_CON_TO_EA" (0x18021dae0))` at `0x1801224d0` (alternate param
`"Unknown_FCC_Error"` 0x1801ed878 at `0x180122503`) — all four VAs verified by direct byte read.
Gate @ `0x1801224a7`: `cmp [rsp+0x58],0x2 / cmp eax,0x3` else fire.
It is **not** `FUTOnlineGameModeBase` (that string is a debug formatter, one xref, raises nothing).
The surrounding flow is a front-end `.nav` state machine, dispatched by string on event `0x3D`
(`0x180019BA0`, field `"StrParam"` 0x1801ed788): `beginFUTLogin``retrieveUserData`
`CheckRewards``loginComplete``FinalizeLogin` → telemetry `('GAME','ACTN','MODE',"enter_hub")`
→ transition into the FUT hub. Live strings in the process confirm the flow files:
`loading nav/fut/futFlow.nav`, `/fut/futLogInFlow.nav`, `/fut/futGameHubFlow.nav`.
**A 404 on the first user lookup is an accepted, non-fatal answer** (`0x18007AF20`:
`[rdx+0x1c]==0 ? ok : [rdx+0x20]==0x194 ? ok : ServerFatalError`) — it is the new-user branch
that routes to createClub.
---
## 2. THE FIX (minimal)
### 2.0 Path B first — the zero-config, zero-relaunch experiment
Because every descriptor **already** holds `http://easw.easports.com:8099/`, the cheapest possible test
needs no responder change at all:
```bash
echo "127.0.0.1 easw.easports.com" | sudo tee -a /etc/hosts
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/utas_server.py # binds 127.0.0.1:8099
# re-enter Ultimate Team (a game restart is safest — the resolver may have negatively cached)
```
Do this **in addition** to Path A below; it costs nothing and it removes the config store from the
equation (we have not proven CardsDLL reads the same merged `_all` section the exe uses).
### 2.1 (a) `blaze_responder_v3b.py` — fetchClientConfig additions
Insert after the `OSDK_ROSTER` block (~line 520) and change `client_config_for`:
```python
# --- FUT / UTAS (RS4) BASE URL --------------------------------------------
# CardsDLL RS4::ServerSettings::resolve @0x180124270 writes each of the 125
# endpoint descriptors' baseUrl (VA 0x1802caa20 + i*0x30 + 0x18) in 3 passes,
# last non-empty wins:
# 1. hardcoded default @0x1802caa08 = "http://easw.easports.com:8099/" (DEAD HOST)
# 2. cfg["FUT_RS4_APIURL_<MODULE_NAME>"] key fmt @0x18021fa70
# 3. cfg["FUT_RS4_URL_<CALL_TAG>"] key fmt @0x18021faa0
# A key only counts if HasKey(vt+0xe8) AND GetString(vt+0x30) is non-empty.
# The value is used verbatim when it contains "://" (@0x180177ab3), a trailing
# '/' is force-appended (@0x180177b28), then the endpoint path is strcat'd.
# FUT/MODULE_BASEURL_%s and FUT/SINGLE_BASEURL_%s are formatted but NEVER looked
# up (dead code). FUT_TARGET_PORT must NOT be served: bug @0x1801808e8 makes its
# GetInt read the key FUT_MAX_HOPS instead.
UTAS_BASE = "http://127.0.0.1:8099/"
FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
"DELETEUSER", "ITEMS", "ITEMS_BY_RES", "DELETEITEMS", "MATCH", "SBC",
"TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASON", "SEASONUSER",
"SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE",
"WATCHLIST", "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE",
"MARKETDATA", "CLIENTDATA", "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA",
"TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE",
"ADMIN", "DEBUG", "MAINTENANCE",
] # 48, VA 0x18021df80 NAME field
# Per-call overrides (pass 3, runs last and wins). Not required if pass 2 lands,
# but free insurance for the boot set.
FUT_RS4_CALLS_BOOT = [
"GETSETTINGS", "AUTHENTICATION", "LOGIN", "LOGOUT", "CREATEUSER",
"GETUSERINFO", "GETUSERDATA", "GETUSERCREDITS", "USERRELIABILITYINFO",
"GETHUBDATA", "GETUSERMASSINFO", "LOADACTIVESQUAD", "SQUADLIST",
"GETSQUADINFO", "GETCLUBINFO", "KEEPALIVE", "SEASONHISTORY",
]
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)] # exe-side FutServerCall @0x1438dbe88
)
```
```python
def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] ..."""
if cfid == "BlazeSDK":
return sorted(blazesdk_config() + FUT_RS4_CONFIG)
# FUT_RS4_* keys ride on EVERY CFID: we have not proven which section
# CardsDLL's accessor (engineTable[0x170] -> vt+0x118) reads.
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG)
```
Timing is safe: the whole CFID sweep (OSDK_CORE … OSDK_ROSTER) completes at login
(`/tmp/blaze_responder.log` 21:58:11, lines 909-1338), long before EnterFUT runs the resolver.
**Do not add:** `FUT_TARGET_PORT` (poisons the port via the `FUT_MAX_HOPS` bug),
`FUT/MODULE_BASEURL_*`, `FUT/SINGLE_BASEURL_*` (dead), `POW` (leave absent so no `Hash:` header /
proof-of-work round-trip is required).
### 2.2 (b) Minimal UTAS server — `fifa17-recon/tools/utas_server.py`
Plain HTTP on `127.0.0.1:8099`. Rules distilled from §1.7:
* **Never 401/403** (triggers the silent re-auth storm).
* Every body must be **parseable JSON** (parse failure ⇒ err `0x3E6` ⇒ ServerFatalError).
* `204` with no body is explicitly accepted and skips parsing — good for fire-and-forget calls.
* `404` is accepted **only** on the first user lookup (new-user branch).
* Accept any HTTP method on any route (the method enum was not decoded to a literal).
* SKU segment is wildcarded (`/ut/game/<anything>/…`).
```python
#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
* 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
ADDR = ("127.0.0.1", 8099)
LOG = "/tmp/utas_server.log"
SID = "OPENFUT-SID-0000000000000001"
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
PERSONA_NAME = "CAGE" # PDTL.DSNM
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
# ---- payloads -------------------------------------------------------------
def auth_body():
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
def user_info():
# Deserializer 0x18013EC10; every member optional (unknown key ids are
# skipped via 0x180135FF0), so {} also parses.
return {
"personaId": PERSONA_ID,
"clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
"clubNameChangeAllowed": True,
"currencies": [{"name": "coins", "value": 15000},
{"name": "points", "value": 0}],
"won": 0, "draw": 0, "loss": 0,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False,
"feature": {"trade": True},
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
"actives": [], "squadList": [],
}
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member. The member
# NAME is never compared, but the nesting level is required.
USER_GET = {"userInfo": user_info()}
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
USER_POST = {"login": True, "userData": user_info(),
"squad": {}, "starterPack": {}, "bonusPacks": []}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
G = r"/ut/game/[^/]+"
ROUTES = [
(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)),
(re.compile(G + r"/user/credits"), lambda m, h: (200, {"credits": 15000})),
(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"/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, {})),
(re.compile(G + r"/season"), lambda m, h: (200, {})),
(re.compile(G + r"/club"), lambda m, h: (200, {})),
]
def user_route(h):
if h.command == "POST":
return 200, USER_POST
if NEW_USER:
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
return 404, {}
return 200, USER_GET
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _handle(self):
n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n) if n else b""
log("%s %s" % (self.command, self.path))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
if body:
log(" body: %s" % body[:1200].decode("utf-8", "replace"))
code, payload = 200, {}
for rx, fn in ROUTES:
if rx.search(self.path):
code, payload = fn(rx, self)
break
else:
log(" !! UNMAPPED PATH -> catch-all 200 {}")
raw = b"" if payload is None else json.dumps(payload).encode()
self.send_response(code)
if raw:
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD" and raw:
self.wfile.write(raw)
log(" -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle
def log_message(self, *a):
pass
if __name__ == "__main__":
open(LOG, "a").close()
log("=== utas_server http://%s:%d ===" % ADDR)
http.server.ThreadingHTTPServer(ADDR, H).serve_forever()
```
Exact wire responses for the two that matter:
```
POST /ut/auth
HTTP/1.1 200 OK
Content-Type: application/json
{"protocol":1,"sid":"OPENFUT-SID-0000000000000001",
"serverTime":"2026-07-31 22:40:00","lastOnlineTime":"2026-07-31 22:40:00"}
GET /ut/game/fifa17/settings
HTTP/1.1 200 OK
Content-Type: application/json
{"configs":[]}
```
Every subsequent request must be answered 2xx and must carry (from the client)
`X-UT-SID: OPENFUT-SID-0000000000000001`.
### 2.3 Fastest validation with no relaunch (live poke)
CardsDLL live base `0x6ffffc140000` (slide `0x6FFE7C140000`). Write a pointer to your own
NUL-terminated URL string into every descriptor's baseUrl field:
```
for i in 0..125: *(u64*)(0x1802caa20 + i*0x30 + 0x18 + slide) = <ptr to "http://127.0.0.1:8099/">
```
then re-enter Ultimate Team. `/proc/PID/mem` writes work under `ptrace_scope=1` (already proven by
`openfut-poke`). This bypasses both the responder and the DNS question in one step.
---
## 3. OBSERVABLE SUCCESS SIGNALS (strongest first)
1. **A 4th FIFA socket**`ss -tnp | grep FIFA17` shows `SYN-SENT`/`ESTAB` to `127.0.0.1:8099`
(today there is none: three Blaze/roster sockets only). This alone proves URL resolution +
`connect()`, i.e. the whole of §1.21.4 landed.
2. **`/tmp/utas_server.log` shows a real request line** with the literal SKU:
`GET /ut/game/fifa17/settings` (or `POST /ut/auth`) plus headers
`Accept: application/json`, `Content-Type: application/json`, `User-Agent: ProtoHttp …`.
This is also how we *read* the SKU instead of guessing it.
3. **`POST /ut/auth`** with a body containing `"method":"authcode"` and our
`"identification":{"authCode":"OPENFUT-000…"}` — proves the Blaze authCode reached CardsDLL.
4. **Subsequent requests carry `X-UT-SID: OPENFUT-SID-0000000000000001`** — proves the sid was
stored at `ServerCall+0x1e8` and re-emitted by `0x180126080`; i.e. CardsDLL considers itself
authenticated. If a later request arrives with an *empty* `X-UT-SID:`, the sid is per-call and we
have another site to find.
5. **No re-auth storm** — the same `POST /ut/auth` must not repeat 23 times in a row (that pattern
means we answered 401/403 somewhere).
6. **In-game**: the "error connecting to FIFA 17 Ultimate Team" popup does not appear; the nav flow
advances `beginFUTLogin → retrieveUserData → loginComplete → FinalizeLogin` and emits the
telemetry `('GAME','ACTN','MODE',"enter_hub")`, landing on the FUT hub (or the starter-squad
screen if we answer 404 / `NEW_USER=True`).
7. **Live re-reads** (no relaunch needed):
* descriptor baseUrl at `0x1802caa20 + i*0x30 + 0x18 + slide` now reads `http://127.0.0.1:8099/`
instead of `http://easw.easports.com:8099/` → the config keys were consumed;
* `grep` process memory for `ut/game/` → currently **zero** hits; any hit proves
`BuildEndpointPath` @`0x180123da0` ran;
* the telemetry ring should now read `{"type":"utas","status":"start_flow"}` **without** the
following `{"type":"utas","status":"error","status_code":"0"}`.
---
## 4. WHAT STILL NEEDS A LIVE EXPERIMENT
| # | Unknown | Why it matters | Cheapest resolution |
|---|---|---|---|
|1|**The SKU token** returned by `engine->vt[0x250]` (feeds `game/%s`). `fifa17` is inferred from FIFA17.exe `0x1438dac20` + `GAMEID=fifa17`, never read directly (the getter lives in the encrypted exe).|Wrong token ⇒ our routes 404.|Wildcarded in the server (`/ut/game/[^/]+/…`); the first log line tells us the truth.|
|2|**Which config section CardsDLL reads** (`engineTable[0x170] → vt+0x118`, HasKey `+0xe8` / GetString `+0x30`) vs the exe's merged `_all` section (`getSection @0x14719e050`).|If it is a different store, the keys never land.|Already mitigated: keys attached to every CFID + the `/etc/hosts` Path B fallback.|
|3|**Report 3's finding that `ut/game/` has zero hits in 1.87 GB of process memory**, i.e. no RS4 path was ever built — which would put the block *upstream* of URL resolution (the nav flow never firing `beginFUTLogin`).|If true, config keys alone will not produce a socket.|Discriminator: after the fix, if the descriptors show our URL but still no SYN, instrument `0x180018F50` (`beginFUTLogin`) / `0x180122440` (the fatal-error site). Counter-evidence: the `utas start_flow`/`error status_code 0` telemetry pair says the flow *did* run and got no HTTP status — consistent with a DNS failure after the path was built into a since-freed heap buffer.|
|4|**HTTP method** for each call (the ProtoHttp method enum was not decoded to a literal `POST`/`GET`).|None if we accept all methods.|Server accepts every verb; log records the truth.|
|5|**Boot order** of the initial-load calls. `GetSettings` is the only non-market call with `preAuthFlag=1` (descriptor `+0x20`), which strongly implies it is first, but the driver (`CardsAdaptor vt[0x758]/[0x768]`) lives outside CardsDLL.|Determines which response must be right first.|Read the access log.|
|6|**Killswitch semantics** — `desc+0x21` is live 0 for all 126 (verified). If 0 means "disabled" rather than "not killed", `{"configs":[]}` would starve every call.|Could block everything after GetSettings.|If GetSettings returns and nothing else is ever requested, populate `configs` from the setter thunks (e.g. `0x180124020``0x1802cae31` = GetSettings' own byte) and the literal `FUT_INSTRUCTIONS_KILLSWITCH` (`0x180202FB0`).|
|7|**`Hash: %s`** value (auth-only header, from `svc->vt[0x420]`, hex digest — alphabet `0123456789abcdef` @0x21f598) and the **`POW`** feature flag.|Only if EA-side validation were mirrored; we control the flag.|Leave `POW` unserved; if a `Hash:` header shows up anyway, ignore it server-side.|
|8|**The per-class error extractor `call->vt[0x60]`** (`0x18016D47B`) that turns a parsed body into `[resp+0x1c]`. Key ids located: `code`(0x92), `reason`(0x279), `debug`(0xcb), `string`(0x2f6), `errorState`(0x10d); the function itself was not reversed.|If a 200 with a body still yields ServerFatalError, this is the next target — most likely our body contains a `code` member it reads as an error.|Keep bodies minimal (no `code`/`reason`/`debug` keys) — the supplied payloads already avoid them.|
|9|**The content/CDN base** (`vt+0x3f8``<X>/pow/mm/`, `<X>/fut/`), live EMPTY.|May cause a *secondary* failure after the API connects (LiveMessage / hub assets).|If a post-auth failure mentions pow/mm, add a config value shaped like `http://127.0.0.1:8099//fifa/fltOnlineAssets/2013`.|
|10|**The exe-side `FutServerCall`** (`user/accountinfo`, header `Easw-Session-Data-Nucleus-Id: %lld`, no X-UT-SID) — its response shape (`userAccountInfo`, `personas`, `userClubList`, `clubName`, `established`, `assetId`, `returningUser`) is read from live strings, not from a reversed parser.|A second, independent client that may error separately.|Route already stubbed to `200 {}`; iterate from its log line.|
### The next gate after this one
Assuming we clear auth, the flow reaches `retrieveUserData``FinalizeLogin`. Two outcomes:
* **Existing-club path** (`NEW_USER=False`): the hub loads against `{"userInfo":{…}}` with an **empty
club and empty squad** (`"actives":[]`, `"squadList":[]`). The hub will render, but the moment the
UI asks for real content — `LoadActiveSquad`, `SquadList`, `ViewCards` / `ut/game/fifa17/item`,
`GetUserMassInfo` — it needs an actual item set. Empty arrays should be *structurally* valid
(every member is optional, unknown key ids are skipped) but will likely surface as an empty club
screen or a "squad required" wall.
* **New-user path** (`NEW_USER=True`, or a 404 on the first GET): `GotoStarterSquad` /
`SetNewUserContextData``POST ut/game/fifa17/user` (CreateUser) with
`{"login":true,"userData":{…},"starterPack":{…},"squad":{…},"bonusPacks":[]}` — that is where a
**real starter pack + a legal 11-player squad** must be produced.
Either way, the next gate is **content, not protocol** — which is exactly `openfut-core`'s job
(`data/cards/`, `data/packs/`, the squad/club models). The clean handoff is: `utas_server.py` stays a
thin router that proxies `/ut/game/*/…` to `openfut-core` on `:8080`, keeping only `ut/auth` and the
X-UT-SID session locally. The item-JSON schema for that work is already recoverable — CardsDLL parses
by **hashed key ids** (FNV-1a `0x811c9dc5` @ `0x180180D00`, red-black map @ `0x1802E6598`, unknown ⇒
id `0x38C` and silently skipped) against a **907-entry key dictionary at VA `0x1802D2760`** (file
`0x2D0D60`, alphabetical: index 6 = `accountCreatedPlatformName``0x38a` = `yellowCards`). That
table is the complete FUT JSON vocabulary and should be dumped into `openfut-core` as the schema
reference for the content phase.
---
## Appendix A — disagreements between the four reversals, resolved
| Claim | Verdict |
|---|---|
|"Config keys are served empty ⇒ no URL" (report 2)|**Rejected.** Live: all 126 descriptors hold the easw default; DNS NXDOMAIN is the failure. Confirmed independently by reports 1, 3, 4 and re-verified here.|
|Descriptor table at `0x1802caa28` +0x10 (r1) vs `0x1802caa20` +0x18 (r3/r4)|**Same address** (`0x2caa38`). Normalised to base `0x1802caa20`, stride `0x30`, baseUrl at `+0x18`.|
|`FUT/MODULE_BASEURL_%s` live vs dead|**Dead** — verified in disasm here: formatted into `[rbp+0x180]`, lookups take `[rsp+0x40]`.|
|`%s` = platform token (brief, report 2) vs module/call NAME (r1, r3, r4)|**Module/call NAME.** The platform token appears only in `game/%s`.|
|`serverTime` `"YYYY-MM-DD HH:MM:SS"` (r2/r4) vs `"YYYY-MM-DDTHH:MM:SS"` (r3)|**Both parse** — six `atoi` at fixed char offsets 0,5,8,11,14,17. Use the space form.|
|Port 8082 (r3) vs 8099 (r1, r4)|**8099**, so the `easw.easports.com → 127.0.0.1` hosts trick works with zero config.|
|`/user/hub`, `/user/registration`, `/active/user/%lld`, `%s/pow/mm/` as auth/initial-load (brief)|**All misattributed** — FUT Champions, active-squad loader, and the FIFA-Points store respectively.|
|125 vs 126 endpoint descriptors|**125 real** (idx 0124) + one terminator at idx 125 (`moduleIdx = 48`, empty name).|
## Appendix B — the 125 call tags (for `FUT_RS4_URL_<TAG>` if ever needed)
ISSEARCH, ISOFFERTRADE, ISSTART, RELISTALL, GETCLUBUSERS, GETCLUBINFO, CLUBSEARCH, CLUBSTATS,
STAFFSTATS, CONSUMABLESSEARCH, DREAMSQUADSEARCH, GETSQUADINFO, UPDATESQUADNAME, RETRIEVESQUAD,
LOADACTIVESQUAD, DELETESQUAD, SAVESQUAD, SQUADLIST, GETLBOPTIONS, GETLBENTRIES, GETLBENTRYDATA,
GETSETTINGS, AUTHENTICATION, LOGIN, LOGOUT, RESETUSER, USERRELIABILITYINFO, CREATEUSER, GETUSERINFO,
SETUSERINFO, GETHISTORICAL, SETTUTDATA, GETTOWDATA, SETTOWDATA, SETFAVDATA, GETUSERCREDITS,
GETUSERDATA, VIEWCARDS, ASSIGNCARD, APPLYCARD, APPLYCARDBYRES, ACTIVATECARD, CONSUMECARD, DISCARDCARD,
DISCARDCARDBYRES, DISCARDACARD, MOVECARD, MOVECARDBYRES, SWAPCARD, CREATEMATCH, MATCHREADY,
DESTROYMATCH, PLAYGAME, RESETMATCH, KEEPALIVE, LOADCATEGORYDETAILS, LOADSETCHALLENGES, STARTCHALLENGE,
LOADSQUADCHALLENGE, SAVESQUADCHALLENGE, SUBMITCHALLENGE, TAGSETS, SETSBCDATA, TOURNAMENTLIST,
TOURNAMENTTEAMS, GETACTIVETOURNAMENTS, UPDATETOURNAMENT, TOURNAMENTLOADDATA, TOURNAMENTQUIT,
SEASONLIST, SEASONUPDATE, SEASONLOADDATA, SEASONQUIT, SEASONHISTORY, PURCHASEDITEMS, PURCHASEPACK,
PURCHASEITEMS, STOREPACKTYPES, STOREPACKQUANTITIES, ISREMOVEWATCH, ISWATCHTRADE, ISWATCHLIST,
ISVIEWTRADE, GETTRADEPILE, GETAUCTIONCOUNT, ISREMOVETRADE, GETSUGGESTEDPRICING, CHANGECLUBNAME,
GETPHISHINGQUESTION, SETPHISHINGANSWER, VALIDATEPHISHINGANSWER, GETTRUSTEDCONSOLELIST, GETCAPTCHA,
EXCHANGECAPTCHA, VALIDATECAPTCHA, VALIDATETFA, UPDATEUSERACTION, GETUSERACTION,
GETMANAGERQUESTREWARD, SETMANAGERQUESTCOMPLETE, GETHUBDATA, GETUSERMASSINFO, FIFAPOINTSTRANSFER,
FRIENDLYSEASONUPDATE, FRIENDLYSEASONLOAD, FRIENDLYSEASONHISTORY, GETAVAILABLELOANPLAYERS,
SIGNLOANPLAYER, GETCHEMISTRYATTR, GETDRAFTCURRENTSTATE, GETDRAFTCHOICES, GETDRAFTSTATS, GETDRAFTAWARD,
PURCHASEDRAFTMODE, PICKDRAFTCHOICE, PICKDRAFTAUTOCHOICE, GETSTORYMODEREWARD, CHAMPIONSHUB,
CHAMPIONSTOPX, CHAMPIONSRANK, CHAMPIONSFRIENDS, GRANTPRIZECHAMPIONUSER, REGISTERCHAMPIONSLEAGUE,
GetChampionsUserCountry, LIVEMESSAGE
*(Note: tag 124 is spelled `GetChampionsUserCountry` in mixed case in the binary — the key would be
`FUT_RS4_URL_GetChampionsUserCountry`, byte-for-byte.)*
@@ -0,0 +1,496 @@
# QOS_CONNECT_PLAN — "Unable to connect to the EA servers"
**Date:** 2026-07-31 · **Target:** FIFA17.exe (decrypted, VMA==runtime VA), live pid 50676
**Sources:** decrypted image + `/proc/50676/mem` + `/tmp/blaze_responder.log` + `/tmp/lsx.log` +
`blaze_responder_v3b.py`. **Clean-room: no leaked EA source was consulted.**
---
## 0. Verdict up front
> **The QoS hypothesis in BRIEF5 is WRONG. The empty `LTPS` and the unserved `127.0.0.1:17502` are
> both harmless and were never even reached. The bug is a *silent type error in our `CONF` map*:
> three of the values we send are TimeValue-typed and FIFA's duration parser rejects bare integers,
> so `pingPeriod`, `defaultRequestTimeout` and `connIdleTimeout` all land as `0`.
> The one-line class of fix is `"30000000"` → `"30s"`.**
BRIEF5's premise is also factually wrong and should be retired: **session 1 did not stay up for
~2.5 minutes of pings.** It died at `16:38:07`, one second after the login burst, and the whole log
contains exactly one `Util::ping` ever. The "2.5 minutes" is dead air between two failed connections.
Three of the four independent reverser passes converged on this cause. Everything load-bearing below
was **re-verified from scratch** for this synthesis (live reads + raw bytes re-disassembled), and two
new pieces of proof were added that no individual report had — see §1.5 and §1.6.
---
## 1. THE CAUSE — end to end
### 1.1 What we actually send
`blaze_responder_v3b.py:400-406`, inside `PreAuthResponse.CONF` (a `map<string,string>`):
```python
("autoReconnectEnabled", "1"), # OK — read via atoi
("connIdleTimeout", "90000000"), # BROKEN — TimeValue
("defaultRequestTimeout", "30000000"), # BROKEN — TimeValue
("maxReconnectAttempts", "5"), # OK — read via atoi
("pingPeriod", "20000000"), # BROKEN — TimeValue
```
We wrote microseconds as bare integers. FIFA reads these five keys through **two different vtable
getters**, and only one of them accepts a bare integer.
### 1.2 The two getters
`ConnectionManager` vtable (live vptr `0x1438a0850`):
| Slot | Function | Semantics |
|---|---|---|
| `vt+0x48` | `0x146e1bb80`-adjacent | raw `getConfigString` |
| `vt+0x50` | `0x146e1bb80` | **uint32** — string → `atoi` (import `0x148e21ad8`). **Works.** |
| `vt+0x58` | `0x146e1bda0` | **TimeValue** — string → duration parser `0x1479b2d50`. **Broken for us.** |
`0x146e1bda0` re-disassembled live (raw bytes read out of pid 50676 for this document):
```
146e1bda0: 53 push rbx
146e1bda1: 4883ec20 sub rsp,0x20
146e1bda5: 488b01 mov rax,[rcx]
146e1bda8: 4c89c3 mov rbx,r8
146e1bdab: 4c8d442430 lea r8,[rsp+0x30]
146e1bdb0: ff5048 call [rax+0x48] ; getConfigString
146e1bdb3: 84c0 test al,al
146e1bdb5: 7506 jne 0x146e1bdbd
146e1bdb7: 4883c420 5b c3 add rsp,0x20; pop rbx; ret ; key absent -> false
146e1bdbd: 488b542430 mov rdx,[rsp+0x30]
146e1bdc2: 4889d9 mov rcx,rbx
146e1bdc5: e8866fb900 call 0x1479b2d50 ; TimeValue::parse <-- return value
146e1bdca: b001 mov al,0x1 ; <-- CLOBBERED, UNCONDITIONAL "success"
146e1bdcc: 4883c420 5b c3 add rsp,0x20; pop rbx; ret
```
Line `146e1bdca: b0 01` is the whole problem: **the parser's boolean result is thrown away.** The
caller is told the value was applied. It was not.
### 1.3 The parser rejects us
`0x1479b2d50` accumulates digits, then dispatches on the byte that terminates the run. Raw bytes at
`0x1479b2db4`, read live:
```
1479b2db4: 80f964 7435 cmp cl,'d' ; je
1479b2db9: 80f968 742b cmp cl,'h' ; je
1479b2dbe: 80f96d 7414 cmp cl,'m' ; je (+ 's' lookahead -> ms)
1479b2dc3: 80f973 740a cmp cl,'s' ; je
1479b2dc8: 80f979 7535 cmp cl,'y' ; jne 0x1479b2e02
...
1479b2e02: 30c0 xor al,al ; return false
1479b2e04: eb50 jmp 0x1479b2e56 ; SKIPS the store at 0x1479b2e46
```
Accepted grammar: optional `-`, then `:`-separated `<digits><unit>` with unit ∈ `{y, d, h, m, ms, s}`.
A **NUL terminator with no unit** — i.e. exactly `"20000000"` — falls straight to `0x1479b2e02`,
returns false, and **never executes `mov [r14],rax`**. The caller pre-zeroes each out-slot
(`0x146e1d0af`, `0x146e1d10c`, `0x146e1d143`, all `mov QWORD PTR [rbp+N],rdi` with `rdi=0`), so the
value the SDK ends up applying is **0**.
### 1.4 Live proof that all three landed as zero
Read out of pid 50676 for this document. `ConnectionManager = 0x43c47ff0` (vptr `0x1438a0850` ✓,
`CM+0x1288 = "Blaze 15.1.1.3.0 (OpenFUT)\n"` — our own `SVER`, so this is the object our reply
configured):
| Field | Offset | Written at | **Live value** | What we intended |
|---|---|---|---|---|
| `pingPeriod` | `CM+0xd1c` | `0x146e1d0c9` / `0x146e1d0f5` | **15000** | 20000 |
| `defaultRequestTimeout` | `CM+0x278` | `0x146e1d12c` | **0** | 30000 |
| `connIdleTimeout` | `CM+0xd28` | `0x146e1d164` | **0** | 90000 |
| `maxReconnectAttempts` | `CM+0xc5c` | `0x146e1d1b6` | 5 ✓ | 5 |
| `autoReconnectEnabled` | `CM+0x11b7` | `0x146e1d188` | 1 ✓ | 1 |
The split is exactly along the getter boundary: **every `atoi` key took, every TimeValue key was
dropped.** That is not a coincidence, it is the signature.
The `15000` is *not* a "key missing" default — it is the clamp. Bytes at `0x146e1d0a5`, live:
```
146e1d0a5: 488d1584 39a8fc lea rdx,[rip-0x357867c] ; -> 0x1438a0a30 = "pingPeriod"
146e1d0ac: 4889d9 mov rcx,rbx
146e1d0af: 48897d07 mov [rbp+7],rdi ; pre-zero out-slot
146e1d0b3: ff5058 call [rax+0x58] ; getConfigTimeValue -> always true
146e1d0b6: 48be cff753e3a59bc420 movabs rsi,0x20c49ba5e353f7cf ; /1000 magic
146e1d0c0: 84c0 test al,al
146e1d0c2: 750d jne 0x146e1d0d1
146e1d0c4: b8983a0000 mov eax,0x3a98 ; 15000 = key-absent default
146e1d0c9: 89831c0d0000 mov [rbx+0xd1c],eax
```
`al` is always 1, so we take the `jne` — the parsed value (0) is divided by 1000 → 0, then the
`cmovl` at `0x146e1d0f2` clamps anything below 1000 up to `0x3a98`. Reaching 15000 *through the
clamp* is positive proof the parse produced 0. The `lea` also confirms the key name byte-for-byte:
`0x1438a0a30` is exactly the address our own source comment on line 406 cites.
### 1.5 NEW PROOF — session 2's response callback provably never ran
`onPreAuthResponse` (`0x146e1cf10`) has **no branch on its success path that can skip
`0x146e1d1cb: call 0x146e1e460` (sendPing)**. So "did a ping happen" is a perfect oracle for "did the
callback run". The log shows exactly one ping ever (`RX #3984`, 16:38:06, conn 43427) and none in
session 2.
`onPingResponse` (`0x146e1d290`) stamps `CM+0x11a0 = response STIM` and `CM+0x11a4 = local tick`.
Live values, decoded for this document:
- `CM+0x11a0 = 1785541086`**`2026-07-31 16:38:06`**, and our responder builds `STIM` as
`int(time.time())` (`blaze_responder_v3b.py:564`) — this is literally the timestamp *we* generated
and sent in session 1.
- `CM+0x11a4 = 14437471 ms`. This is a system-monotonic tick, not process-relative. Against
`/proc/uptime = 16638.9 s` at wallclock `17:14:48`, it back-solves to **`16:38:06`**.
Two independent clocks, both landing on **16:38:06**, not 16:40:34. The preAuth response callback
last completed in session 1 and **did not run at all in session 2** — even though our reply was
byte-identical (`cmp -l rx_3983… rx_4009…` differs in one byte, offset 13 = msgNum) and was fully
received (the raw 772-byte payload is still sitting in the recv buffer at `0x43c4e2xx-0x43c4e373`).
**The teardown therefore precedes the dispatch of our reply. Nothing about the reply's *content* is
being rejected.** That kills the "QoS field validation" theory outright — the QoS fields are parsed
*inside* the callback that never ran.
### 1.6 NEW PROOF — why session 1 survived preAuth and session 2 did not
This is the asymmetry BRIEF5 asked about, and it falls straight out of §1.4.
`ConnectionManager::ctor` at `0x146e187ea`:
```
146e187ea: 488b5608 mov rdx,[rsi+0x8] ; BlazeHub
146e187ee: 8b8244050000 mov eax,[rdx+0x544]
146e187f4: 89867802 0000 mov [rsi+0x278],eax ; defaultRequestTimeout = hub+0x544
```
Live `hub = 0x43c47330`, `hub+0x544 = 10000`. So a freshly-constructed ConnectionManager has a
**healthy 10000 ms request timeout** — and `CM+0x278` is only ever overwritten **inside the preAuth
*response* callback** (`0x146e1d12c`).
| | `CM+0x278` while its own preAuth RPC was outstanding | Outcome |
|---|---|---|
| conn A (43427, 16:38:06) | **10000 ms** (ctor default, not yet clobbered) | preAuth callback ran → ping sent → login burst |
| conn C (35037, 16:40:34) | **0** (clobbered by conn A's callback, persists for the CM's lifetime) | preAuth job expires before the reply is dispatched → teardown |
**Our own preAuth reply poisons the timeout that the *next* preAuth needs.** Session 1 worked *because
it ran before our config was applied*. That is why the identical bytes produce opposite outcomes, and
why no amount of varying the QOSS content would ever have helped.
The same zero explains the rest of the log: with `connIdleTimeout = 0`, **every** connection dies at
its first idle moment — conn A right after the ping reply, conn B (`43131`) right after `TX #4008.0`,
the last frame of the login burst, conn C right after our preAuth reply. Conn B's 24 RPCs all
succeeded because localhost replies land inside the same SDK tick they were issued on, so the expiry
sweep never saw them outstanding.
Conn B also explains itself: `onSocketConnected` (`0x146e1cb60`) gates preAuth on a flag —
```
146e1cbed: 4038bb3c0b0000 cmp BYTE PTR [rbx+0xb3c],dil ; dil = 0 (errorCode)
146e1cbf4: 7507 jne 0x146e1cbfd
146e1cbf6: e8f2140000 call 0x146e1e0f0 ; sendPreAuthRequest
146e1cbfb: eb0c jmp 0x146e1cc09
146e1cbfd: c68380110000 01 mov BYTE PTR [rbx+0x1180],1
146e1cc04: e854180000 call 0x146e1e460 ; sendPing only
```
Conn B skipped preAuth (its first frame is `fetchClientConfig msgNum=2`) → `CM+0xb3c` was non-zero →
conn B was an **SDK auto-reconnect** (`autoReconnectEnabled=1`, `maxReconnectAttempts=5`, both live
✓). `CM+0xb3c` reads **0** now, so the flag is cleared on full teardown — consistent with conn C
re-sending preAuth. And `msgNum` is one monotonic counter `0…26` across all three sockets, with live
`CM+0xc50 = 26` ✓ — **there is only one ConnectionManager**, so "session 2 is a different kind of
connection" is false.
### 1.7 The QoS path is exonerated, with disasm
`QosManager::startLatencyProbes` `0x146e1dae0`, re-disassembled live:
```
146e1dae0: 53 push rbx
146e1dae1: 4883ec20 sub rsp,0x20
146e1dae5: 488b8120020000 mov rax,[rcx+0x220] ; LTPS end
146e1daec: 4889cb mov rbx,rcx
146e1daef: 48398118020000 cmp [rcx+0x218],rax ; LTPS begin
146e1daf6: 7508 jne 0x146e1db00
146e1daf8: 30c0 xor al,al ; EMPTY -> false, benign, no side effects
146e1dafa: 4883c420 5b c3 add rsp,0x20; pop rbx; ret
```
Its caller `QosManager::initialize` `0x146e1c3f0` has a dedicated no-probes-started branch:
```
146e1c5aa: e831150000 call 0x146e1dae0
146e1c5af: 84c0 test al,al
146e1c5b1: 7508 jne 0x146e1c5bb
146e1c5b3: 4889f9 mov rcx,rdi
146e1c5b6: e875d5ffff call 0x146e19b30 ; normal QoS-completion fn
146e1c5bb: c6471001 mov BYTE PTR [rdi+0x10],1 ; initialized = true
```
Live `QosManager = CM+0x1db0 = 0x43c49da0` (vptr `0x1438a07c8` ✓) proves that exact path ran to
completion:
| Field | Live | Meaning |
|---|---|---|
| `Q+0x10` | **1** | `initialize()` reached its final store — completed |
| `Q+0x20` | 0 | both QoS tests off — our `enableQos*Test=false` parsed fine |
| `Q+0x218` / `Q+0x220` | 0 / 0 | LTPS empty → probe loop `0x146e1c4bb..0x146e1c4f0` never ran |
| `Q+0x18` | 0 | no DirtySDK `QosApi` object exists |
| `Q+0x1c8` / `Q+0x1f8` / `Q+0x250` | 10 / 17502 / 5000000 | our `LNP` / `BWPS.PSP` / `TIME` arrived intact |
| `hub+0x53f` | 1 | QoS is *enabled*; the early-out at `0x146e1c44b` was **not** taken |
| `CM+0x1f60` | 1 | SDK's own "QoS ready" flag is set |
So QoS ran, with QoS enabled, with an empty ping-site list, and **reported ready**.
**Nothing ever contacted 17502.** Verified for this document: no `445e` in `/proc/net/{tcp,udp,tcp6,udp6}`,
and `ss -lntup` shows our responder listening only on 42127 / 42130 / 42131. The `BWPS` port is merely
the `QosApi` *create* argument (`0x146e1975a: movzx r8d,WORD PTR [rbx+0x1f8]`), and the real probe
transport is HTTPS (`https://%s:%u/qos/qos` @ `0x143aa75c0`) driven **only** by `LTPS` entries, of
which there are none.
`PinQosError_ReferenceEvent` @`0x1439de5c8` has zero code xrefs (it sits in a telemetry name table
next to `CM_WonTournament`, `PS3TOSAccepted`, …) and `POW:sConnectionManager` @`0x143995b88` is an
allocation tag in the **powdll loader** string block. Both anchors in BRIEF5 are red herrings.
### 1.8 The on-screen message corroborates all of the above
The popup text is loc key `OSDK_LOST_CON_TO_EA` @`0x14395c6c8` (resolved English on the heap at
`0x7b438e0`). Selection site `0x1471aa220`, bytes re-read live at `0x1471aa286`:
```
1471aa286: 83ba0c02000001 cmp DWORD PTR [rdx+0x20c],1
1471aa28d: 488d0534247bfc lea rax,[rip-0x384dbcc] ; 0x14395c6c8 OSDK_LOST_CON_TO_EA
1471aa294: 4c8d1d157d7afc lea r11,[rip-0x38582eb] ; 0x143951fb0 OSDK_A_R30
1471aa29b: 4c0f44d8 cmove r11,rax
```
Caller `0x1471a1dda` passes `rdx = cnncMgr+0x4c4`, so the tested field is `cnncMgr+0x6d0` — the
**"an EA request is currently in flight" busy flag** (set to 1 immediately before each async Blaze op
at `0x1471b50bc`, `…5115`, `…5202`, `…5282`, `…5322`, `…5cb1`, `0x1471b6578`; cleared on completion at
`0x14717d7bb`, `0x147190d06`, `0x1471b4fa4`, `0x1471b501e`, `0x1471b54a8`, `0x1471b5c37`, `0x1471b65c6`,
`0x1471d5486`, `0x1471d6919`).
So the message means, literally: **"the socket died while an RPC was outstanding."** Not "QoS
validation failed", and not "login timed out" — the 30 s login timeout path
(`LoginStateVersionCheck::Tick` `0x1471b4ee0`, `lea eax,[rdx+0x7530]`) resolves to table index 0 and
yields `OSDK_A_R30`, a *different* on-screen string. The alternative state-machine selector also did
not fire: the live `LoginStateLogin` object (`0x43d189d8`, vtable `0x14395c180`) still has
`+0x7c errCode = 0` and `+0x80 = 0x143951fb0` (the ctor default), i.e. no error was ever latched.
The popup is therefore a pure transport-disconnect notice, and it is *exactly* what a request that
expires under a 0 ms timeout would produce.
### 1.9 Causal chain, one paragraph
We send `defaultRequestTimeout="30000000"` in `PreAuthResponse.CONF`. FIFA reads it with the TimeValue
getter `0x146e1bda0`, whose parser `0x1479b2d50` requires a unit suffix and returns false on a bare
integer; the getter discards that false (`146e1bdca: b0 01`) and the SDK applies the pre-zeroed
out-slot, i.e. **0**, to `CM+0x278` — overwriting the ctor's healthy 10000 ms — plus `CM+0xd28 = 0`
(idle timeout) and `CM+0xd1c` clamped to 15000. This happens *inside session 1's preAuth response
callback*, so session 1's own preAuth was still protected by 10000 ms and completed normally. From
that instant on, every connection on this ConnectionManager dies at its first idle moment, and any RPC
that is not answered within the issuing tick is expired immediately. When the user goes online at
16:40:34, conn C issues `Util::preAuth` under a 0 ms timeout; the job is expired before our reply is
dispatched, the callback never runs (proved by the 16:38:06 ping tick), the SDK tears the socket down
with `cnncMgr+0x6d0 == 1`, and `0x1471aa220` selects `OSDK_LOST_CON_TO_EA`: **"Unable to connect to
the EA servers at this time."**
---
## 2. THE FIX — ranked and minimal
### FIX 1 — REQUIRED, proven, 3 lines, no new listener or thread
`blaze_responder_v3b.py:401-406`. Change bare microsecond integers to unit-suffixed duration strings:
```python
("connIdleTimeout", "90s"), # was "90000000" -> parsed to 0
("defaultRequestTimeout", "30s"), # was "30000000" -> parsed to 0
("pingPeriod", "20s"), # was "20000000" -> clamped to 15000
```
`"90000ms"` / `"30000ms"` / `"20000ms"` are equally valid. Leave `maxReconnectAttempts` and
`autoReconnectEnabled` as plain integers — they go through the `atoi` getter and already work.
Grammar accepted by `0x1479b2d50`: optional leading `-`, then `:`-separated `<digits><unit>` with
unit ∈ `{y, d, h, m, ms, s}`; combined as `((((y*365+d)*24+h)*60+m)*60+s)*1000+ms`, then ×1000 → µs.
Corroborating precedent from the shipped image: the default for `QosConfigInfo.timeout` is the literal
string `"5s"` @ `~0x14388cc90`.
While editing, add a comment recording the getter split so this class of bug cannot recur:
```python
# CONF value TYPES matter. Keys read via ConnMgr vt+0x50 (0x146e1bb80) go through
# atoi -> plain integers are fine. Keys read via vt+0x58 (0x146e1bda0) go through
# TimeValue::parse (0x1479b2d50), which REQUIRES a unit suffix (y/d/h/m/ms/s) and
# silently yields 0 for a bare integer -- 0x146e1bdca discards the parser's bool.
```
**Non-interactive verification (no relaunch needed to check the *parse*, only a reconnect):**
read the live ConnectionManager and assert the three fields flipped.
```bash
PID=$(pgrep -x FIFA17.exe)
python3 - <<'EOF'
import struct
PID=<pid>
f=open("/proc/%d/mem"%PID,"rb")
def u32(va): f.seek(va); return struct.unpack("<I",f.read(4))[0]
CM=0x43c47ff0 # re-locate: scan rw memory for qword == 0x1438a0850
print("pingPeriod CM+0xd1c =", u32(CM+0xd1c), "(want 20000, currently 15000)")
print("defReqTO CM+0x278 =", u32(CM+0x278), "(want 30000, currently 0)")
print("connIdleTO CM+0xd28 =", u32(CM+0xd28), "(want 90000, currently 0)")
EOF
```
> Note `CM=0x43c47ff0` is this process instance's address. After a relaunch, re-locate it by scanning
> rw regions for the 8-byte vptr `0x1438a0850` (exactly one hit), or for the string
> `Blaze 15.1.1.3.0 (OpenFUT)` and subtracting `0x1288`.
**Observable success signal (the one that matters):** on the go-online connection, after our
`Util::preAuth` reply the client emits **`Util::ping` (component `0x0009`, cmd `0x0002`)** instead of
`closed`, and proceeds to **`Authentication::login` (`0x0001`/`0x000a`)** on that same socket. A
secondary signal: the boot connection stops dying ~1 s after the login burst and starts emitting a
ping every ~20 s.
### FIX 2 — cheap, low risk, not the cause: close the CIDS gap
`blaze_responder_v3b.py:521-525`. We advertise `CIDS = (1, 4, 5, 7, 9, 15, 25, 28, 30722)`, but the
session-1 log shows the client talking to three components we never listed:
| Component | Seen in log | Frame |
|---|---|---|
| `0x000a` (10) | `RX #3999 cmd:0x0005`, `RX #4008 cmd:0x0002` | UserSessions-adjacent |
| `0x000b` (11) | `RX #4001 cmd:0x0a28` | — |
| `0x08c9` (2249) | `RX #4005 cmd:0x0001` | — |
Add `10, 11, 0x08c9` to `COMPONENT_IDS`. Not the cause (the client sent to them regardless and we
replied), but it is free and it removes a known divergence from a real server. Do this **after**
FIX 1 lands and only if FIX 1 alone does not clear the gate, so the two changes stay separable.
### FIX 3 — DO NOT DO YET. Populating `LTPS` (and serving :17502)
**Explicitly deferred, and it is the one item that would need a new listener and thread.**
Reasons to leave `qos_config()` (`blaze_responder_v3b.py:527-538`) exactly as it is:
1. The empty-LTPS path is a *first-class, benign* branch (`0x146e1daf8: xor al,al`) and live memory
proves it completed with QoS enabled (`Q+0x10=1`, `hub+0x53f=1`, `CM+0x1f60=1`). It blocks nothing.
2. The QoS manager is initialized at `0x146e1d214`, **after** the `sendPing` at `0x146e1d1cb`, inside
a callback that session 2 provably never entered (§1.5). It is downstream of the failure.
3. Populating `LTPS` **creates** work: the per-site loop `0x146e1c4bb..0x146e1c4f0` would then run and
fire real HTTPS `GET https://127.0.0.1:17502/qos/qos` requests. Serving those needs a **new TLS
listener + thread** in the responder, with a cert the client accepts. We would be trading a
non-problem for a real one.
4. The Heat2 framing for `map<string, STRUCT>` is flagged **UNVERIFIED** in `heat2.py:204` (and the
`STRUCT` value path in `_enc_value` likewise). A malformed TDF here would fail the client's decode
of the *whole* PreAuthResponse — a strictly worse failure than the current one.
Only revisit if, **after FIX 1**, the game reaches login but then stalls on a ping-site-dependent
feature. In that case the anchors are already mapped: `QosConfigInfo` copied to `QosManager+0x1b8`;
`+0x1c8`=LNP, `+0x1d0`=BWPS (`QosPingSiteInfo` vtable `0x143889ba0`), `+0x200`=LTPS map (vtable
`0x143889c48`, begin `+0x218` / end `+0x220`, **element stride 0x20**, int32 latency at `element+0x18`
— stride and latency offset independently confirmed by the OSDK helper `0x1471c0c90`, which loops
`add rax,0x20` writing `mov DWORD PTR [rax+0x18],0x3e8`). The minimal payload would be:
```python
("LTPS", (MAP, (STRING, STRUCT, [
("eu-west", OrderedDict([("PSA", (STRING, "127.0.0.1")), ("PSP", (INT, 17502))])),
]))),
("LNP", (INT, 1)), # drop from 10 -- one probe round, not ten
```
and it must ship **together with** an HTTPS listener on `127.0.0.1:17502` answering `/qos/qos`.
The OSDK side degrades gracefully in the meantime: `GetPingSiteAliasList` impl `0x1472d1780`
`0x14728c4c0` explicitly returns an empty script array when the container is NULL or its count
(`[container+0x28]`) is 0, so an empty ping-site list yields `""` / `[]` rather than an error.
### FIX 4 — instrumentation to buy, cheaply, what the next iteration will need
Independent of the above, and worth landing with FIX 1:
- **Sub-second timestamps** in the responder log. Current second-resolution timestamps cannot resolve
the RPC round trip, which is the whole question for a timeout bug. (Measured for this document: the
pure-CPU cost of `heat2.dump` + `hexdump` on the 772-byte reply is only **0.08 ms/iter**, and
`raw.sendall(out)` at line 1252 already runs *before* the TX logging at 1254-1260 — so responder
latency is almost certainly not a contributing factor. Timestamps would confirm that outright.)
- **Log the redirector request body.** `blaze_responder_v3b.py:1338` logs only the request line and
`build_redirect_response()` (~:1274) ignores the request entirely, so the responder cannot actually
substantiate "session 2 asked for the SAME service" — that was established from the byte-identical
preAuth requests, not from the redirector.
- **Log FIN vs RST and bytes-read-before-close** on the Blaze socket. The recv-loop EOF at
`blaze_responder_v3b.py:1195-1270` currently reports a bare `closed` for every case.
- **Timestamp `/tmp/lsx.log`.** The four `GetAuthCode` issuances (ids 25-28) cannot currently be
aligned with the Blaze log.
---
## 3. What still needs a live experiment
Static RE has taken this as far as it can go. Everything below needs the game.
### 3.1 The decisive one — apply FIX 1, then re-enter online (no relaunch strictly required, but cleaner)
1. Edit the three CONF lines, restart **the responder only**.
2. Relaunch FIFA17 (cleanest: guarantees a fresh ConnectionManager with `CM+0x278 = 10000` from the
ctor and `CM+0xb3c = 0`).
3. Immediately after the boot login, read live `CM+0xd1c` / `CM+0x278` / `CM+0xd28`. **Gate:** they
must read `20000 / 30000 / 90000`. If they do not, the fix did not apply and nothing else matters.
4. Then have the user go online. **Gate:** the log must show `Util::ping` after our preAuth reply on
that connection, then `Authentication::login`.
Two clean outcomes, both informative:
- **Connection survives → FIX 1 was the whole bug.** Proceed to whatever the next gate is (likely
the constant `AuthCode` `OPENFUT-0000…` we return on every LSX `GetAuthCode`).
- **Connection still dies → the zero timeouts were real but not sufficient.** Go to §3.2.
### 3.2 If it still dies — read the error code the SDK hands the callback
This is the single highest-value remaining measurement and it is small. Breakpoint (or
single-step-and-read) at `0x146e1cf10` (`onPreAuthResponse` entry) and capture **`r8d`**, or at the
branch `0x146e1cf33` (`test r8d,r8d` / `je 0x146e1cf82`). That one integer separates:
- **callback entered with a non-zero errorCode** → the SDK generated the failure itself (timeout,
transport) — chase the failure dispatcher `0x146e18170` (callers `0x146e1990c`, `0x146e1c1c2`,
`0x146e1cf77`, `0x146e1d2de`) and the connect-failure handler `0x146e1cc70`;
- **callback never entered at all** → the socket was torn down before dispatch, i.e. exactly the
timeout-expiry story, and the remaining question is *who* called `disconnect`.
Pair it with a live read of `LoginStateLogin` (`0x43d189d8`, vtable `0x14395c180`) **at the moment of
failure**: if `+0x80` becomes `0x14395c6c8` the state-machine selector fired and `+0x260` names the
outstanding async op (states `{3,4,6,10,12,13,14,15,23,25}` map to `OSDK_LOST_CON_TO_EA`); if `+0x80`
stays `0x143951fb0` with `+0x7c == 0`, it was the transport-disconnect popup at `0x1471aa220`.
### 3.3 Open items that only dynamic work can close
| # | Question | Why it is open | How to close it |
|---|---|---|---|
| 1 | **Who reads `CM+0xd28` and `CM+0x278`?** A disp32 scan for `0x00000d28` / `0x00000278` across `0x144ed3000-0x14a000000` finds **only the write sites** (`0x146e1d163`, `0x146e1d12b`). | Readers are inside VM-mutated functions. So "0 ⇒ close immediately" is inferred from behaviour (all three connections die at first idle), **not** proven by disassembly. | FIX 1 is itself the experiment. If the connections stop dying, the inference was right. |
| 2 | **What writes `CM+0xb3c`** (the preAuth-skip flag)? Byte-pattern scans for `c6 8x 3c 0b 00 00` over `0x144ed3000-0x149000000` find no writer; only the read at `0x146e1cbed`. | Set from a virtualized function. | Watchpoint on `CM+0xb3c`. Knowing it would let us distinguish reconnect from fresh connect — and possibly force *every* connection onto the working preAuth-skipping path. |
| 3 | **Two functions on the QoS completion path are VM stubs**: `0x146e19b30` (`push rcx; lea rcx,[0x148775779]; jmp 0x14e02d517`) and the QoS-retrieved callback `0x146e1c5e0` (`push rcx; lea rcx,[0x146fdea3d]; jmp 0x149875d6c`). Same for the writer of `CM+0x1f60` (only the read at `0x146e1c3c2` exists). | Denuvo/VMProtect-style. Not statically decompilable. | Session 1 provably ran both to completion, so they are not fatal. Only worth tracing if FIX 3 ever becomes necessary. |
| 4 | **Does the client actually probe `:17502` once LTPS is populated?** | Never tested — LTPS has always been empty; nothing has ever opened that port (`/proc/net/*` clean). | `tcpdump -i lo port 17502` during the first attempt with a populated LTPS. Only relevant under FIX 3. |
| 5 | **What is disconnect `reason == 2`?** The dispatcher `0x14718dda0` tail-jumps to `0x1471aa220` passing its own arg; reasons 3/4/5 additionally emit a `NETW`/`LoginError` telemetry event (string `0x14395c840`). The producer of the enum was not walked. | Static chase not completed. | Backtrace from `0x14718dda0` at the moment of failure. Almost certainly "connection to Blaze lost". |
| 6 | **Blaze error codes `0x000B0001` and `0x000C0001`** are the only two special-cased in `OnBlazeError` `0x1471d53e0` (`cmp edx,0xb0001; je` / `cmp edx,0xc0001; je`) — i.e. the only two failures FIFA treats as recoverable. Not decoded. | Would tell us which failures we can safely provoke. Component `0x000b` is live in our traffic (`RX #4001 cmd:0x0a28`). | Decode against the component table; low priority. |
| 7 | **The constant `AuthCode`.** `/tmp/lsx.log` shows four `GetAuthCode` issuances (ids 25-28) and we return the same `OPENFUT-0000…` every time. | Untested — we have never survived past preAuth on the online connection. | This is the most likely **next** gate once FIX 1 lands. Have it in mind, do not pre-emptively change it. |
---
## 4. One-line summary for the commit message
```
blaze: send CONF durations as unit-suffixed strings ("30s"), not bare microseconds
FIFA17's TimeValue getter (ConnMgr vt+0x58, 0x146e1bda0) discards the parse
result from 0x1479b2d50, which rejects unit-less integers. pingPeriod,
defaultRequestTimeout and connIdleTimeout were all landing as 0 (live:
CM+0xd1c=15000 via clamp, CM+0x278=0, CM+0xd28=0), zeroing the request/idle
timeouts for the whole ConnectionManager lifetime and tearing down every
connection at its first idle moment -- including the go-online preAuth, whose
response callback never ran (proved by CM+0x11a4 still holding session 1's
16:38:06 tick). Not a QoS/ping-site issue: LTPS-empty is a benign first-class
branch and nothing ever contacted :17502.
```
@@ -0,0 +1,417 @@
# 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 100120 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 2529, 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 15 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.
+588
View File
@@ -0,0 +1,588 @@
#!/usr/bin/env python3
"""
OpenFUT clean-room LSX responder for FIFA 17 -- v2 (EVENT-PUSHING).
v2 vs v1 (lsx_responder.py): v1 was REQUEST-DRIVEN ONLY. It answered every verb
the client asked for and never sent an unsolicited frame. That is exactly why
the client never issued GetAuthCode and never sent Blaze Authentication::login.
THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS:
(1) "internet is reachable" -> OriginMgr online byte [0x1448a3ac0]
fed by the REQUEST verb GetInternetConnectedState -> connected="1"
(v1 already beat this; live-confirmed == 1)
(2) "a user is LOGGED IN" -> OriginMgr.m_isLoggedIn [OriginMgr+0x13]
fed ONLY by a server-PUSHED <Event sender="LOGIN_EVENT"><Login/>
There is NO request verb that can set it.
v1 fed (1) and never fed (2), so m_isLoggedIn was 0 for the whole session,
FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever
(both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately,
OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH
could never be filled -> no Blaze login -> "Unable to retrieve account
information."
BINARY EVIDENCE (all re-verified byte-for-byte from our own live dumps, not
from any leak; see the PROVENANCE block at the bottom of this docstring):
* Origin event dispatcher @0x146f1e060, case edx==2 (OriginEventT::Login) is
the ONLY case in the whole dispatcher that mutates state:
146f1e09e: 41 83 39 01 cmp DWORD PTR [r9],0x1 ; IsLoggedIn==1
146f1e0ab: c6 41 13 01 mov BYTE PTR [rcx+0x13],1 ; m_isLoggedIn=TRUE
146f1e0af: c7 41 14 00.. mov DWORD PTR [rcx+0x14],0 ; clear login error
146f1e0b8: c6 41 13 00 mov BYTE PTR [rcx+0x13],0 ; else FALSE
* <Login> element matcher @0x147102880:
- reads attribute "sender" (literal @0x143938028) off the <Event> node
via vtbl+0x70; `test rax,rax; je fail` -> sender MUST be present
- inline strcmp of that value against the handler's registered sender
-> a mismatched sender is SILENTLY DROPPED
- then requires the child element name == "Login" (@0x14393d0ac)
* Handler sender strings come from the service-name tables. NOTE there are
TWO parallel structures, so do not "fix" one stride into the other:
- the const char* INIT table @0x144341420 is STRIDE 8;
- the runtime std::string array the SDK actually indexes (sdk+0x3b0, via
GetServiceName @0x1470e4870 with `shl rax,0x5`) is STRIDE 0x20, max
index 0x21.
Both resolve index 14 == LOGIN_EVENT, so the conclusion is the same.
Verified contents of the index space:
idx 0 SDK 1 PROFILE 2 PRESENCE 3 FRIENDS 4 COMMERCE
idx 5 RECENTPLAYER 6 IGO 7 MISC 8 LOGIN
idx 9 UTILITY 10 XMPP 11 CHAT 12 IGO_EVENT
idx13 EALS_EVENTS 14 LOGIN_EVENT 15 INVITE_EVENT
idx16 PROFILE_EVENT ... 27 ONLINE_STATUS_EVENT
"LOGIN_EVENT" (@0x14394c790) is referenced from EXACTLY ONE place in the
whole image: table slot 0x144341490 == index 14. Likewise
"ONLINE_STATUS_EVENT" (@0x14394c868) only from 0x1443414f8 == index 27.
* <Login> attribute parser @0x147138660: opens namespace "lsx", reads attribute
"IsLoggedIn" (@0x14394e0f0), then @0x14713ffa0 does
strcmp(value,"false"); setne al; mov BYTE PTR [rdi],al
-> ANY value except the literal string "false" means TRUE.
* <OnlineStatusEvent> parser @0x147139e00 reads attribute "isOnline"
(@0x14394e180, lower-case i) in the same shape.
* Symbols proving the handler templates are instantiated in this build:
Origin::EventHandler<struct lsx::LoginT,unsigned int>::HandleMessage
Origin::EventHandler<struct lsx::OnlineStatusEventT,bool>::HandleMessage
(payload type `unsigned int` matches `cmp DWORD PTR [r9],1` above.)
* Structural proof that unsolicited <Event> frames are consumable: the LSX
handshake itself is one -- <LSX><Event sender="EALS"><Challenge/></Event>.
WHAT WE DELIBERATELY DID NOT CHANGE
* The crypto (challenge / session-key derivation / AES-ECB+PKCS7+hex+NUL) is
byte-verified against a captured real session; it is copied verbatim.
* Every verb v1 answered is answered identically. Nothing was removed.
WIRE PROTOCOL (unchanged, reversed from stp-origin_emu.dll @ 0x6ffffc930000):
transport : TCP 127.0.0.1:4216, each message NUL-terminated (send strlen+1).
handshake : server sends <Challenge key="..."> IN PLAINTEXT;
client replies plaintext with response=/key=;
server replies <ChallengeAccepted response="H"> where
H = hex(AES128_ECB(K_FIXED, PKCS7pad16(clientKeyAscii)))
K_FIXED = 000102...0f (emu .rdata 0x935038)
session : every later frame (BOTH directions, Responses AND Events) is
hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"
SESSION_KEY derived from H via the MSVCR srand/rand LCG.
USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's bind() fails.
(This file does NOT auto-start anything; the main session owns processes.)
PROVENANCE / CLEAN ROOM: every constant and algorithm here was recovered by our
own static+dynamic analysis of binaries we own (FIFA17.exe unpacked in our own
process, stp-origin_emu.dll as loaded) plus traffic we ourselves captured.
Nothing is derived from the 2021 EA/FIFA leak.
"""
import os
import re
import socket
import sys
import threading
import time
from Crypto.Cipher import AES
# ---------------------------------------------------------------- identity
# SHARED CONSTANTS -- must stay byte-identical to 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"
LOCALE = "en_US"
AUTHCODE_FILE = "/tmp/openfut_authcode.txt"
CLIENTID_FILE = "/tmp/openfut_lsx_clientid.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"
# ------------------------------------------------------------- event tuning
# Pushes are idempotent state notifications, so re-sending is harmless and is
# cheap insurance against FIFA registering its <Login> handler later than our
# first push. Set OPENFUT_LSX_EVENTS=0 to fall back to v1 behaviour (useful as
# an A/B control if you want to prove the events are what moved the needle).
EVENTS_ENABLED = os.environ.get("OPENFUT_LSX_EVENTS", "1") != "0"
EVENT_HEARTBEAT_SECS = float(os.environ.get("OPENFUT_LSX_EVENT_PERIOD", "5"))
EVENT_HEARTBEAT_COUNT = int(os.environ.get("OPENFUT_LSX_EVENT_COUNT", "24"))
# EXPERIMENT: push the Login Event in PLAINTEXT right after ChallengeAccepted
# (before the stream goes encrypted) instead of via the encrypted heartbeat.
# Tests the workflow's strongest remaining hypothesis -- that FIFA drops
# encrypted mid-session Events (the emu's only Event, the Challenge, is plaintext
# and pre-key). See serve() step 3b and REPACK_INTEL.md sec.4 step 2.
LOGIN_PLAINTEXT = os.environ.get("OPENFUT_LSX_LOGIN_PLAINTEXT", "0") != "0"
# A/B-control integrity: v1 (lsx_responder.py) answered GetGameInfo
# FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
# silently changed it to "true", which meant OPENFUT_LSX_EVENTS=0 was NOT a
# byte-identical control any more. Keep it OFF by default so events-off ==
# v1 exactly; flip OPENFUT_LSX_FULLGAME=1 to run the FULLGAME="true" experiment
# on its own.
FULLGAME_PURCHASED_TRUE = os.environ.get("OPENFUT_LSX_FULLGAME", "0") != "0"
def log(*a):
print("[lsx]", *a, flush=True)
# ---------------------------------------------------------------- crypto
# (verbatim from v1 -- verified end-to-end by decrypting captured
# captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.)
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))
# AES128-ECB(K_FIXED, 0x10*16) -- the constant the emu appends as the 3rd hex
# block (== PKCS7 pad block of an aligned 32-byte key). See REPACK_INTEL.md sec.0-B.
_TAIL_CONST = AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex()
def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str:
"""Emu-exact ChallengeAccepted.response (stp-origin_emu.dll 0x180001f10).
The emu computes only TWO AES blocks from the 32-ASCII client key, then
strcat_s's the client's OWN response[64:] verbatim (@0x1800020a9) -> 96 hex.
Our older 3-block PKCS7 form is numerically identical *while the client
PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed
byte-exact). We now reproduce the emu exactly and, when the client's
response= is available, echo its tail and assert the constant so a future
client that randomises block 3 fails LOUDLY instead of silently."""
two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex()
if len(client_response_attr) >= 64:
tail = client_response_attr[64:]
assert tail == _TAIL_CONST, f"unexpected ChallengeResponse tail {tail!r}"
return two + tail
return two + _TAIL_CONST
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")
# ------------------------------------------------------- PUSHED EVENTS (NEW)
#
# Frame shape is identical to the server-initiated <Challenge> that already
# works, i.e. <LSX><Event sender="..."><Element .../></Event></LSX>
# No id attribute (the Challenge has none; the matcher never reads one).
#
# `sender` is strcmp'd against the handler's registered service name. A
# mismatch is silently dropped -- costs us nothing -- so for the Login element
# we emit BOTH candidate senders: "LOGIN_EVENT" (table index 14, the one the
# event-handler factory uses) and "LOGIN" (table index 8, the plain service
# name). Exactly one of them will match; the other is a no-op.
# Event handlers are keyed on serviceNames[facility] too (registrar 0x14710df80);
# with our empty GetConfigResponse those names are "", so the handlers expect
# sender="". "" first; the named variants are harmless no-ops (dropped silently)
# and become correct once GetConfigResponse populates the table (RANK 2).
LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN")
ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")
def event(sender: str, element: str) -> str:
return f'<LSX><Event sender="{sender}"><{element}/></Event></LSX>'
def login_event_frames() -> list:
"""The frames that flip OriginMgr.m_isLoggedIn ([OriginMgr+0x13]) to 1.
IsLoggedIn is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE.
Keep the value literally "true" anyway: it is what a real Origin client
sends and it keeps the log readable."""
out = [event(s, 'Login IsLoggedIn="true"') for s in LOGIN_EVENT_SENDERS]
out += [event(s, 'OnlineStatusEvent isOnline="true"')
for s in ONLINE_EVENT_SENDERS]
return out
class Conn:
"""Socket + session key + a send lock.
The lock matters: pushes come from a heartbeat thread while the request
loop may be writing a Response. LSX frames are NUL-delimited, so two
interleaved sendall()s would corrupt the stream and the client would drop
the connection (which would look exactly like a protocol bug)."""
def __init__(self, sock, addr):
self.sock = sock
self.addr = addr
self.key = None
self.lock = threading.Lock()
self.alive = True
self.pushed_login = False
# Set True once GetAuthCode has been issued, so the heartbeat stops
# re-pushing Login/OnlineStatus events. Re-pushing after the auth code
# is granted re-enters FIFA's state-mutating Origin event dispatcher
# (case 2 @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts
# on the FE bus) ~24 more times DURING Blaze login, which we do not want.
self.stop_events = False
def send_plain(self, xml: str):
with self.lock:
self.sock.sendall(xml.encode() + b"\0")
def send_enc(self, xml: str):
with self.lock:
self.sock.sendall(lsx_encrypt(xml, self.key))
def push_login_state(self, why: str):
if not EVENTS_ENABLED:
return
for frame in login_event_frames():
try:
self.send_enc(frame)
except Exception as e:
self.alive = False
log("push failed:", e)
return
log(f"PUSH ({why}) >> {frame}")
if not self.pushed_login:
self.pushed_login = True
log("*** first <Login IsLoggedIn=\"true\"> pushed. Watch for "
"GetAuthCode next. ***")
def heartbeat(self):
"""Re-push the login state a bounded number of times.
FIFA builds its Origin event handlers lazily; if our first push lands
before the <Login> handler is registered the matcher simply finds no
handler and drops it. Re-pushing removes that race without needing to
guess the exact registration moment."""
for _ in range(EVENT_HEARTBEAT_COUNT):
time.sleep(EVENT_HEARTBEAT_SECS)
if not self.alive or self.stop_events:
return
self.push_login_state("heartbeat")
# ---------------------------------------------------------------- responses
def resp(mid, body, sender=""):
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
def build_reply(mid, req_name, attrs, conn, recipient=""):
"""Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script).
CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
0x1471189b0 rejects any <Response> whose `sender` attribute does not
byte-equal the `recipient` the client put on the matching <Request> (it
reads serviceNames[facility]; with our empty GetConfigResponse all 34 names
are "" so recipient="" for every verb after GetConfig, which itself uses the
hard-coded literal "EbisuSDK"). We were answering GetProfile/GetAuthCode/
QueryEntitlements with sender="EbisuSDK" -> silently discarded -> GetProfile
(the SOLE writer of OriginSDK+0x3a0 default-user) never took -> the whole
online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO the request's
recipient back as the response sender. This local `resp` shadows the module
one and makes `sender` default to `recipient`."""
def resp(mid, body, sender=None):
s = recipient if sender is None else sender
return f'<LSX><Response id="{mid}" sender="{s}"><{body}/></Response></LSX>'
if req_name == "GetInternetConnectedState":
# FLAG (1): "internet is reachable". Stub hardcoded connected="0"
# -> "log in to Origin". This is NOT the logged-in flag; see the
# module docstring.
return resp(mid, 'InternetConnectedState connected="1"')
if req_name == "GetAuthCode":
# Request shape is built at 0x14713b8d0:
# <GetAuthCode ClientId="..." Scope="..."/>
# Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
#
# THE ATTRIBUTE NAME IS "value" -- verified, not guessed:
# the "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
# -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
# attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
# @0x1436c7768, concat at 0x14712d130) and does exactly ONE
# get-attribute-as-string call 0x14713fe50(node, "value", &dest).
# dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands at
# +0xc8 -- which is what OriginRequestAuthCodeSync's impl 0x1470e67f0
# reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as *out_len.
# Code=/Return= are NEVER read; with them alone the parsed string is
# empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the OSDK classifier
# 0x14717d5d0 falls into its `test rbp,rbp / je` arm and reports
# OSDK_UNDERAGE_ERROR (a mislabelled "no auth code" fallback).
# Code=/Return= are kept only as harmless padding.
client_id = attrs.get("ClientId", "")
scope = attrs.get("Scope", "")
code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
# Only touch the run's success-signal files on a REAL request (conn is a
# live socket). --selftest calls build_reply(..., conn=None); if it
# wrote these files it would pre-satisfy watch-step "authcode.txt becomes
# non-empty" and make a non-event read as success on the next live run.
if conn is not None:
for path, val in ((AUTHCODE_FILE, code), (CLIENTID_FILE, client_id)):
try:
with open(path, "w") as fh:
fh.write(val)
except Exception:
pass
# GetAuthCode has fired: stop the heartbeat so we do not keep
# re-pushing Login/OnlineStatus events during Blaze login.
conn.stop_events = True
log("*** GetAuthCode ISSUED ***")
log(f" ClientId={client_id!r} Scope={scope!r}")
log(f" code={code} -- this must arrive as Blaze "
f"LoginRequest.AUTH in Authentication::login (1/0x0A)")
return resp(mid,
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
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="{recipient}">'
f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
f'</Response></LSX>')
if req_name == "GetProfile":
# This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
# (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
# @0x1470da680 are bare reads of those fields, written only by
# OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
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"')
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":
# MUST be true or the client shows "Your title version is
# outdated" and blocks all online features.
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
if gi == "FULLGAME_PURCHASED" and FULLGAME_PURCHASED_TRUE:
# OFF by default: v1 answered "false" here (fell through to default).
# Keeping this gated makes OPENFUT_LSX_EVENTS=0 byte-identical to v1.
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
# FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a trial;
# matches v1 exactly)
return resp(mid, 'GetGameInfoResponse GameInfo="false"')
if req_name == "GetSetting":
sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE
if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
return resp(mid, 'GetSettingResponse Setting="production"')
if sid == "LANGUAGE":
return resp(mid, f'GetSettingResponse Setting="{LOCALE}"')
return resp(mid, 'GetSettingResponse Setting="false"')
if req_name == "GetConfig":
return resp(mid, 'GetConfigResponse Config="false"')
if req_name == "IsProgressiveInstallationAvailable":
return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
'Available="false"')
return resp(mid, 'ErrorSuccess Code="0" Description=""')
# Trigger points: push right after answering these verbs. GetProfile is the
# earliest safe moment -- by then the SDK has built its handler set and has a
# default user, so a Login event has somewhere to land.
PUSH_AFTER = {
"GetProfile": "after GetProfile",
"GetInternetConnectedState": "after GetInternetConnectedState",
"GetGameInfo": "after GetGameInfo UPTODATE",
}
REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
# The response `sender` must byte-equal the request's `recipient` (matcher
# 0x1471189b0). Captured separately (default "") so a frame that ever lacks
# `recipient` still gets answered fast instead of a 15s stall.
RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"')
def serve(sock, addr):
conn = Conn(sock, addr)
hb = None
try:
# 1. plaintext Challenge
conn.send_plain(f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
# 2. plaintext ChallengeResponse from client. The emu parses response="
# BEFORE key=" (0x180001f10); extract both so challenge_response can
# echo the client's own 3rd block (REPACK_INTEL.md C1/C2).
data = sock.recv(4096)
txt = data.decode(errors="replace")
mk = re.search(r'key="([^"]*)"', txt)
mr = re.search(r'response="([^"]*)"', txt)
client_key = mk.group(1) if mk else CHALLENGE_KEY
client_resp = mr.group(1) if mr else ""
h = challenge_response(client_key, client_resp)
conn.key = derive_session_key(h)
log(f"client key={client_key} response={h[:16]}... "
f"session_key={conn.key.hex()}")
# 3. plaintext ChallengeAccepted
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
# 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
# unsolicited Event is the plaintext, pre-session-key Challenge; there
# is zero evidence an *encrypted mid-session* Event routes to the same
# parser (REPACK_INTEL.md sec.4 step 2). So push the Login Event here,
# in PLAINTEXT, right after ChallengeAccepted -- before the stream goes
# encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
if LOGIN_PLAINTEXT and EVENTS_ENABLED:
conn.stop_events = True
for frame in login_event_frames():
conn.send_plain(frame)
log(f"PUSH (plaintext post-accept) >> {frame}")
# 4. encrypted request/response loop
buf = b""
while True:
data = sock.recv(65536)
if not data:
break
# Buffer partial frames: a 64 KiB recv can straddle a NUL boundary,
# and split() would silently drop the trailing partial (C3).
buf += data
*frames, buf = buf.split(b"\0")
for chunk in filter(None, frames):
try:
xml = lsx_decrypt(chunk + b"\0", conn.key)
except Exception as e:
log("decrypt fail:", e)
continue
mm = REQ_RE.search(xml)
if not mm:
log("<<", xml)
continue
mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
attrs = dict(ATTR_RE.findall(rest))
rm = RECIP_RE.search(xml)
recip = rm.group(1) if rm else ""
reply = build_reply(mid, name, attrs, conn, recip)
log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
log(f">> {reply}")
conn.send_enc(reply)
why = PUSH_AFTER.get(name)
if why and EVENTS_ENABLED:
# For GetGameInfo only fire on UPTODATE, otherwise we would
# push three times per boot for FREETRIAL/LANGUAGES too.
if name != "GetGameInfo" or attrs.get("GameInfoId") == "UPTODATE":
conn.push_login_state(why)
if hb is None:
hb = threading.Thread(target=conn.heartbeat,
daemon=True)
hb.start()
except Exception as e:
log("connection error:", e)
finally:
conn.alive = False
try:
sock.close()
except Exception:
pass
log("connection closed", addr)
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("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)")
log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}"
f" (period={EVENT_HEARTBEAT_SECS}s count={EVENT_HEARTBEAT_COUNT})")
while True:
c, a = s.accept()
log("connection from", a)
threading.Thread(target=serve, args=(c, a), daemon=True).start()
# ---------------------------------------------------------------- self-test
def selftest():
"""No live game needed. Proves the crypto is untouched and the new event
frames encrypt/decrypt cleanly through our own codec."""
h = challenge_response("18a70055a3541fb27ab8e0f47afad18c")
assert h.startswith("e4f5166209929e15"), h
k = derive_session_key(h)
assert k.hex() == "6a9da3e78615153cc2f10eec25ae6382", k.hex()
print("[ok] crypto matches the captured 2026-07-30 session verbatim")
frames = login_event_frames()
assert len(frames) == len(LOGIN_EVENT_SENDERS) + len(ONLINE_EVENT_SENDERS)
for f in frames:
assert lsx_decrypt(lsx_encrypt(f, k), k) == f
print("[ok] round-trip:", f)
# "" sender first (correct for the current empty service-name table)
assert '<Event sender=""><Login IsLoggedIn="true"/></Event>' in frames[0]
r = build_reply(42, "GetAuthCode", {"ClientId": "X", "Scope": "Y"}, None)
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
# actually reads; Code=/Return= are legacy padding.
assert '<AuthCode value=' in r, r
print("[ok] GetAuthCode ->", r)
print("[ok] selftest passed")
if __name__ == "__main__":
if "--selftest" in sys.argv:
selftest()
else:
main()
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# ============================================================================
# OpenFUT — FIFA 17 offline FUT backend orchestrator
#
# ./openfut-fut.sh start bring up the whole harness (arm host + all servers)
# ./openfut-fut.sh stop shut the servers down
# ./openfut-fut.sh restart stop + start
# ./openfut-fut.sh status show what's up
#
# After `start`, launch FIFA 17 and select Ultimate Team. See RUNBOOK below (status).
# Volatile host state (sysctls/iptables/cert) does NOT survive a reboot; `start`
# re-arms everything, so just re-run it after booting. /etc/hosts persists.
# ============================================================================
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")" # tools/
CERT=redir_cert.pem; KEY=redir_key.pem
# name script "port[,port...]" extra-env
SERVERS=(
"lsx lsx_responder_v2.py 4216 OPENFUT_LSX_EVENT_COUNT=100000"
"blaze blaze_responder_v3b.py 42127,42130,42131 -"
"roster roster_server.py 8081 -"
"utas utas_server.py 8099 -"
"autopatch autopatch.py - -"
)
c() { printf ' %s\n' "$*"; }
up() { ss -tlnp 2>/dev/null | grep -q ":$1 "; }
ensure_cert() {
[ -s "$CERT" ] && [ -s "$KEY" ] && return 0
echo "[*] generating self-signed TLS cert (redirector MITM; ProtoSSL verify is patched)"
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
-subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1" \
>/dev/null 2>&1
}
armed() {
[ "$(cat /proc/sys/kernel/yama/ptrace_scope 2>/dev/null)" = 0 ] \
&& [ "$(cat /proc/sys/net/ipv4/conf/lo/route_localnet 2>/dev/null)" = 1 ] \
&& grep -q '[[:space:]]easw\.easports\.com\b' /etc/hosts 2>/dev/null
}
ensure_armed() {
if armed; then c "host already armed (ptrace_scope=0, route_localnet=1, /etc/hosts ok)"; return 0; fi
echo "[*] arming host state (needs root — a password dialog will appear)"
pkexec sh "$PWD/root_arm.sh" || { echo "!! root_arm failed. Run manually: pkexec sh $PWD/root_arm.sh"; return 1; }
}
start() {
ensure_cert
ensure_armed || exit 1
echo "[*] starting servers (detached)…"
for s in "${SERVERS[@]}"; do
read -r name script ports env <<<"$s"
pkill -9 -f "$script" 2>/dev/null; :
done
sleep 1
for s in "${SERVERS[@]}"; do
read -r name script ports env <<<"$s"
local envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
: > "/tmp/${name}.log" 2>/dev/null || true
setsid bash -c "exec $envprefix python3 -u $script" </dev/null >"/tmp/${name}.log" 2>&1 &
disown
done
sleep 2
status
}
stop() {
echo "[*] stopping servers…"
for s in "${SERVERS[@]}"; do read -r name script _ <<<"$s"; pkill -9 -f "$script" 2>/dev/null; :; done
sleep 1; c "servers stopped (host arm + /etc/hosts left intact)"
}
status() {
echo "== OpenFUT FIFA17 FUT backend =="
armed && c "host: ARMED ✓" || c "host: NOT armed (run: pkexec sh $PWD/root_arm.sh)"
for s in "${SERVERS[@]}"; do
read -r name script ports env <<<"$s"
if pgrep -f "$script" >/dev/null; then
if [ "$ports" = "-" ]; then c "$name ✓ (running)"
else
local ok=1; IFS=',' read -ra ps <<<"$ports"
for p in "${ps[@]}"; do up "$p" || ok=0; done
[ $ok = 1 ] && c "$name ✓ ($ports)" || c "$name ⚠ running but a port is down ($ports)"
fi
else c "$name ✗ DOWN"; fi
done
echo "-- RUNBOOK --"
c "1. (this must be done BEFORE launching FIFA — servers bind the ports the game needs)"
c "2. Launch FIFA 17 fresh: ~/Desktop/launch-fifa17.sh (a FRESH launch avoids the live-DB error)"
c "3. In-game: select Ultimate Team. At the 'security question', type ANY answer -> Continue -> OK."
c "4. -> the FUT hub. Logs: /tmp/{lsx,blaze,roster,utas,autopatch}.log"
}
case "${1:-status}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*) echo "usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# OpenFUT FIFA 17 — arm the privileged host state (run via pkexec/sudo).
# Idempotent: safe to re-run. Volatile bits (sysctls, iptables) do NOT survive a
# reboot -> re-run after boot. /etc/hosts DOES persist.
set -e
# 1) allow /proc/PID/mem writes (autopatch's ProtoSSL cert-verify patch)
sysctl -q kernel.yama.ptrace_scope=0
# 2) allow routing DNAT'd traffic to loopback
sysctl -q net.ipv4.conf.lo.route_localnet=1
# 3) redirect FIFA17's Blaze dial (winter15 -> 159.153.51.20) to our TLS server :42127
iptables -t nat -C OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0.0.1:42127 2>/dev/null \
|| iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0.0.1:42127
# 4) point FUT's dead hardcoded UTAS host (easw.easports.com:8099) at our utas_server
grep -q '[[:space:]]easw\.easports\.com\b' /etc/hosts 2>/dev/null \
|| printf '127.0.0.1\teasw.easports.com\n' >> /etc/hosts
echo "--- armed ---"
sysctl kernel.yama.ptrace_scope net.ipv4.conf.lo.route_localnet
iptables -t nat -L OUTPUT -n | grep -i '159.153.51.20' || echo " (DNAT missing!)"
grep 'easw.easports.com' /etc/hosts && echo " /etc/hosts ok" || echo " (/etc/hosts easw missing!)"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""FUT roster-update HTTPS server for FIFA 17 (OpenFUT).
The FUT loading flow (checkFUTRostersFlow / state CheckFUTRosterUpdateXML) downloads
a roster-update XML from ROSTERUPDATE_URL (which blaze_responder_v3b.py now serves as
https://127.0.0.1:8081/fifa17/fut/rosterupdate.xml). On download SUCCESS the flow
raises `advance` -> CheckFUTSquadBinFile -> (no squad) -> EnterFUT -> CardsDLL loads.
On FAIL it raises `back` -> abort. So this must return something FIFA ACCEPTS.
We don't have (or need) EA's real roster: the base player DB is baked into CardsDLL;
the roster-update is an optional delta. Start by serving a minimal "no update" body,
LOG every request (path/headers) so we learn exactly what FIFA fetches, and iterate.
HTTPS because EA's value is https and the DirtySDK download mgr may reject http; FIFA's
ProtoSSL cert-verify is patched (autopatch), so our self-signed cert is accepted.
"""
import http.server, ssl, os, sys, datetime
HERE = os.path.dirname(os.path.abspath(__file__))
CERT = os.path.join(HERE, "redir_cert.pem")
KEY = os.path.join(HERE, "redir_key.pem")
LOG = "/tmp/roster_server.log"
ADDR = ("127.0.0.1", 8081)
# Minimal "no update available" roster body. Unknown-format -> iterate from the log.
ROSTER_XML = b'<?xml version="1.0" encoding="utf-8"?>\n<rosterupdate version="0"/>\n'
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
class H(http.server.BaseHTTPRequestHandler):
def _handle(self, method):
log("%s %s from %s" % (method, self.path, self.client_address))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
body = ROSTER_XML
self.send_response(200)
self.send_header("Content-Type", "application/xml")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
if method == "GET":
self.wfile.write(body)
log(" -> 200 %dB (%r)" % (len(body), body[:60]))
def do_GET(self): self._handle("GET")
def do_HEAD(self): self._handle("HEAD")
def do_POST(self):
n = int(self.headers.get("Content-Length", 0) or 0)
if n:
log(" POST body: %r" % self.rfile.read(n)[:200])
self._handle("POST")
def log_message(self, *a): # silence default stderr logging
pass
def main():
open(LOG, "a").close()
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
ctx.minimum_version = ssl.TLSVersion.TLSv1
try:
ctx.set_ciphers("ALL:@SECLEVEL=0")
except Exception:
pass
httpd = http.server.HTTPServer(ADDR, H)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
log("=== roster_server https://%s:%d (FUT roster-update) ===" % ADDR)
httpd.serve_forever()
if __name__ == "__main__":
main()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
* 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
ADDR = ("127.0.0.1", 8099)
LOG = "/tmp/utas_server.log"
SID = "OPENFUT-SID-0000000000000001"
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
PERSONA_NAME = "CAGE" # PDTL.DSNM
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
# ---- payloads -------------------------------------------------------------
def auth_body():
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
def user_info():
# Deserializer 0x18013EC10; every member optional (unknown key ids are
# skipped via 0x180135FF0), so {} also parses.
return {
"personaId": PERSONA_ID,
"clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
"clubNameChangeAllowed": True,
"currencies": [{"name": "coins", "value": 15000},
{"name": "points", "value": 0}],
"won": 0, "draw": 0, "loss": 0,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False,
"feature": {"trade": True},
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
"actives": [], "squadList": [],
}
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member. The member
# NAME is never compared, but the nesting level is required.
USER_GET = {"userInfo": user_info()}
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
USER_POST = {"login": True, "userData": user_info(),
"squad": {}, "starterPack": {}, "bonusPacks": []}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
G = r"/ut/game/[^/]+"
ROUTES = [
(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)),
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
# Returning trusted=true makes FUT SKIP the security question.
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
(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})),
(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"/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, {})),
(re.compile(G + r"/season"), lambda m, h: (200, {})),
(re.compile(G + r"/club"), lambda m, h: (200, {})),
]
def user_route(h):
if h.command == "POST":
return 200, USER_POST
if NEW_USER:
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
return 404, {}
return 200, USER_GET
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _handle(self):
n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n) if n else b""
log("%s %s" % (self.command, self.path))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
if body:
log(" body: %s" % body[:1200].decode("utf-8", "replace"))
code, payload = 200, {}
for rx, fn in ROUTES:
if rx.search(self.path):
code, payload = fn(rx, self)
break
else:
log(" !! UNMAPPED PATH -> catch-all 200 {}")
raw = b"" if payload is None else json.dumps(payload).encode()
self.send_response(code)
if raw:
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD" and raw:
self.wfile.write(raw)
log(" -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle
def log_message(self, *a):
pass
if __name__ == "__main__":
open(LOG, "a").close()
log("=== utas_server http://%s:%d ===" % ADDR)
http.server.ThreadingHTTPServer(ADDR, H).serve_forever()