From edab23f04a06c6cbf337ac056b1d9d59f7d7dc91 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 1 Aug 2026 09:12:17 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o --- fifa17-recon/.gitignore | 11 + fifa17-recon/FUT-RUNBOOK.md | 67 + fifa17-recon/README.md | 196 ++ fifa17-recon/docs/ENQUEUE_PLAN.md | 317 +++ fifa17-recon/docs/REPACK_INTEL.md | 399 ++++ fifa17-recon/tools/autopatch.py | 49 + fifa17-recon/tools/blaze_responder_v3b.py | 1734 +++++++++++++++++ fifa17-recon/tools/heat2.py | 478 +++++ fifa17-recon/tools/login_dump/BRIEF2.md | 67 + fifa17-recon/tools/login_dump/BRIEF3.md | 55 + fifa17-recon/tools/login_dump/BRIEF4.md | 53 + fifa17-recon/tools/login_dump/BRIEF5.md | 58 + fifa17-recon/tools/login_dump/BRIEF6.md | 48 + fifa17-recon/tools/login_dump/BRIEF7.md | 63 + .../tools/login_dump/COMPONENT_000A_PLAN.md | 428 ++++ fifa17-recon/tools/login_dump/CONNECT_PLAN.md | 493 +++++ fifa17-recon/tools/login_dump/ENQUEUE_PLAN.md | 317 +++ .../tools/login_dump/FUT_LOADING_PLAN.md | 584 ++++++ .../tools/login_dump/FUT_UTAS_PLAN.md | 640 ++++++ .../tools/login_dump/QOS_CONNECT_PLAN.md | 496 +++++ .../tools/login_dump/UNDERAGE_PLAN.md | 417 ++++ fifa17-recon/tools/lsx_responder_v2.py | 588 ++++++ fifa17-recon/tools/openfut-fut.sh | 104 + fifa17-recon/tools/root_arm.sh | 21 + fifa17-recon/tools/roster_server.py | 79 + fifa17-recon/tools/utas_server.py | 147 ++ 26 files changed, 7909 insertions(+) create mode 100644 fifa17-recon/.gitignore create mode 100644 fifa17-recon/FUT-RUNBOOK.md create mode 100644 fifa17-recon/README.md create mode 100644 fifa17-recon/docs/ENQUEUE_PLAN.md create mode 100644 fifa17-recon/docs/REPACK_INTEL.md create mode 100644 fifa17-recon/tools/autopatch.py create mode 100644 fifa17-recon/tools/blaze_responder_v3b.py create mode 100644 fifa17-recon/tools/heat2.py create mode 100644 fifa17-recon/tools/login_dump/BRIEF2.md create mode 100644 fifa17-recon/tools/login_dump/BRIEF3.md create mode 100644 fifa17-recon/tools/login_dump/BRIEF4.md create mode 100644 fifa17-recon/tools/login_dump/BRIEF5.md create mode 100644 fifa17-recon/tools/login_dump/BRIEF6.md create mode 100644 fifa17-recon/tools/login_dump/BRIEF7.md create mode 100644 fifa17-recon/tools/login_dump/COMPONENT_000A_PLAN.md create mode 100644 fifa17-recon/tools/login_dump/CONNECT_PLAN.md create mode 100644 fifa17-recon/tools/login_dump/ENQUEUE_PLAN.md create mode 100644 fifa17-recon/tools/login_dump/FUT_LOADING_PLAN.md create mode 100644 fifa17-recon/tools/login_dump/FUT_UTAS_PLAN.md create mode 100644 fifa17-recon/tools/login_dump/QOS_CONNECT_PLAN.md create mode 100644 fifa17-recon/tools/login_dump/UNDERAGE_PLAN.md create mode 100644 fifa17-recon/tools/lsx_responder_v2.py create mode 100755 fifa17-recon/tools/openfut-fut.sh create mode 100755 fifa17-recon/tools/root_arm.sh create mode 100644 fifa17-recon/tools/roster_server.py create mode 100755 fifa17-recon/tools/utas_server.py diff --git a/fifa17-recon/.gitignore b/fifa17-recon/.gitignore new file mode 100644 index 0000000..fe43d08 --- /dev/null +++ b/fifa17-recon/.gitignore @@ -0,0 +1,11 @@ +# Heavy / game-derived / volatile — never commit +*.asm +*.bin +*.strings +*.dll +*.exe +*.pem +*.key +*.log +__pycache__/ +captures/ diff --git a/fifa17-recon/FUT-RUNBOOK.md b/fifa17-recon/FUT-RUNBOOK.md new file mode 100644 index 0000000..e0634b9 --- /dev/null +++ b/fifa17-recon/FUT-RUNBOOK.md @@ -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). diff --git a/fifa17-recon/README.md b/fifa17-recon/README.md new file mode 100644 index 0000000..6ab3cfb --- /dev/null +++ b/fifa17-recon/README.md @@ -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 +... +``` +Fire2/Heat2 binary is the **second hop** — the redirector replies with a +`` 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 +`` 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 +`...`. Working response (member=0 = ipAddress variant): +```xml + +
+ 127.0.0.1213070643342130 +
+ 0 + + 0 +
+``` +`` is a **decimal uint32** host-order (2130706433 = 127.0.0.1). `` 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 ``; client + replies ``; **H = hex(AES128-ECB(K=000102..0f, + PKCS7pad16(clientKey_ascii)))** (32 ASCII → 48 bytes/3 blocks); server sends ``; 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 `` 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 C1–C3 (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 ``. +**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 + 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). diff --git a/fifa17-recon/docs/ENQUEUE_PLAN.md b/fifa17-recon/docs/ENQUEUE_PLAN.md new file mode 100644 index 0000000..5833c3d --- /dev/null +++ b/fifa17-recon/docs/ENQUEUE_PLAN.md @@ -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 ``. 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`, **transmits it** (`call [0x148e219f8]`), + registers the pending future in the SDK reqId-keyed map (`0x1470e6540`), writes future→out, reqId→out. + *This is the point `` 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 `` 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(' 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(' {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=") + 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(' {NODE:#x}. Watch /tmp/lsx.log for .") + +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 `` 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 `` 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 `` 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. diff --git a/fifa17-recon/docs/REPACK_INTEL.md b/fifa17-recon/docs/REPACK_INTEL.md new file mode 100644 index 0000000..e44d82b --- /dev/null +++ b/fifa17-recon/docs/REPACK_INTEL.md @@ -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: + ``` + + ``` +2. Client replies **plaintext** ``. +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,; 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` | `` | +| `0x1800050b0` | `` | +| `0x180005170` | `` | +| `0x1800051d0` | `` | +| `0x180005230` | `` | +| `0x1800052b0` | `` | +| `0x1800053b0` | `` | +| `0x180005410` | `` | +| `0x180005470` | `` | +| `0x1800054d0` | `` | +| `0x180005530` | `` | +| `0x180005590` | `` | + +**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 `` 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` ``. `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* `` with no `id` and no `` 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 `` 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 +%s2b8ee7faea76e8a34f5f5d20e5328e32%d%d%s2017-01-01T00:00:00Z2017-01-01T00:00:00Z +``` + +**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 ``. 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 ``, 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× `` 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 `` with no `id` and no `` 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 `` 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 `` 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 ``-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 C1–C3** (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* `` 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 1–2). +* **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). diff --git a/fifa17-recon/tools/autopatch.py b/fifa17-recon/tools/autopatch.py new file mode 100644 index 0000000..967a6e0 --- /dev/null +++ b/fifa17-recon/tools/autopatch.py @@ -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) diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py new file mode 100644 index 0000000..593557f --- /dev/null +++ b/fifa17-recon/tools/blaze_responder_v3b.py @@ -0,0 +1,1734 @@ +#!/usr/bin/env python3 +"""FIFA17 Blaze redirector + SESSION SERVER (v3b) -- offline forged authentication. + +WHAT IS NEW vs v3 (this file; applies the adversarial-verify blocker/major fixes) +--------------------------------------------------------------------------------- + * BLOCKER: blazesdk_config() now serves the four client-id keys + (blazeSdkClientId/blazeServerClientId/blazeSdkClientSecret/identityRedirectUri). + Without a non-empty blazeSdkClientId, OriginRequestAuthCodeSync rejects the + request before any LSX GetAuthCode is issued. (Also mirrored into OSDK_NUCLEUS.) + * BLOCKER: implemented getAccount (1/0x1E) -> AccountInfo (16 members, reversed + from descriptor 0x14487c810), getPersona (1/0x5A) -> GetPersonaResponse and + listPersonas (1/0x64) -> ListPersonasResponse. Previously these fell through + to an empty reply and reproduced the account-info popup one layer later. + * MAJOR: dropped 9 PHANTOM OSDK config keys (not string literals in FIFA17.exe); + added the real SV_ENABLE_SERVER_VERSIONING/SV_CLIENT_CHANGELIST/SV_SERVER_VERSION + to OSDK_CORE (read by LoginStateVersionCheck, state 700); pointed the real + NUCLEUS_CREATE_URL/NUCLEUS_ADDED_URL at the local stub; added the netres CFID. + * MINOR: UserIdentification.ALOC now echoes sess.account_locale (was a hardcoded + fallback, could diverge from UserAuthenticated for a non-enUS client). + * The logout (1/0x46) log no longer shouts "FAILURE SIGNAL": it is the normal + LoginStateLogout (state 500) between Connect (400) and PCLogin (800). + +WHAT IS NEW vs v2 +----------------- + * Util::fetchClientConfig (9/1) answered for real, per-CFID, with a proper + FetchConfigResponse{CONF: map}. Unknown CFID -> EMPTY MAP + (a present-but-empty CONF), never an empty frame. + * Authentication (component 0x0001): + login (1/0x0A) -> forged offline LoginResponse (persona 33068179/CAGE) + logout (1/0x46) -> empty REPLY, and logged as a FAILURE SIGNAL + getAuthToken (1/0x24), listUserEntitlements2 (1/0x1D) + * UserSessions (0x7802) NOTIFICATIONs pushed unsolicited: + 0x0008 UserAuthenticated (Blaze::UserSessionLoginInfo) + 0x0001 UserSessionExtendedDataUpdate + 0x0002 UserAdded (Blaze::UserData) + * Util::postAuth (9/8), setClientState (9/0x1C), userSettingsLoad (9/0x0A), + setClientMetrics (9/0x16), AssociationLists::getLists (25/6), + UserSessions::updateNetworkInfo (0x7802/0x14). + * Optional local Nucleus OAuth stub on 42131 serving POST /connect/token, with + nucleusConnect / nucleusConnectTrusted in the BlazeSDK config pointed at it. + * PingResponse now carries ONLY STIM (FIFA 17's PingResponse @0x144875560 has + exactly one member; v2's extra TIME was Mirror's-Edge-Catalyst's field). + +TWO-LAYER ORDERING -- READ THIS FIRST +------------------------------------- +This file is layer 2. Layer 1 is Origin/LSX on 127.0.0.1:4216. The client's +`origin.nav` gates FUT on `OriginIsOnlineTrue` and only then runs +`futBlazeLogin`; without a layer-1 auth code the client sends +`Authentication::logout` (1/0x46) instead of `login` and shows +"Unable to connect to the EA Servers ... log in to Origin in Online Mode". +Start `lsx_responder.py` (before FIFA) or apply `lsx_force_online.py` FIRST. +Receiving 1/0x46 here means layer 1 is still broken. + +CLEAN ROOM. Every TDF tag/type below comes from FIFA17.exe's own in-process TDF +reflection metadata that we walked in live memory, plus our own captured wire +bytes. Independent third-party clean-room BlazeSDK-15.x reimplementations were +consulted only to cross-check *structure*. No EA/FIFA leaked source was used. + +Run: python3 blaze_responder_v3.py (binds 42127 + 42130 [+ 42131]) +Selftest: python3 blaze_responder_v3.py --selftest +Log: /tmp/blaze_responder.log +Frames: /tmp/blaze_rx/ +""" + +from __future__ import annotations + +import binascii +import json +import os +import random +import socket +import ssl +import string +import struct +import sys +import threading +import time +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import heat2 # noqa: E402 +from heat2 import ( # noqa: E402 + INT, STRING, BLOB, STRUCT, LIST, MAP, encode_tdf, decode_tdf, +) + +# ================================================================== identity +# SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py. +# Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language). +# A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26), +# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND. + +PERSONA_ID = 33068179 +PERSONA_NAME = "CAGE" +USER_ID = 33068179 # blazeId / userId; same value keeps BUID==UID==PID +EXT_ID = 33068179 # XREF externalId +EMAIL = "cage@openfut.local" +PERSONA_NAMESPACE = "cem_ea_id" # must equal PreAuthResponse.NASP +CLIENT_PLATFORM = 4 # Blaze::ClientPlatformType -> pc +PERSONA_STATUS = 2 # PersonaStatus::Code -> ACTIVE (verified live: table 0x14487ad20, ACTIVE==2) +USER_SESSION_TYPE = 0 # Blaze::UserSessionType -> normal/console user +ACCOUNT_LOCALE_FALLBACK = 0x656E5553 # 'enUS'; overwritten by the client's own + # PreAuthRequest LANG/LOC when we see it. + +CONTENT_ID = "1027460" # FIFA 17 EA offer id (retail) +ENTITLEMENT_TAG = "ONLINE_ACCESS" # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe +ENTITLEMENT_GROUP = "FIFA17PCBoxContent" # was "FIFA17PC" -> matched NEITHER strstr +# needle in EntitlementComponent::onListEntitlements (0x146f27440): FUT keeps an +# entitlement only if GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC" (needles +# @0x144334030), TAG non-empty, STAT==1. "FIFA17PC" survived none -> empty store. + +TITLE_ID = "309111" +CLIENT_ID = "FIFA17-PC-SERVER-BLAZE" +PLATFORM = "pc" +SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" + +# ================================================================== config + +HOST = "127.0.0.1" +REDIR_PORT = 42127 +BLAZE_PORT = 42130 +NUCLEUS_PORT = 42131 +BLAZE_IP_STR = "127.0.0.1" +BLAZE_IP_U32 = (127 << 24) | 1 +LOG = "/tmp/blaze_responder.log" +RXDIR = "/tmp/blaze_rx" +HERE = os.path.dirname(os.path.abspath(__file__)) +CERT = os.path.join(HERE, "redir_cert.pem") +KEY = os.path.join(HERE, "redir_key.pem") + +# Serve a local OAuth stub and advertise it as nucleusConnect. The client's +# LoginStateMachineImpl builds "/connect/token", POSTs +# grant_type=client_credentials, and scrapes '"access_token" : "'. +NUCLEUS_STUB_ENABLED = True +EMIT_NUCLEUS_URLS = True +NUCLEUS_BASE = "http://%s:%d" % (HOST, NUCLEUS_PORT) + +# Any RPC we do not implement still gets an empty REPLY so the client's request +# never times out. Flip to False to find out what it truly blocks on. +REPLY_EMPTY_TO_UNKNOWN = True + +# Push the UserAuthenticated notification BEFORE writing the login reply +# (grid-blaze order) or after (pamplona order). Both are reported to work. +NOTIFY_BEFORE_LOGIN_REPLY = False + +DUMP_FRAMES = True + +_log_lock = threading.Lock() + + +def log(m: str) -> None: + line = "[%s] %s" % (time.strftime("%H:%M:%S"), m) + with _log_lock: + print(line, flush=True) + try: + with open(LOG, "a") as fh: + fh.write(line + "\n") + except Exception: + pass + + +def hexdump(b: bytes, limit: int = 512) -> str: + out = [] + for i in range(0, min(len(b), limit), 16): + chunk = b[i:i + 16] + txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk) + out.append(" %04x: %-47s %s" + % (i, binascii.hexlify(chunk, " ").decode(), txt)) + if len(b) > limit: + out.append(" ... (%d more bytes)" % (len(b) - limit)) + return "\n".join(out) + + +# ================================================================== Fire2 +# +# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame / +# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them): +# +# [0:4] u32 payload length +# [4:6] u16 metadata length +# [6:8] u16 component +# [8:10] u16 command (== notification id on NOTIFICATION) +# [10:13] u24 msgNum +# [13] u8 (msgType << 5) | (userIndex & 0x1F) +# [14] u8 options +# [15] u8 reserved +# wire = header(16) || metadata || payload +# +# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame). + +FIRE2_HDR = 16 + +MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6) +MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION", + 3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"} + +COMP_AUTH = 0x0001 +COMP_GAMEMANAGER = 0x0004 +COMP_REDIRECTOR = 0x0005 +COMP_STATS = 0x0007 +COMP_UTIL = 0x0009 +COMP_MESSAGING = 0x000F +COMP_ASSOCLISTS = 0x0019 +COMP_GAMEREPORTING = 0x001C +COMP_USERSESSIONS = 0x7802 +COMP_CENSUSDATA = 0x000A # id built at 0x147e639cd; "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 + +# ---- Util (0x0009) command table, recovered from the binary's own +# getCommandName switch (jump table 0x141b17af4). +CMD_FETCHCLIENTCONFIG = 0x0001 +CMD_PING = 0x0002 +CMD_PREAUTH = 0x0007 +CMD_POSTAUTH = 0x0008 +CMD_USERSETTINGSLOAD = 0x000A +CMD_USERSETTINGSSAVE = 0x000B +CMD_SETCLIENTMETRICS = 0x0016 +CMD_SETCLIENTSTATE = 0x001C + +UTIL_CMDS = { + 0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData", + 0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer", + 0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad", + 0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll", + 0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser", + 0x14: "filterForProfanity", 0x15: "fetchQosConfig", + 0x16: "setClientMetrics", 0x17: "setConnectionState", + 0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing", + 0x1C: "setClientState", +} + +# ---- Authentication (0x0001) command table. Recovered by CALLING the client's +# own getCommandName (0x146e0d2a0) in-process over ids 1..320 -- the name +# pool is Denuvo-mutated and statically unrecoverable. Validated against +# Util (reproduced preAuth=7/ping=2/fetchClientConfig=1) and cross-checked +# against a static REST-binding struct (0x143896a80 -> trustedLogin=0x0B). +CMD_LOGIN = 0x000A +CMD_TRUSTEDLOGIN = 0x000B +CMD_GETACCOUNT = 0x001E +CMD_LISTUSERENTITLEMENTS2 = 0x001D +CMD_GETAUTHTOKEN = 0x0024 +CMD_EXPRESSLOGIN = 0x003C +CMD_LOGOUT = 0x0046 # <-- the "we gave up" RPC, NOT a login +CMD_GETPERSONA = 0x005A +CMD_LISTPERSONAS = 0x0064 + +AUTH_CMDS = { + 0x0A: "login", 0x0B: "trustedLogin", 0x14: "updateAccount", + 0x15: "upgradeAccount", 0x1D: "listUserEntitlements2", 0x1E: "getAccount", + 0x1F: "grantEntitlement", 0x20: "listEntitlements", 0x22: "getUseCount", + 0x23: "decrementUseCount", 0x24: "getAuthToken", 0x26: "getPasswordRules", + 0x27: "grantEntitlement2", 0x2B: "modifyEntitlement2", 0x2C: "consumecode", + 0x2D: "passwordForgot", 0x2F: "getPrivacyPolicyContent", + 0x30: "listPersonaEntitlements2", 0x33: "checkAgeReq", 0x34: "getOptIn", + 0x35: "enableOptIn", 0x36: "disableOptIn", 0x3C: "expressLogin", + 0x46: "logout", 0x5A: "getPersona", 0x64: "listPersonas", + 0x65: "expressCreateAccount", 0xE6: "createWalUserSession", + 0xF1: "acceptLegalDocs", 0xF2: "getEmailOptInSettings", + 0xF6: "getTermsOfServiceContent", 0x104: "getOriginPersona", + 0x10E: "checkEmail", 0x118: "getPersonaNameSuggestions", 0x122: "guestLogin", +} + +# ---- UserSessions (0x7802). Commands and NOTIFICATIONS live in separate +# number spaces. Notification ids decoded statically from the client's +# own getNotificationName jump table at 0x141b03f70 (clean, unmutated). +NOTIFY_USER_EXTENDED_DATA_UPDATE = 0x0001 +NOTIFY_USER_ADDED = 0x0002 +NOTIFY_USER_REMOVED = 0x0003 +NOTIFY_USER_UPDATED = 0x0005 +NOTIFY_USER_AUTHENTICATED = 0x0008 +NOTIFY_USER_UNAUTHENTICATED = 0x0009 +NOTIFY_SERVER_DRAINING = 0x000C + +USERSESSIONS_NOTIFY_NAMES = { + 0x01: "UserSessionExtendedDataUpdate", 0x02: "UserAdded", + 0x03: "UserRemoved", 0x05: "UserUpdated", 0x08: "UserAuthenticated", + 0x09: "UserUnauthenticated", 0x0C: "ServerDraining", +} + +CMD_UPDATENETWORKINFO = 0x0014 # UserSessions command space +CMD_GETLISTS = 0x0006 # AssociationLists + +COMP_NAMES = { + COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager", + COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util", + COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists", + COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions", + COMP_CENSUSDATA: "CensusData", + 0x000B: "Clubs", 0x081C: "SponsoredEvents", 0x08C9: "OSDKSettings", +} + + +def rpc_name(component: int, command: int, msg_type: int = MESSAGE) -> str: + comp = COMP_NAMES.get(component, "Component:0x%04x" % component) + if component == COMP_USERSESSIONS and msg_type == NOTIFICATION: + cmd = USERSESSIONS_NOTIFY_NAMES.get(command, "notify:0x%04x" % command) + return "%s::<%s>" % (comp, cmd) + if component == COMP_UTIL: + cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command) + elif component == COMP_AUTH: + cmd = AUTH_CMDS.get(command, "cmd:0x%04x" % command) + elif component == COMP_CENSUSDATA: + cmd = CENSUSDATA_CMDS.get(command, "cmd:0x%04x" % command) + else: + cmd = "cmd:0x%04x" % command + return "%s::%s" % (comp, cmd) + + +def fire2(component: int, command: int, msg_num: int, msg_type: int, + payload: bytes = b"", metadata: bytes = b"", + user_index: int = 0, options: int = 0) -> bytes: + h = bytearray(16) + struct.pack_into(">I", h, 0, len(payload)) + struct.pack_into(">H", h, 4, len(metadata)) + struct.pack_into(">H", h, 6, component & 0xFFFF) + struct.pack_into(">H", h, 8, command & 0xFFFF) + h[10] = (msg_num >> 16) & 0xFF + h[11] = (msg_num >> 8) & 0xFF + h[12] = msg_num & 0xFF + h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F) + h[14] = options & 0xFF + h[15] = 0 + return bytes(h) + metadata + payload + + +def parse_fire2_header(buf: bytes) -> dict: + return dict( + payload_len=struct.unpack_from(">I", buf, 0)[0], + metadata_len=struct.unpack_from(">H", buf, 4)[0], + component=struct.unpack_from(">H", buf, 6)[0], + command=struct.unpack_from(">H", buf, 8)[0], + msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12], + msg_type=(buf[13] >> 5) & 0x07, + user_index=buf[13] & 0x1F, + options=buf[14], + reserved=buf[15], + ) + + +def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes: + """A Blaze reply echoes component/command/msgNum/userIndex verbatim and only + overwrites the msgType bits (byte[13] = 0x20 for REPLY + userIndex 0).""" + return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type, + payload, user_index=hdr["user_index"]) + + +def notification(component: int, notify_id: int, payload: bytes = b"", + user_index: int = 0) -> bytes: + """Unsolicited server push: msgType = NOTIFICATION (2) -> byte[13] = 0x40, + msgNum = 0 (notifications are not correlated to a request).""" + return fire2(component, notify_id, 0, NOTIFICATION, payload, + user_index=user_index) + + +# ================================================================== session + +class Session(object): + """Per-connection forged session state.""" + + def __init__(self): + self.session_key = make_session_key() + self.auth_code = "" # whatever LoginRequest.AUTH carried + self.account_locale = ACCOUNT_LOCALE_FALLBACK + self.service_name = "fifa-2017-pc" + self.logged_in = False + self.login_time = 0 + + +def make_session_key() -> str: + """Real Blaze session keys look like <16 hex>_<44 base64-ish chars>. + The client never validates it -- grid-blaze literally ships "0" -- but the + SAME string must appear in LoginResponse.SESS.KEY and in the + UserAuthenticated notification's KEY, so we mint it once per session.""" + alpha = string.ascii_letters + string.digits + "$*" + return ("%016x_" % random.getrandbits(64)) + \ + "".join(random.choice(alpha) for _ in range(44)) + + +# ============================================== Util::fetchClientConfig (9/1) +# +# Response type: Blaze::Util::FetchConfigResponse @0x1448752e0 -- a SINGLE +# member `CONF` : map. NOT double-nested: the extra nesting only +# exists inside PreAuthResponse, where CONF is itself a FetchConfigResponse +# whose own single member is also called CONF. Easy to get wrong. + +# Keys verified present as string literals in FIFA17.exe (owner in comment). +# Anything not in the binary is silently ignored, so the map is kept minimal. +# All time values are MICROSECONDS -- the client divides by 1000 to get ms. +def blazesdk_config() -> list: + cfg = [ + # --- BLOCKER FIX (verify: identity-and-next-gate) --------------------- + # OriginRequestAuthCodeSync (real impl 0x1470e67f0) hard-rejects an empty + # ClientId BEFORE any LSX GetAuthCode is issued: + # 1470e6831: test r8,r8 / je (NULL clientId) + # 1470e683a: cmp BYTE [r8],0 / je (empty clientId) + # Its caller 0x147237440 sources the clientId from THIS BlazeSDK config + # map: 147237484 lea rdx,->"blazeSdkClientId"; call [rax+0x48]. Without + # a non-empty value here, flipping m_isLoggedIn alone can NEVER produce a + # GetAuthCode. All four key names verified present as string literals in + # FIFA17.exe (blazeSdkClientId@0x1439726a8, blazeServerClientId@0x143972690, + # blazeSdkClientSecret@0x1439726c0, identityRedirectUri@0x1439726d8). + # Non-empty is the only hard requirement; the actual value the client + # presents will be echoed back to /tmp/openfut_lsx_clientid.txt. + ("blazeSdkClientId", "FIFA17PC"), + ("blazeServerClientId", "FIFA17PC-SERVER"), + ("blazeSdkClientSecret", "openfut-secret"), + ("identityRedirectUri", "http://127.0.0.1/login_successful.html"), + # --------------------------------------------------------------------- + # 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). Sending "30000000" landed defaultRequestTimeout=0 and + # connIdleTimeout=0 -> every Blaze conn died at first idle -> "Unable to connect + # to the EA servers" on the go-online preAuth. Durations MUST be unit-suffixed. + ("associationListSkipInitialSet", "1"), # 0x143b6eb88 AssocListAPI (atoi) + ("autoReconnectEnabled", "1"), # 0x1438a0a68 ConnMgr (atoi) + ("connIdleTimeout", "90s"), # 0x1438a0a58 TimeValue -> 90000ms + ("defaultRequestTimeout", "30s"), # 0x1438a0a40 TimeValue -> 30000ms + ("enableQosBandwidthTest", "false"), # 0x1438a0a08 clears bit1 + ("enableQosFirewallTest", "false"), # 0x1438a09f0 clears bit0 + ("maxReconnectAttempts", "5"), # 0x1438a0a80 ConnMgr (atoi) + ("pingPeriod", "20s"), # 0x1438a0a30 TimeValue -> 20000ms + ("userManagerMaxCachedUsers", "128"), # UserManager + ("voipHeadsetUpdateRate", "0"), # VoIP + ] + if EMIT_NUCLEUS_URLS: + # LoginStateMachineImpl (0x14389fd50-0x14389fef8) builds + # "/connect/token", POSTs grant_type=client_credentials, + # and scrapes '"access_token" : "' out of the reply. Point it at our own + # stub (see nucleus_handle) so it can never reach a real EA host. + cfg += [ + ("nucleusConnect", NUCLEUS_BASE), # 0x14389fef8 + ("nucleusConnectTrusted", NUCLEUS_BASE), # 0x14389fdf8 + ] + return sorted(cfg) + + +# The OSDK_* sections are NOT Blaze plumbing. They are FIFA's own OSDK +# (8.01.03.00-fifa.01) ResourceLoader tuning maps; every key falls back to a +# built-in default, which is why our empty replies did not by themselves kill +# the login. Answering them non-empty is cheap insurance and removes a +# variable. Key names are literals observed in FIFA17.exe. +OSDK_CORE = [ + ("OSDK_PRESENCE_DELAY", "5"), # verified string literal in image + ("OSDK_PRESENCE_POLL", "60"), # verified + ("OSDK_ANTIGRIEFING_MAX_COUNT", "0"), # verified + ("OSDK_ARENA_ENABLED", "0"), # verified + # --- MAJOR FIX (verify: identity-and-next-gate) ---------------------- + # LoginStateVersionCheck (OSDK state 700, runs right after the normal + # Logout 500) reads these three SV_* keys; a version mismatch trips the + # "Client/server version mismatch!" string @0x14395d1d0. All three are + # REAL string literals in FIFA17.exe (SV_ENABLE_SERVER_VERSIONING + # @0x14395d148 xref 0x14717f8fa, SV_CLIENT_CHANGELIST @0x14395d168, + # SV_SERVER_VERSION @0x14395d180). Disable server versioning so the + # check cannot fail. + ("SV_ENABLE_SERVER_VERSIONING", "0"), + ("SV_CLIENT_CHANGELIST", "0"), + ("SV_SERVER_VERSION", "0"), +] +OSDK_CLIENT = [ + ("OSDK_CLUBS_MAX_SEARCH_RESULT", "50"), + ("OSDK_CLUBS_LOAD_MEMBER_PAGE_SIZE", "25"), + ("OSDK_CLUBS_MAX_USERS_FOR_GAME", "22"), + ("OSDK_CLUBS_LEADERBOARD_CLUB_MAX", "100"), + ("OSDK_CLUBS_INCOME_SEARCH_MAX", "100"), +] +# --- MAJOR FIX (verify: identity-and-next-gate) -------------------------- +# The four OSDK_NUCLEUS_* keys below were PHANTOM -- none of them exist as +# string literals in FIFA17.exe, so serving them was cosmetic (the log said +# "4 key(s)" but the client ignored every one). Dropped. The only real +# NUCLEUS_*_URL literals in the image are NUCLEUS_CREATE_URL (0x14395eb60) +# and NUCLEUS_ADDED_URL (0x14395ebb0); point them at the local stub. The four +# blazeSDK client-id keys are mirrored here as zero-cost insurance in case the +# client sources ClientId from this CFID rather than BlazeSDK (which CFID is +# authoritative is an inference; the LSX log will report which value wins). +OSDK_NUCLEUS = [ + ("NUCLEUS_CREATE_URL", NUCLEUS_BASE), # verified literal @0x14395eb60 + ("NUCLEUS_ADDED_URL", NUCLEUS_BASE), # verified literal @0x14395ebb0 + ("blazeSdkClientId", "FIFA17PC"), + ("blazeServerClientId", "FIFA17PC-SERVER"), + ("blazeSdkClientSecret", "openfut-secret"), + ("identityRedirectUri", "http://127.0.0.1/login_successful.html"), +] +# OSDK_WEBOFFER_ENABLED / OSDK_WEBOFFER_URL were BOTH phantom (absent in the +# image). Nothing real to serve here -> empty map (still a present CONF). +OSDK_WEBOFFER = [] +# OSDK_ABUSE_REPORTING_ENABLED was phantom; OSDK_ABUSE_NUM_TYPES is real. +OSDK_ABUSE_REPORTING = [ + ("OSDK_ABUSE_NUM_TYPES", "0"), # verified literal in image +] +# OSDK_TICKER_ENABLED was phantom -> empty map. +OSDK_TICKER = [] +# --- FUT LOADING GATE (COMPONENT: FUT_LOADING_PLAN.md) -------------------- +# FIFA fetches fetchClientConfig(CFID='OSDK_ROSTER') at FUT entry (RX #22). +# ROSTERUPDATE_URL is the ONLY remaining source for the FUT roster-XML URL -- +# the two ini keys (FUT/ROSTERUPDATE_URL @0x143af1810, ROSTERUPDATE_URL +# @0x143af1828) are absent on disk, so the resolver 0x147a77410 falls through to +# cfg->getString("ROSTERUPDATE_URL","",...) @0x147a77562; an EMPTY result makes +# 0x147a77577 return WITHOUT issuing the HTTP request -> flow CheckFUTRosterUpdateXML +# never gets advance/back -> the silent FUT loading-screen hang. The store is the +# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this +# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url. +# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject +# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK. +ROSTER_HOST = "127.0.0.1:8081" +OSDK_ROSTER = [ + ("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST), + ("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0 + ("ROSTER_VER", "0"), # @0x143973ab0 + ("ROSTER_CSUM", ""), # @0x143973ad0 +] +# "netres" is itself a CFID (config section) -- it sits immediately before +# OSDK_CORE in the CFID-name table @0x143962be0, so the client may request +# fetchClientConfig(CFID="netres"). We have no verified keys for it; serve a +# present-but-empty map (unknown CFID would already do this, but make it +# explicit so the log shows it was handled, not defaulted). +OSDK_NETRES = [] +# Not requested by FIFA 17 in our capture, but both independent clean-room +# emulators answer it identically; harmless to have ready. +IDENTITY_PARAMS = [ + ("display", "console2/welcome"), + ("redirect_uri", "http://127.0.0.1/success"), +] + +CLIENT_CONFIGS = { + "BlazeSDK": None, # built dynamically, see below + "netres": OSDK_NETRES, # CFID (verified @0x143962be0) + "OSDK_CORE": OSDK_CORE, + "OSDK_CLIENT": OSDK_CLIENT, + "OSDK_NUCLEUS": OSDK_NUCLEUS, + "OSDK_WEBOFFER": OSDK_WEBOFFER, + "OSDK_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, + "OSDK_XMS_ABUSE_REPORTING": OSDK_ABUSE_REPORTING, + "OSDK_TICKER": OSDK_TICKER, + "OSDK_ROSTER": OSDK_ROSTER, # FUT roster-XML URL (loading gate) + "IdentityParams": IDENTITY_PARAMS, +} + +# --- FUT / UTAS (RS4) BASE URL (FUT_UTAS_PLAN.md) -------------------------- +# CardsDLL RS4::ServerSettings::resolve @0x180124270 sets each of the 125 endpoint +# descriptors' baseUrl in passes, last non-empty wins: (1) hardcoded default +# "http://easw.easports.com:8099/" (DEAD host -> "error connecting"); (2) cfg +# ["FUT_RS4_APIURL_"]; (3) cfg["FUT_RS4_URL_"]. Value used +# VERBATIM when it has "://" + a trailing '/' forced. Belt-and-braces alongside the +# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/" +# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT +# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code). +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", +] +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)] +) + + +def client_config_for(cfid: str) -> list: + """-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we + still wrap in a present CONF field -- never an empty frame). + FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section + CardsDLL reads is unproven, so serve them everywhere).""" + if cfid == "BlazeSDK": + return sorted(blazesdk_config() + FUT_RS4_CONFIG) + return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG) + + +def fetch_config_response_fields(cfid: str) -> "OrderedDict": + """Blaze::Util::FetchConfigResponse -- single member CONF : map.""" + return OrderedDict([ + ("CONF", (MAP, (STRING, STRING, client_config_for(cfid)))), + ]) + + +# ================================================== Util::preAuth (9/7) reply + +COMPONENT_IDS = [ + COMP_AUTH, COMP_GAMEMANAGER, COMP_REDIRECTOR, COMP_STATS, COMP_UTIL, + COMP_MESSAGING, COMP_ASSOCLISTS, COMP_GAMEREPORTING, COMP_USERSESSIONS, +] + + +def qos_config() -> "OrderedDict": + """Blaze::QosConfigInfo -- 4 members per reflection (FIFA 17's descriptor + has NO SVID, unlike Mirror's Edge Catalyst).""" + return OrderedDict([ + ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo + ("PSA", (STRING, "127.0.0.1")), + ("PSP", (INT, 17502)), + ]))), + ("LNP", (INT, 10)), + ("LTPS", (MAP, (STRING, STRUCT, []))), + ("TIME", (INT, 5000000)), + ]) + + +def subscribe_census_data_updates_response_fields() -> "OrderedDict": + """Blaze::CensusData::SubscribeToCensusDataUpdatesResponse (classinfo 0x144c11dc0, + tdfId 0x746128a3, 3 members, ALL TDF type 0x0e TimeValue -> Heat2 INT varint of + MICROSECONDS). The client reply cb 0x147e57050 computes + delay_ms = (CNP + NTMT) / 1000 + and arms a FunctorJob (0x147e57380) that re-sends this RPC with RSUB=1. All three + default to 0, so our old EMPTY reply gave delay=0 -> the scheduler's zero-delay + branch (0x146dbae7c) put it on the READY list -> one re-subscribe per idle tick = + the observed ~30/s storm. 30s+90s -> the client re-subscribes every 120s instead. + Tags ascend (8eec00 < bb4b74 < cb4b74) = correct Heat2 order.""" + return OrderedDict([ + ("CNP", (INT, 30 * 1000000)), # censusNotificationPeriod + ("NTMT", (INT, 90 * 1000000)), # notificationTimeout + ("RTMT", (INT, 300 * 1000000)), # resubscribeTimeout (error path only) + ]) + + +def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict": + return OrderedDict([ + ("ASRC", (STRING, TITLE_ID)), # authenticationSource + ("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds + ("CLID", (STRING, CLIENT_ID)), # clientId + ("CONF", (STRUCT, fetch_config_response_fields("BlazeSDK"))), + ("ESRC", (STRING, TITLE_ID)), # entitlementSource + ("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN + ("MAID", (INT, 0)), # machineId + ("MINR", (INT, 0)), # underageSupported = false + ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace + ("PILD", (STRING, "")), # legalDocGameIdentifier + ("PLAT", (STRING, PLATFORM)), # platform + ("QOSS", (STRUCT, qos_config())), # qosSettings + ("RSRC", (STRING, TITLE_ID)), # registrationSource + ("SVER", (STRING, SERVER_VERSION)), # serverVersion + ]) + + +def ping_response_fields() -> "OrderedDict": + """Blaze::Util::PingResponse @0x144875560 has EXACTLY ONE member: STIM + (serverTime, uint32). v2 also sent TIME -- that is MEC's field, not + FIFA 17's. Dropped.""" + return OrderedDict([("STIM", (INT, int(time.time())))]) + + +# ==================================== Authentication::login (1/0x0A) -- FORGED +# +# Blaze::Authentication::LoginResponse @0x14487d170 -- EXACTLY 5 members. +# NOTE the divergence from both MEC emulators: they emit CNTX, ERRC and a +# top-level SKEY. FIFA 17's LoginResponse has NONE of those -- CNTX/ERRC are +# the Blaze *error metadata* block, and the session key lives at SESS.KEY. + +def persona_details_fields(now: int) -> "OrderedDict": + """Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members.""" + return OrderedDict([ + ("DSNM", (STRING, PERSONA_NAME)), # displayName MUST be "CAGE" + ("LAST", (INT, now)), # lastAuthenticated uint32 + ("PID", (INT, PERSONA_ID)), # personaId int64 MUST be 33068179 + ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform enum -> pc + ("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE + ("XREF", (INT, EXT_ID)), # extId uint64 + ]) + + +def user_login_info_fields(sess: Session, now: int) -> "OrderedDict": + """Blaze::Authentication::UserLoginInfo @0x14487cb00 -- 8 members. + ('1CON' packs to 0x11 which sorts BELOW 'A'=0x21, so it is first.)""" + return OrderedDict([ + ("1CON", (INT, 0)), # isFirstConsoleLogin = false + ("BUID", (INT, USER_ID)), # blazeUserId -- MUST be != 0 + ("FRST", (INT, 0)), # isFirstLogin = false + ("KEY", (STRING, sess.session_key)), # sessionKey -- MUST be non-empty + ("LLOG", (INT, now)), # lastLoginDateTime + ("MAIL", (STRING, EMAIL)), # email + ("PDTL", (STRUCT, persona_details_fields(now))), + ("UID", (INT, USER_ID)), # userId -- MUST be != 0 + ]) + + +def login_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Authentication::LoginResponse @0x14487d170 -- 5 members only.""" + now = int(time.time()) + return OrderedDict([ + ("ANON", (INT, 0)), # isAnonymous -- 1 would give a guest session + ("NTOS", (INT, 0)), # needsLegalDoc -- 1 diverts to the legal-doc flow + ("SESS", (STRUCT, user_login_info_fields(sess, now))), + ("SPAM", (INT, 1)), # isOfLegalContactAge + ("UNDR", (INT, 0)), # isUnderage -- 1 strips online features + ]) + + +# =========================== Authentication::getAccount (1/0x1E) -- FORGED +# +# BLOCKER FIX (verify: identity-and-next-gate). getAccount was NOT implemented +# (it fell through to REPLY_EMPTY_TO_UNKNOWN -> an AccountInfo with UID=0/CO=""), +# which reproduces the "Unable to retrieve account information" popup one layer +# later. Blaze::Authentication::AccountInfo @0x14487c810 has EXACTLY 16 members, +# reversed byte-for-byte from FIFA17.exe's own reflection metadata: +# * class name + member count (16) read from the class descriptor header; +# * member tags read from the tag table @0x1448775a0 (stride 0x30, tag +# immediate at entry+0x20 in the 0xTTTTTT00 packing); +# * per-member WIRE TYPE read from each member's subtype-descriptor pointer: +# subtype 0x144867628 == string -> ASRC CO DOB DTCR LATH LN MAIL PML (8) +# int / enum subtypes -> AMU GOPT RC STAS STAT TPOT UDU UID (8) +# Enum values resolved from the in-image enum tables: +# STAS = AccountStatus::Code ACTIVE = 1 (table 0x14487a6c0) +# STAT = EmailStatus::Code VERIFIED = 2 (table 0x14487aa00) +# RC = StatusReason::Code none = 0 +# Ascending packed-tag order (what heat2 emits): AMU ASRC CO DOB DTCR GOPT LATH +# LN MAIL PML RC STAS STAT TPOT UDU UID. IDENTITY: MAIL/UID/ASRC MUST byte-match +# LoginResponse.SESS.MAIL/UID and PreAuthResponse.NASP. Empty request. + +ACCOUNT_LOCALE_STR = "en_US" # AccountInfo.LN (language) + + +def account_info_fields(sess: "Session", now: int) -> "OrderedDict": + day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) + return OrderedDict([ + ("AMU", (INT, 0)), # (int) + ("ASRC", (STRING, PERSONA_NAMESPACE)), # == PreAuthResponse.NASP + ("CO", (STRING, "US")), # country + ("DOB", (STRING, "1990-01-01T00:00:00Z")), # dateOfBirth (ISO-8601) + ("DTCR", (STRING, "2016-09-01T00:00:00Z")), # dateCreated (ISO-8601) + ("GOPT", (INT, 0)), # globalOptin + ("LATH", (STRING, day)), # lastAuth (ISO-8601) + ("LN", (STRING, ACCOUNT_LOCALE_STR)), # language + ("MAIL", (STRING, EMAIL)), # == LoginResponse.SESS.MAIL + ("PML", (STRING, "")), # parentalEmail + ("RC", (INT, 0)), # StatusReason::Code + ("STAS", (INT, 1)), # AccountStatus ACTIVE = 1 + ("STAT", (INT, 2)), # EmailStatus VERIFIED = 2 + ("TPOT", (INT, 0)), # thirdPartyOptin + ("UDU", (INT, 0)), # underageUser + ("UID", (INT, USER_ID)), # == LoginResponse.SESS.UID + ]) + + +# ============= Authentication::getPersona (1/0x5A) / listPersonas (1/0x64) +# +# Blaze::Authentication::PersonaInfo @0x14487c7c0 -- 7 members (tags from the +# reflection table @0x144877430): DSNM DTCR LADT NSNM PID STAS STRC. DSNM/DTCR/ +# NSNM share the string subtype 0x144867628; PID is int; STAS is +# PersonaStatus::Code (ACTIVE = 2, table 0x14487ad20); STRC is StatusReason::Code. +# LADT (lastAuthenticated) has a distinct subtype -- emitted as an INT timestamp +# (best-guess; only reached via getPersona/listPersonas, off the critical +# getAccount path, so a wrong type here cannot re-raise the account-info popup). + +def persona_info_fields(now: int) -> "OrderedDict": + return OrderedDict([ + ("DSNM", (STRING, PERSONA_NAME)), # displayName "CAGE" + ("DTCR", (STRING, "2016-09-01T00:00:00Z")), # dateCreated (string) + ("LADT", (INT, now)), # lastAuthenticated (best-guess int) + ("NSNM", (STRING, PERSONA_NAMESPACE)), # nameSpaceName "cem_ea_id" + ("PID", (INT, PERSONA_ID)), # personaId 33068179 + ("STAS", (INT, 2)), # PersonaStatus ACTIVE = 2 + ("STRC", (INT, 0)), # statusReasonCode + ]) + + +def get_persona_response_fields(now: int) -> "OrderedDict": + """Blaze::Authentication::GetPersonaResponse @0x14487d1c0 -- 2 members: + PINF (PersonaInfo) + UID (int).""" + return OrderedDict([ + ("PINF", (STRUCT, persona_info_fields(now))), + ("UID", (INT, USER_ID)), + ]) + + +def list_personas_response_fields(now: int) -> "OrderedDict": + """Blaze::Authentication::ListPersonasResponse @0x14487d210 -- 1 member: + PINF (list).""" + return OrderedDict([ + ("PINF", (LIST, (STRUCT, [persona_info_fields(now)]))), + ]) + + +# ============================ UserSessions notifications (component 0x7802) +# +# Authentication (0x0001) publishes NO notifications at all -- its +0x28 slot is +# getRestResourceInfo, not getNotificationName. The login-success notification +# lives on UserSessions, whose getNotificationName (0x146de19a0) is clean, +# unmutated code with a 12-entry jump table at 0x141b03f70: +# 1 UserSessionExtendedDataUpdate 2 UserAdded 3 UserRemoved +# 5 UserUpdated 8 UserAuthenticated 9 UserUnauthenticated +# 12 ServerDraining + +# CGID (connectionGroupObjectId) is an ObjectId triple. heat2's OBJID encoding +# is UNVERIFIED on the wire and a wrong encoding desynchronises the whole TDF +# parse, whereas an ABSENT member simply keeps its client-side default. So we +# omit it. Flip this once OBJID is confirmed against a real capture. +EMIT_OBJID_FIELDS = False + + +def user_session_login_info_fields(sess: Session, now: int) -> "OrderedDict": + """Blaze::UserSessionLoginInfo @0x14486f920 -- 16 members. This is a + SUPERSET of UserLoginInfo with the persona fields flattened in rather than + nested. KEY must be byte-identical to LoginResponse.SESS.KEY.""" + f = OrderedDict([ + ("1CON", (INT, 0)), # isFirstConsoleLogin + ("ALOC", (INT, sess.account_locale)), # accountLocale (echo client's) + ("BUID", (INT, USER_ID)), # blazeUserId + ("DSNM", (STRING, PERSONA_NAME)), # displayName + ("FRST", (INT, 0)), # isFirstLogin + ("KEY", (STRING, sess.session_key)), # sessionKey <- SAME string + ("LAST", (INT, now)), # lastAuthenticated + ("LLOG", (INT, now)), # lastLoginDateTime + ("MAIL", (STRING, EMAIL)), # email + ("NASP", (STRING, PERSONA_NAMESPACE)), # must match PreAuthResponse + ("PID", (INT, PERSONA_ID)), # personaId + ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform + ("UID", (INT, USER_ID)), # userId + ("USTP", (INT, USER_SESSION_TYPE)), # userSessionType + ("XREF", (INT, EXT_ID)), # extId + ]) + if EMIT_OBJID_FIELDS: + from heat2 import OBJID + f["CGID"] = (OBJID, (COMP_USERSESSIONS, 1, USER_ID)) + return f + + +def network_qos_data_fields() -> "OrderedDict": + """Blaze::Util::NetworkQosData @0x14486e680 -- 5 members. + NATT = NatType; 0 = OPEN, which is what we want offline.""" + return OrderedDict([ + ("BWHR", (INT, 0)), # bandwidthHostedRate + ("DBPS", (INT, 100000)), # downstream bits/s + ("NAHR", (INT, 0)), # natHostedRate + ("NATT", (INT, 0)), # NatType -> OPEN + ("UBPS", (INT, 100000)), # upstream bits/s + ]) + + +def user_session_extended_data_fields() -> "OrderedDict": + """Blaze::UserSessionExtendedData @0x144870390 -- 12 members. + + TWO FIFA-17-SPECIFIC DELTAS vs the MEC emulators: FIFA HAS `PSLM` + (latencyList) which they lack, and FIFA has `BPS` as a TOP-LEVEL string + member whereas they bury it inside the ADDR union. Follow FIFA's layout. + + ADDR (NetworkAddress union), CVAR (variable) and ULST (list) are + omitted: heat2's UNION/OBJID encodings are unverified and a bad one breaks + the whole parse, while an absent member just keeps its default.""" + return OrderedDict([ + ("BPS", (STRING, "openfut")), # bestPingSiteAlias + ("CTY", (STRING, "US")), # country + ("DMAP", (MAP, (INT, INT, []))), # dataMap map + ("HWFG", (INT, 0)), # hardwareFlags bitfield + ("ISP", (STRING, "OpenFUT")), # iSP + ("PSLM", (LIST, (INT, [0]))), # latencyList <- FIFA-only + ("QDAT", (STRUCT, network_qos_data_fields())), + ("TZ", (STRING, "")), # timeZone + ("UATT", (INT, 0)), # userInfoAttribute + ]) + + +def user_session_extended_data_update_fields() -> "OrderedDict": + """Blaze::UserSessionExtendedDataUpdate @0x1448703e0 -- 3 members.""" + return OrderedDict([ + ("DATA", (STRUCT, user_session_extended_data_fields())), + ("SUBS", (INT, 1)), # subscribed + ("USID", (INT, USER_ID)), # userId + ]) + + +def user_identification_fields(sess: "Session") -> "OrderedDict": + """Blaze::UserIdentification @0x14486ebc0 -- 9 members. + ALOC echoes the session's captured locale so a client that ever sends a + non-enUS PreAuth LANG cannot see two different locales for one session + (UserAuthenticated already uses sess.account_locale).""" + return OrderedDict([ + ("AID", (INT, USER_ID)), # accountId + ("ALOC", (INT, sess.account_locale)), # accountLocale (echo client's) + ("EXBB", (BLOB, b"")), # externalBlob + ("EXID", (INT, EXT_ID)), # externalId + ("ID", (INT, USER_ID)), # blazeId + ("NAME", (STRING, PERSONA_NAME)), # name + ("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace + ("ORIG", (INT, PERSONA_ID)), # originPersonaId + ("PIDI", (INT, PERSONA_ID)), # pidId + ]) + + +def user_data_fields(sess: "Session") -> "OrderedDict": + """Blaze::UserData @0x1448706b0 -- 3 members. Payload of UserAdded (2). + FLGS is a UserDataFlags bitfield; bit0 = online/authenticated.""" + return OrderedDict([ + ("EDAT", (STRUCT, user_session_extended_data_fields())), + ("FLGS", (INT, 3)), + ("USER", (STRUCT, user_identification_fields(sess))), + ]) + + +def build_login_notifications(sess: Session, now: int) -> list: + """The push sequence the client waits on after a successful login.""" + return [ + ("UserAuthenticated", notification( + COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, + encode_tdf(user_session_login_info_fields(sess, now)))), + ("UserSessionExtendedDataUpdate", notification( + COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, + encode_tdf(user_session_extended_data_update_fields()))), + ("UserAdded", notification( + COMP_USERSESSIONS, NOTIFY_USER_ADDED, + encode_tdf(user_data_fields(sess)))), + ] + + +# ================================================ post-login RPC bodies + +def post_auth_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Util::PostAuthResponse @0x144875810 -- TELE, TICK, UROP. + Telemetry/ticker are pointed at a dead local port on purpose: we want the + client to have a well-formed config and then fail to connect quietly rather + than resolve a real EA hostname.""" + tele = OrderedDict([ # GetTelemetryServerResponse (15) + ("ADRS", (STRING, "127.0.0.1")), + ("ANON", (INT, 0)), + ("DISA", (STRING, "")), + ("EDCT", (INT, 0)), + ("FILT", (STRING, "")), + ("LOC", (INT, sess.account_locale)), + ("MINR", (INT, 0)), + ("NOOK", (STRING, "")), + ("PORT", (INT, 9988)), + ("SDLY", (INT, 15000)), + ("SESS", (STRING, sess.session_key)), + ("SKEY", (STRING, "")), + ("SPCT", (INT, 75)), + ("STIM", (STRING, "")), + ("SVNM", (STRING, "telemetry-openfut")), + ]) + tick = OrderedDict([ # GetTickerServerResponse (3) + ("ADRS", (STRING, "127.0.0.1")), + ("PORT", (INT, 8999)), + ("SKEY", (STRING, "")), + ]) + urop = OrderedDict([ # UserOptions (2) + ("TMOP", (INT, 0)), # TelemetryOpt -> out/disabled + ("UID", (INT, USER_ID)), + ]) + return OrderedDict([ + ("TELE", (STRUCT, tele)), + ("TICK", (STRUCT, tick)), + ("UROP", (STRUCT, urop)), + ]) + + +def entitlement_fields(now: int, group=None, tag=None, eid=1) -> "OrderedDict": + """Blaze::Authentication::Entitlement @0x14487d490 -- 16 members. + FUT's client filter onListEntitlements (0x146f27440) keeps a record only if + GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC", TAG non-empty, STAT==1. + PRID/GNAM/TAG must contain no '|' and no '/' (re-serialised as PRID|GNAM|TAG|UCNT/).""" + group = group or ENTITLEMENT_GROUP + tag = tag or ENTITLEMENT_TAG + return OrderedDict([ + ("DEVI", (STRING, "")), # deviceUri + ("GDAY", (STRING, "2016-09-01T00:00:00Z")), # grantDate + ("GNAM", (STRING, group)), # groupName (MUST contain the needle) + ("ID", (INT, eid)), # id + ("ISCO", (INT, 0)), # isConsumable + ("PID", (INT, PERSONA_ID)), # personaId + ("PJID", (STRING, CONTENT_ID)), # projectId (EA offer id) + ("PRCA", (INT, 2)), # productCatalog + ("PRID", (STRING, CONTENT_ID)), # productId + ("STAT", (INT, 1)), # EntitlementStatus -> ACTIVE (MUST be 1) + ("STRC", (INT, 0)), # statusReasonCode + ("TAG", (STRING, tag)), # entitlementTag (MUST be non-empty) + ("TDAY", (STRING, "")), # terminationDate (never) + ("TYPE", (INT, 1)), # EntitlementType + ("UCNT", (INT, 0)), # useCount + ("VER", (INT, 1)), # version + ]) + + +def entitlements_response_fields() -> "OrderedDict": + """Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST. + Emit BOTH groups FUT accepts so byte[entMgr+0x88] "loaded" flag flips.""" + now = int(time.time()) + return OrderedDict([ + ("NLST", (LIST, (STRUCT, [ + entitlement_fields(now, "FIFA17PCBoxContent", "ONLINE_ACCESS", 1), + entitlement_fields(now, "FIFA16PC", "ONLINE_ACCESS", 2), + ]))), + ]) + + +def get_auth_token_response_fields(sess: Session) -> "OrderedDict": + """Blaze::Authentication::GetAuthTokenResponse @0x14487d080 -- 1 member.""" + tok = sess.auth_code or ("OPENFUT-" + sess.session_key[:16]) + return OrderedDict([("AUTH", (STRING, tok))]) + + +def user_settings_response_fields() -> "OrderedDict": + """TODO(verify): Util::userSettingsLoad's response descriptor was not + reflected. Both independent clean-room emulators use a single `DATA` + string, and an unknown-tag payload is ignored rather than fatal, so an empty + DATA is the safe minimum -- the client falls back to defaults.""" + return OrderedDict([("DATA", (STRING, ""))]) + + +def get_lists_response_fields() -> "OrderedDict": + """TODO(verify): AssociationLists::getLists (25/6) response is 3P-only + (GetListsResponse{LMAP: list}). FIFA's list names are NOT + verified -- do not invent them. An EMPTY list is well-formed and means + 'this user has no association lists', which is true offline.""" + return OrderedDict([("LMAP", (LIST, (STRUCT, [])))]) + + +# ================================================================== dispatch + +def extract_service_name(fields) -> str: + """PreAuthRequest.CDAT.SVCN -- echo it back as INST.""" + try: + cdat = fields.get("CDAT") + if cdat and cdat[0] == STRUCT: + svcn = cdat[1].get("SVCN") + if svcn and svcn[0] == STRING and svcn[1]: + return svcn[1] + except Exception: + pass + return "fifa-2017-pc" + + +def find_nested_int(fields, tag: str): + """Depth-first search for an INT member `tag` anywhere in a decoded TDF.""" + if not isinstance(fields, dict): + return None + for k, (t, v) in fields.items(): + if k == tag and t == INT: + return v + if t == STRUCT: + r = find_nested_int(v, tag) + if r is not None: + return r + return None + + +def get_str(fields, tag: str, default: str = "") -> str: + try: + tv = fields.get(tag) + if tv and tv[0] == STRING: + return tv[1] + except Exception: + pass + return default + + +def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list: + """-> list of frames to send back, in order (may be empty).""" + comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"] + + # Transport-level PING frame (msgType 4) -> PING_REPLY (5), empty body. + if mtype == PING: + log(" -> transport PING, answering PING_REPLY (empty)") + return [reply_to(hdr, b"", msg_type=PING_REPLY)] + + if mtype not in (MESSAGE,): + log(" -> msgType %s is not a request; not answering" + % MSGTYPE_NAME.get(mtype, mtype)) + return [] + + # ---------------------------------------------------------------- Util + if comp == COMP_UTIL: + if cmd == CMD_PREAUTH: + sess.service_name = (extract_service_name(fields) + if fields is not None else "fifa-2017-pc") + loc = None + if fields is not None: + loc = find_nested_int(fields, "LANG") + if loc is None: + loc = find_nested_int(fields, "LOC") + if loc: + sess.account_locale = loc + log(" -- client locale 0x%08x captured for ALOC" % loc) + resp = preauth_response_fields(service_name=sess.service_name) + payload = encode_tdf(resp) + log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" + % (sess.service_name, len(payload), heat2.dump(resp))) + return [reply_to(hdr, payload)] + + if cmd == CMD_PING: + resp = ping_response_fields() + log(" -> PingResponse STIM=%d" % resp["STIM"][1]) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_FETCHCLIENTCONFIG: + cfid = get_str(fields or {}, "CFID", "") + resp = fetch_config_response_fields(cfid) + n = len(resp["CONF"][1][2]) + log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s" + % (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)")) + for k, v in resp["CONF"][1][2]: + log(" %-32s = %s" % (k, v)) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_POSTAUTH: + resp = post_auth_response_fields(sess) + log(" -> PostAuthResponse (TELE/TICK/UROP)") + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_SETCLIENTSTATE: + log(" -> setClientState: empty REPLY (no response TDF)") + return [reply_to(hdr, b"")] + + if cmd == CMD_SETCLIENTMETRICS: + log(" -> setClientMetrics: empty REPLY (no response TDF)") + return [reply_to(hdr, b"")] + + if cmd == CMD_USERSETTINGSLOAD: + log(" -> UserSettingsResponse (empty DATA; TODO verify descriptor)") + return [reply_to(hdr, encode_tdf(user_settings_response_fields()))] + + if cmd == CMD_USERSETTINGSSAVE: + log(" -> userSettingsSave: empty REPLY (accepted, discarded)") + return [reply_to(hdr, b"")] + + if cmd == 0x15: # fetchQosConfig -> proven-good QosConfigInfo body + log(" -> QosConfigInfo (fetchQosConfig)") + return [reply_to(hdr, encode_tdf(qos_config()))] + + # ------------------------------------------------------ Authentication + if comp == COMP_AUTH: + if cmd == CMD_LOGIN: + sess.auth_code = get_str(fields or {}, "AUTH", "") + sess.logged_in = True + sess.login_time = int(time.time()) + log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus " + "validation -- forged offline session)" % sess.auth_code) + resp = login_response_fields(sess) + payload = encode_tdf(resp) + log(" -> LoginResponse (%d bytes):\n%s" + % (len(payload), heat2.dump(resp))) + notifs = build_login_notifications(sess, sess.login_time) + out = [] + if NOTIFY_BEFORE_LOGIN_REPLY: + for name, fr in notifs: + log(" ~> NOTIFY 0x7802/0x%04x %s" % + (parse_fire2_header(fr)["command"], name)) + out.append(fr) + out.append(reply_to(hdr, payload)) + else: + out.append(reply_to(hdr, payload)) + for name, fr in notifs: + log(" ~> NOTIFY 0x7802/0x%04x %s" % + (parse_fire2_header(fr)["command"], name)) + out.append(fr) + return out + + if cmd in (CMD_TRUSTEDLOGIN, CMD_EXPRESSLOGIN): + # Same forged session; the request fields differ but we ignore them. + sess.logged_in = True + sess.login_time = int(time.time()) + log(" == Authentication::%s -> same forged LoginResponse" + % AUTH_CMDS.get(cmd, cmd)) + out = [reply_to(hdr, encode_tdf(login_response_fields(sess)))] + out += [fr for _, fr in build_login_notifications(sess, + sess.login_time)] + return out + + if cmd == CMD_LOGOUT: + # An empty REPLY was already correct on the wire (logout has NO + # request and NO response TDF -- no LogoutRequest/LogoutResponse + # exists in the client's type index). But receiving this at all + # means the client decided it had no credential BEFORE Blaze auth. + # NOTE: logout here is NORMAL, not a failure. The OSDK state table + # (0x147159154-0x147159680) orders Connect 400 -> Logout 500 -> + # VersionCheck 700 -> PCLogin 800, so LoginStateLogout is the routine + # "drop any stale session" step BEFORE PCLogin/GetAuthCode. Only + # treat it as a problem if we NEVER subsequently see login (1/0x0A). + if sess.logged_in: + log(" -- Authentication::logout (1/0x46) after a login " + "(session teardown; expected)") + else: + log(" -- Authentication::logout (1/0x46), no login yet -- " + "normal LoginStateLogout (state 500). If GetAuthCode / login " + "(1/0x0A) never follow, layer 1 (LSX :4216) or blazeSdkClientId " + "is the blocker; see lsx_responder_v2.py + blazesdk_config().") + return [reply_to(hdr, b"")] + + if cmd in (CMD_LISTUSERENTITLEMENTS2, 0x20, 0x30, 0x27): + # 0x1D listUserEntitlements2 / 0x20 listEntitlements / + # 0x30 listPersonaEntitlements2 / 0x27 grantEntitlement2 -- all return + # the ONLINE_ACCESS entitlement so the client sees it however it asks. + log(" -> Entitlements{NLST:[%s / offer %s / ACTIVE]} (cmd 0x%02x)" + % (ENTITLEMENT_TAG, CONTENT_ID, cmd)) + return [reply_to(hdr, encode_tdf(entitlements_response_fields()))] + + if cmd == CMD_GETAUTHTOKEN: + resp = get_auth_token_response_fields(sess) + log(" -> GetAuthTokenResponse AUTH=%r" % resp["AUTH"][1]) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_GETACCOUNT: + # BLOCKER FIX: the RPC behind the "Unable to retrieve account + # information" popup. Forged AccountInfo, identity-consistent. + resp = account_info_fields(sess, int(time.time())) + payload = encode_tdf(resp) + log(" == Authentication::getAccount (1/0x1E) -> AccountInfo " + "UID=%d MAIL=%r ASRC=%r (%d bytes)" + % (USER_ID, EMAIL, PERSONA_NAMESPACE, len(payload))) + return [reply_to(hdr, payload)] + + if cmd == CMD_GETPERSONA: + resp = get_persona_response_fields(int(time.time())) + log(" -> GetPersonaResponse (1/0x5A) persona %d/%r" + % (PERSONA_ID, PERSONA_NAME)) + return [reply_to(hdr, encode_tdf(resp))] + + if cmd == CMD_LISTPERSONAS: + resp = list_personas_response_fields(int(time.time())) + log(" -> ListPersonasResponse (1/0x64) [%s]" % PERSONA_NAME) + return [reply_to(hdr, encode_tdf(resp))] + + # ------------------------------------------------------- UserSessions + if comp == COMP_USERSESSIONS and cmd == CMD_UPDATENETWORKINFO: + log(" -> updateNetworkInfo: empty REPLY, then re-push " + "UserSessionExtendedDataUpdate") + return [ + reply_to(hdr, b""), + notification(COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE, + encode_tdf(user_session_extended_data_update_fields())), + ] + + # --------------------------------------------------- AssociationLists + if comp == COMP_ASSOCLISTS and cmd == CMD_GETLISTS: + log(" -> GetListsResponse{LMAP: []} (TODO verify FIFA's list names)") + return [reply_to(hdr, encode_tdf(get_lists_response_fields()))] + + # ---------------------------------------------------------------- TODO + # Still unimplemented, in the order they are expected to show up: + # Util::fetchQosConfig (9/0x15) -> QosConfigInfo (see qos_config) + # Util::localizeStrings (9/4) -> echo the requested ids + # Messaging::fetchMessages (15/2) -> empty list + # Stats / GameReporting / GameManager -> FUT-mode specific, later + # Authentication::getTermsOfServiceContent (1/0xF6) and + # getPrivacyPolicyContent (1/0x2F) -> only reached if NTOS != 0 + # Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED + # (three clean-room sources disagree: header[14:16] vs metadata ERRC vs + # payload CNTX/ERRC) -- do not emit one until it is verified on the wire. + # ---------------------------------------------------------------------- + + # ----------------------------------------------------------- CensusData + # subscribeToCensusDataUpdates (0x000A/0x0005): the client re-arms a resend + # timer with delay = (CNP+NTMT)/1000; an EMPTY reply -> delay 0 -> ~30/s storm + # that hangs the FUT loading screen. Return non-zero TimeValues so it settles + # to one re-subscribe per 120s. (COMPONENT_000A_PLAN.md, wire-verified.) + 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): Response{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()))] + + if REPLY_EMPTY_TO_UNKNOWN: + log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not " + "hang (all fields fall back to client-side defaults)" + % rpc_name(comp, cmd, mtype)) + return [reply_to(hdr, b"")] + + log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd, mtype)) + return [] + + +# ============================================================= blaze server + +def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool: + """Fill `buf` to at least n bytes. False on clean EOF / short close.""" + while len(buf) < n: + try: + chunk = sock.recv(65536) + except socket.timeout: + return False + if not chunk: + return False + buf += chunk + return True + + +_frame_counter = [0] + + +def blaze_handle(raw: socket.socket, addr) -> None: + log("*** BLAZE CONNECT from %s ***" % (addr,)) + sess = Session() + log(" session key minted: %s" % sess.session_key) + buf = bytearray() + raw.settimeout(300) + try: + while True: + if not recv_exactly(raw, FIRE2_HDR, buf): + break + hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR])) + total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"] + if hdr["payload_len"] > 4 * 1024 * 1024: + log("BLAZE %s: absurd payload_len %d, dropping connection\n%s" + % (addr, hdr["payload_len"], hexdump(bytes(buf[:64])))) + break + if not recv_exactly(raw, total, buf): + log("BLAZE %s: EOF mid-frame (want %d, have %d)" + % (addr, total, len(buf))) + break + + frame = bytes(buf[:total]) + del buf[:total] + metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]] + payload = frame[FIRE2_HDR + hdr["metadata_len"]:] + + _frame_counter[0] += 1 + n = _frame_counter[0] + log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x " + "meta=%dB payload=%dB" + % (n, rpc_name(hdr["component"], hdr["command"], hdr["msg_type"]), + MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), + hdr["msg_num"], hdr["user_index"], hdr["options"], + hdr["metadata_len"], hdr["payload_len"])) + log("RX #%d HEX:\n%s" % (n, hexdump(frame))) + if metadata: + log("RX #%d METADATA:\n%s" % (n, hexdump(metadata))) + if DUMP_FRAMES: + try: + os.makedirs(RXDIR, exist_ok=True) + fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin" + % (n, hdr["component"], hdr["command"])) + with open(fn, "wb") as fh: + fh.write(frame) + log("RX #%d saved -> %s" % (n, fn)) + except Exception as e: + log("RX #%d save failed: %s" % (n, e)) + + fields = None + if payload: + try: + fields = decode_tdf(payload) + log("RX #%d TDF:\n%s" % (n, heat2.dump(fields))) + except Exception as e: + log("RX #%d TDF DECODE FAILED: %s" % (n, e)) + else: + log("RX #%d TDF: (empty payload)" % n) + + try: + outs = dispatch(hdr, fields, payload, sess) + except Exception as e: + log("RX #%d DISPATCH ERROR: %r" % (n, e)) + outs = [] + + for k, out in enumerate(outs): + raw.sendall(out) + ohdr = parse_fire2_header(out) + log("TX #%d.%d %s msgType=%s msgNum=%d %dB total (%d payload)" + % (n, k, + rpc_name(ohdr["component"], ohdr["command"], + ohdr["msg_type"]), + MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), + ohdr["msg_num"], len(out), ohdr["payload_len"])) + log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024))) + except ConnectionResetError: + log("BLAZE %s: connection reset by client" % (addr,)) + except Exception as e: + log("BLAZE %s ERR: %r" % (addr, e)) + finally: + try: + raw.close() + except Exception: + pass + log("BLAZE %s: closed" % (addr,)) + + +# ======================================================= redirector (TLS) + +def build_redirect_response() -> bytes: + # ServerInstanceInfo.address is a ServerAddress union -> Heat2 XML union is + #
... member="0" = ipAddress variant + # {hostname, ip(uint32 decimal), port(uint16)}. + body = ( + '\n' + '\n' + '\t
\n' + '\t\t\n' + f'\t\t\t{BLAZE_IP_STR}\n' + f'\t\t\t{BLAZE_IP_U32}\n' + f'\t\t\t{BLAZE_PORT}\n' + '\t\t\n' + '\t
\n' + '\t0\n' + '\t\n' + '\t0\n' + '
\n' + ) + b = body.encode() + hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n" + f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode() + return hdr + b + + +def make_tls_context(): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERT, KEY) + ctx.minimum_version = ssl.TLSVersion.TLSv1 + ctx.set_ciphers("ALL:@SECLEVEL=0") + return ctx + + +_ctx = [None] + + +def redir_handle(raw: socket.socket, addr) -> None: + try: + tls = _ctx[0].wrap_socket(raw, server_side=True) + except ssl.SSLError as e: + log("REDIR REJECTED %s: %s" % (addr, e)) + raw.close() + return + log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0])) + try: + tls.settimeout(8) + req = b"" + while b"\r\n\r\n" not in req: + c = tls.recv(4096) + if not c: + break + req += c + if b"content-length:" in req.lower(): + head, _, rest = req.partition(b"\r\n\r\n") + cl = int([l.split(b":")[1] for l in head.split(b"\r\n") + if l.lower().startswith(b"content-length")][0]) + while len(rest) < cl: + c = tls.recv(4096) + if not c: + break + rest += c + req = head + b"\r\n\r\n" + rest + line0 = req.split(b"\r\n", 1)[0].decode(errors="replace") + log("REDIR REQ %s: %s" % (addr, line0)) + resp = build_redirect_response() + tls.sendall(resp) + log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d" + % (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT)) + time.sleep(0.3) + tls.close() + except Exception as e: + log("REDIR ERR %s: %s" % (addr, e)) + + +# ================================================ Nucleus OAuth stub (plain) +# +# LoginStateMachineImpl (string cluster 0x14389fd50-0x14389fef8) builds +# "/connect/token", POSTs "grant_type=client_credentials" +# with a "NEXUS_S2S " authorization header, and scrapes '"access_token" : "' +# out of the reply body. We advertise nucleusConnect = this listener, so the +# client can never reach accounts.ea.com. Note the exact spacing in the JSON: +# the client searches for the literal '"access_token" : "'. + +def nucleus_handle(raw: socket.socket, addr) -> None: + try: + raw.settimeout(10) + req = b"" + while b"\r\n\r\n" not in req and len(req) < 65536: + c = raw.recv(4096) + if not c: + break + req += c + head, _, rest = req.partition(b"\r\n\r\n") + line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else "" + log("NUCLEUS REQ %s: %s" % (addr, line0)) + if head: + log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace")) + if rest: + log("NUCLEUS BODY: %r" % rest[:512]) + + token = "OPENFUT_" + "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(40)) + # Spacing matters: the client greps for the literal '"access_token" : "'. + body = ('{\n "access_token" : "%s",\n "token_type" : "Bearer",\n' + ' "expires_in" : 14400,\n "id_token" : "%s",\n' + ' "refresh_token" : "%s"\n}\n' + % (token, token, token)).encode() + out = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + b"Cache-Control: no-store\r\nContent-Length: " + + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + raw.sendall(out) + log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token)) + except Exception as e: + log("NUCLEUS ERR %s: %s" % (addr, e)) + finally: + try: + raw.close() + except Exception: + pass + + +# ================================================================== serve + +def serve(port: int, handler, name: str) -> None: + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((HOST, port)) + s.listen(16) + log("%s listening on %s:%d" % (name, HOST, port)) + while True: + c, a = s.accept() + threading.Thread(target=handler, args=(c, a), daemon=True).start() + + +# ================================================================== selftest + +def _check_roundtrip(label: str, fields) -> bytes: + payload = encode_tdf(fields) + back = decode_tdf(payload) + again = encode_tdf(back) + assert again == payload, "%s: re-encode differs" % label + assert list(back.keys()) == sorted(fields.keys(), key=heat2.tag_key), \ + "%s: tag order %r" % (label, list(back.keys())) + return payload + + +def _selftest() -> None: + print("=" * 72) + print("blaze_responder_v3 selftest") + print("=" * 72) + + sess = Session() + sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0" + sess.account_locale = 0x656E5553 + now = 1469000000 + + # ---- 1. preAuth still round-trips (regression guard vs v2) + pre = preauth_response_fields() + p = _check_roundtrip("PreAuthResponse", pre) + fr = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, p) + assert fr[13] == 0x20, fr[13] + assert list(decode_tdf(p).keys()) == [ + "ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST", "MAID", "MINR", + "NASP", "PILD", "PLAT", "QOSS", "RSRC", "SVER"] + print("[ok] PreAuthResponse %4d payload bytes" % len(p)) + + # ---- 2. fetchClientConfig per CFID + for cfid in ["BlazeSDK", "OSDK_CORE", "OSDK_CLIENT", "OSDK_NUCLEUS", + "OSDK_WEBOFFER", "OSDK_ABUSE_REPORTING", + "OSDK_XMS_ABUSE_REPORTING", "IdentityParams", "TOTALLY_UNKNOWN"]: + f = fetch_config_response_fields(cfid) + pb = _check_roundtrip("FetchConfigResponse/" + cfid, f) + back = decode_tdf(pb) + assert list(back.keys()) == ["CONF"], back.keys() + kt, vt, items = back["CONF"][1] + assert (kt, vt) == (STRING, STRING) + assert items == client_config_for(cfid), cfid + print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes" + % (cfid, len(items), len(pb))) + assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []" + assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \ + "unknown CFID must still carry a CONF field (empty map, not empty frame)" + + # ---- 3. LoginResponse + lr = login_response_fields(sess) + lp = _check_roundtrip("LoginResponse", lr) + back = decode_tdf(lp) + assert list(back.keys()) == ["ANON", "NTOS", "SESS", "SPAM", "UNDR"], back.keys() + assert "CNTX" not in back and "ERRC" not in back and "SKEY" not in back + s = back["SESS"][1] + assert list(s.keys()) == ["1CON", "BUID", "FRST", "KEY", "LLOG", "MAIL", + "PDTL", "UID"], list(s.keys()) + assert s["KEY"][1] == sess.session_key + assert s["BUID"][1] == USER_ID != 0 and s["UID"][1] == USER_ID + d = s["PDTL"][1] + assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \ + list(d.keys()) + assert d["PID"][1] == PERSONA_ID == 33068179 + assert d["DSNM"][1] == PERSONA_NAME == "CAGE" + assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0 + lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp) + lh = parse_fire2_header(lfr) + assert (lh["component"], lh["command"], lh["msg_type"]) == (1, 0x0A, REPLY) + assert lfr[13] == 0x20 + print("[ok] LoginResponse %4d payload bytes, hdr %s" + % (len(lp), lfr[:16].hex(" "))) + + # ---- 4. UserAuthenticated notification + ua = user_session_login_info_fields(sess, now) + up = _check_roundtrip("UserSessionLoginInfo", ua) + nb = decode_tdf(up) + assert list(nb.keys()) == ["1CON", "ALOC", "BUID", "DSNM", "FRST", "KEY", + "LAST", "LLOG", "MAIL", "NASP", "PID", "PLAT", + "UID", "USTP", "XREF"], list(nb.keys()) + assert nb["KEY"][1] == sess.session_key, "notif KEY must match SESS.KEY" + assert nb["PID"][1] == PERSONA_ID and nb["DSNM"][1] == PERSONA_NAME + assert nb["NASP"][1] == PERSONA_NAMESPACE + nfr = notification(COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, up) + nh = parse_fire2_header(nfr) + assert (nh["component"], nh["command"]) == (0x7802, 0x0008), nh + assert nh["msg_type"] == NOTIFICATION and nh["msg_num"] == 0 + assert nfr[13] == 0x40, nfr[13] + print("[ok] UserAuthenticated notif %4d payload bytes, hdr %s" + % (len(up), nfr[:16].hex(" "))) + + # ---- 5. the other two notifications + post-login RPCs + for label, f in [ + ("UserSessionExtendedDataUpdate", user_session_extended_data_update_fields()), + ("UserAdded (UserData)", user_data_fields(sess)), + ("PostAuthResponse", post_auth_response_fields(sess)), + ("Entitlements", entitlements_response_fields()), + ("GetAuthTokenResponse", get_auth_token_response_fields(sess)), + ("UserSettingsResponse", user_settings_response_fields()), + ("GetListsResponse", get_lists_response_fields()), + ("PingResponse", ping_response_fields()), + ]: + b = _check_roundtrip(label, f) + print("[ok] %-30s %4d payload bytes" % (label, len(b))) + assert list(ping_response_fields().keys()) == ["STIM"], \ + "PingResponse must carry ONLY STIM (TIME is MEC's, not FIFA 17's)" + + # ---- 5b. BLOCKER-FIX responses: AccountInfo / getPersona / listPersonas + ai = account_info_fields(sess, now) + aip = _check_roundtrip("AccountInfo", ai) + aib = decode_tdf(aip) + assert list(aib.keys()) == ["AMU", "ASRC", "CO", "DOB", "DTCR", "GOPT", + "LATH", "LN", "MAIL", "PML", "RC", "STAS", + "STAT", "TPOT", "UDU", "UID"], list(aib.keys()) + assert len(aib) == 16 + # 8 strings / 8 ints exactly, as reversed from the descriptor + strs = [k for k, (t, v) in aib.items() if t == STRING] + ints = [k for k, (t, v) in aib.items() if t == INT] + assert strs == ["ASRC", "CO", "DOB", "DTCR", "LATH", "LN", "MAIL", "PML"], strs + assert ints == ["AMU", "GOPT", "RC", "STAS", "STAT", "TPOT", "UDU", "UID"], ints + # identity consistency vs LoginResponse.SESS + assert aib["UID"][1] == USER_ID and aib["MAIL"][1] == EMAIL + assert aib["ASRC"][1] == PERSONA_NAMESPACE == "cem_ea_id" + assert aib["STAS"][1] == 1 and aib["STAT"][1] == 2 # ACTIVE / VERIFIED + afr = fire2(COMP_AUTH, CMD_GETACCOUNT, 9, REPLY, aip) + assert parse_fire2_header(afr)["command"] == 0x1E + print("[ok] AccountInfo (getAccount 1/0x1E) %4d payload bytes" % len(aip)) + + gp = get_persona_response_fields(now) + gpp = _check_roundtrip("GetPersonaResponse", gp) + gpb = decode_tdf(gpp) + assert list(gpb.keys()) == ["PINF", "UID"], list(gpb.keys()) + pinf = gpb["PINF"][1] + assert list(pinf.keys()) == ["DSNM", "DTCR", "LADT", "NSNM", "PID", + "STAS", "STRC"], list(pinf.keys()) + assert pinf["PID"][1] == PERSONA_ID and pinf["DSNM"][1] == PERSONA_NAME + assert gpb["UID"][1] == USER_ID + print("[ok] GetPersonaResponse (1/0x5A) %4d payload bytes" % len(gpp)) + + lpz = list_personas_response_fields(now) + lpp = _check_roundtrip("ListPersonasResponse", lpz) + lpb = decode_tdf(lpp) + assert list(lpb.keys()) == ["PINF"], list(lpb.keys()) + et, items = lpb["PINF"][1] + assert et == STRUCT and len(items) == 1 + assert items[0]["PID"][1] == PERSONA_ID + print("[ok] ListPersonasResponse (1/0x64) %4d payload bytes" % len(lpp)) + + # ---- 5c. dispatch actually returns them (no fall-through to empty reply) + class _H(dict): + pass + for command, want_kind in ((CMD_GETACCOUNT, "AccountInfo"), + (CMD_GETPERSONA, "GetPersona"), + (CMD_LISTPERSONAS, "ListPersonas")): + h = {"component": COMP_AUTH, "command": command, "msg_type": MESSAGE, + "msg_num": 1, "user_index": 0, "options": 0} + outs = dispatch(h, OrderedDict(), b"", sess) + assert len(outs) == 1, (command, outs) + oh = parse_fire2_header(outs[0]) + assert oh["payload_len"] > 0, "%s produced an EMPTY reply" % want_kind + print("[ok] dispatch 1/%#04x -> %s (%d payload bytes)" + % (command, want_kind, oh["payload_len"])) + + # ---- 6. the full login burst, framed + frames = [fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)] + \ + [fr for _, fr in build_login_notifications(sess, now)] + blob = b"".join(frames) + seen, i = [], 0 + while i < len(blob): + h = parse_fire2_header(blob[i:i + 16]) + tot = 16 + h["metadata_len"] + h["payload_len"] + seen.append((h["component"], h["command"], h["msg_type"])) + decode_tdf(blob[i + 16 + h["metadata_len"]:i + tot]) + i += tot + assert seen == [(0x0001, 0x000A, REPLY), + (0x7802, 0x0008, NOTIFICATION), + (0x7802, 0x0001, NOTIFICATION), + (0x7802, 0x0002, NOTIFICATION)], seen + print("[ok] login burst re-framed: %d frames / %d bytes" + % (len(frames), len(blob))) + + print() + print("---- LoginResponse -------------------------------------------------") + print(heat2.dump(lr)) + print() + print("---- NOTIFY 0x7802/0x0008 UserAuthenticated ------------------------") + print(heat2.dump(ua)) + print() + print("ALL SELFTESTS PASSED") + + +# ================================================================== main + +if __name__ == "__main__": + if "--selftest" in sys.argv: + _selftest() + raise SystemExit(0) + + log("=== RESPONDER v3 START (redir %d / blaze %d%s) ===" + % (REDIR_PORT, BLAZE_PORT, + (" / nucleus %d" % NUCLEUS_PORT) if NUCLEUS_STUB_ENABLED else "")) + log(" persona %d / %r namespace %r entitlement %s (offer %s)" + % (PERSONA_ID, PERSONA_NAME, PERSONA_NAMESPACE, ENTITLEMENT_TAG, + CONTENT_ID)) + log(" REMINDER: layer 1 first -- start lsx_responder.py BEFORE FIFA 17, " + "or the client sends logout (1/0x46) instead of login (1/0x0A).") + _ctx[0] = make_tls_context() + threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"), + daemon=True).start() + if NUCLEUS_STUB_ENABLED: + threading.Thread(target=serve, + args=(NUCLEUS_PORT, nucleus_handle, "NUCLEUS"), + daemon=True).start() + serve(REDIR_PORT, redir_handle, "REDIR") diff --git a/fifa17-recon/tools/heat2.py b/fifa17-recon/tools/heat2.py new file mode 100644 index 0000000..9ba15a2 --- /dev/null +++ b/fifa17-recon/tools/heat2.py @@ -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 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) diff --git a/fifa17-recon/tools/login_dump/BRIEF2.md b/fifa17-recon/tools/login_dump/BRIEF2.md new file mode 100644 index 0000000..2904a08 --- /dev/null +++ b/fifa17-recon/tools/login_dump/BRIEF2.md @@ -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] [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 + `` 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. diff --git a/fifa17-recon/tools/login_dump/BRIEF3.md b/fifa17-recon/tools/login_dump/BRIEF3.md new file mode 100644 index 0000000..418fa4c --- /dev/null +++ b/fifa17-recon/tools/login_dump/BRIEF3.md @@ -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 `` + 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. diff --git a/fifa17-recon/tools/login_dump/BRIEF4.md b/fifa17-recon/tools/login_dump/BRIEF4.md new file mode 100644 index 0000000..cf46d6e --- /dev/null +++ b/fifa17-recon/tools/login_dump/BRIEF4.md @@ -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"). diff --git a/fifa17-recon/tools/login_dump/BRIEF5.md b/fifa17-recon/tools/login_dump/BRIEF5.md new file mode 100644 index 0000000..b2160e0 --- /dev/null +++ b/fifa17-recon/tools/login_dump/BRIEF5.md @@ -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. diff --git a/fifa17-recon/tools/login_dump/BRIEF6.md b/fifa17-recon/tools/login_dump/BRIEF6.md new file mode 100644 index 0000000..444ac9c --- /dev/null +++ b/fifa17-recon/tools/login_dump/BRIEF6.md @@ -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. diff --git a/fifa17-recon/tools/login_dump/BRIEF7.md b/fifa17-recon/tools/login_dump/BRIEF7.md new file mode 100644 index 0000000..4df5789 --- /dev/null +++ b/fifa17-recon/tools/login_dump/BRIEF7.md @@ -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. diff --git a/fifa17-recon/tools/login_dump/COMPONENT_000A_PLAN.md b/fifa17-recon/tools/login_dump/COMPONENT_000A_PLAN.md new file mode 100644 index 0000000..3fc7a78 --- /dev/null +++ b/fifa17-recon/tools/login_dump/COMPONENT_000A_PLAN.md @@ -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 1–4; 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('` `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. diff --git a/fifa17-recon/tools/login_dump/CONNECT_PLAN.md b/fifa17-recon/tools/login_dump/CONNECT_PLAN.md new file mode 100644 index 0000000..fa4d186 --- /dev/null +++ b/fifa17-recon/tools/login_dump/CONNECT_PLAN.md @@ -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)`), 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 `` is accepted only if its `sender` attribute **byte-equals the `recipient` the +client put on the matching ``**. 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 `` to the +`` 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 +``, 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 `` 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 `` children**, because we answer +``. + +### 1.4 On the wire — the smoking gun + +`captures/lsx/full_origin_init_session.log`: + +``` +id=1 (plaintext handshake) +id=2 -> we reply sender="EbisuSDK" MATCH ✔ +id=3 -> we reply sender="EbisuSDK" MISMATCH ✘ +id=4 -> we reply sender="" MATCH ✔ +id=5 -> 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,; call 0x1470e4870; mov rdx,rax; +call `: + +``` +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 +`` 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']*\brecipient="([^"]*)"[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>') + ``` + (`recipient` precedes `id` in every captured frame — ids 1..14 all have + ``. If you prefer robustness, keep the old `REQ_RE` and add a + separate `RECIP_RE = re.compile(r']*\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'<{body}/>' + + 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 `` (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 `` **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 + + + + ...one line per facility, Name == the Facility enum name... + + +``` + +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 +`` 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('` 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 `` +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 ``.** 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('= 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`. diff --git a/fifa17-recon/tools/login_dump/ENQUEUE_PLAN.md b/fifa17-recon/tools/login_dump/ENQUEUE_PLAN.md new file mode 100644 index 0000000..5833c3d --- /dev/null +++ b/fifa17-recon/tools/login_dump/ENQUEUE_PLAN.md @@ -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 ``. 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`, **transmits it** (`call [0x148e219f8]`), + registers the pending future in the SDK reqId-keyed map (`0x1470e6540`), writes future→out, reqId→out. + *This is the point `` 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 `` 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(' 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(' {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=") + 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(' {NODE:#x}. Watch /tmp/lsx.log for .") + +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 `` 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 `` 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 `` 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. diff --git a/fifa17-recon/tools/login_dump/FUT_LOADING_PLAN.md b/fifa17-recon/tools/login_dump/FUT_LOADING_PLAN.md new file mode 100644 index 0000000..282d08f --- /dev/null +++ b/fifa17-recon/tools/login_dump/FUT_LOADING_PLAN.md @@ -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: ; 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 }`, 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 ` + "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. diff --git a/fifa17-recon/tools/login_dump/FUT_UTAS_PLAN.md b/fifa17-recon/tools/login_dump/FUT_UTAS_PLAN.md new file mode 100644 index 0000000..67ca6ec --- /dev/null +++ b/fifa17-recon/tools/login_dump/FUT_UTAS_PLAN.md @@ -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 45–47 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_`** — loop @ `0x180124390`, `edi < 0x30` (48), `rbx` walks the module + table's NAME field, key format string `0x18021fa70`. +3. **`FUT_RS4_URL_`** — 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/") +``` + +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/") +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_"] key fmt @0x18021fa70 +# 3. cfg["FUT_RS4_URL_"] 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//…`). + +```python +#!/usr/bin/env python3 +"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room). + +CardsDLL resolves every RS4 endpoint to + path, where comes from +FUT_RS4_APIURL_ / FUT_RS4_URL_ (blaze_responder_v3b.py) and path is +moduleTable[i] with %s -> "game/". Auth is POST 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//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//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//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) = +``` + +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.2–1.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 2–3 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` → `/pow/mm/`, `/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 0–124) + one terminator at idx 125 (`moduleIdx = 48`, empty name).| + +## Appendix B — the 125 call tags (for `FUT_RS4_URL_` 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.)* diff --git a/fifa17-recon/tools/login_dump/QOS_CONNECT_PLAN.md b/fifa17-recon/tools/login_dump/QOS_CONNECT_PLAN.md new file mode 100644 index 0000000..cab1e0f --- /dev/null +++ b/fifa17-recon/tools/login_dump/QOS_CONNECT_PLAN.md @@ -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`): + +```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 `` 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 `` 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= +f=open("/proc/%d/mem"%PID,"rb") +def u32(va): f.seek(va); return struct.unpack(" 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` 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. +``` diff --git a/fifa17-recon/tools/login_dump/UNDERAGE_PLAN.md b/fifa17-recon/tools/login_dump/UNDERAGE_PLAN.md new file mode 100644 index 0000000..6b290fa --- /dev/null +++ b/fifa17-recon/tools/login_dump/UNDERAGE_PLAN.md @@ -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 ``. 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 `. The classifier at `0x14717d5f3` passes +`mov edx,0x90c3a20f; lea edx,[rdx-0x2b6134a8]` = **`0x65626d67` = `'ebmg'`** (the Ebisu/Blaze-auth +component; the sibling site `0x14717d7b0` uses `0x636e6e63` = `'cnnc'`, the connMgr). + +Live walk I performed on pid 39211: + +``` +mgr = *[0x144b86bf8] = 0x43c46c70 (vptr 0x143959168) +map = [mgr+0x358] = 0x43d15f38 +buckets= [map+0x50] = 0x43d15fd0 , nbuckets = [map+0x58] = 193 +0x65626d67 % 193 -> node 0x43d16880 -> value +ebmg = 0x43d317a8 (vptr 0x143972550) + vt[0x60] = 0x147237c30 + vt[0x68] = 0x147237350 <- GetAuthCode(blazeServerClientId) + vt[0x80] = 0x147237430 <- GetLastError +``` + +`0x147237430` live bytes = `8b 81 50 09 00 00 c3` = **`mov eax,[rcx+0x950]; ret`**. +So the classifier's error input is literally the field **`ebmg+0x950`**, and `rbp` is **`ebmg+0x948`**. + +### 1.3 The writer of `ebmg+0x950` — full disassembly of `vt[0x68]` (live) + +``` +147237350 push/sub…; xor edi,edi ; rdi = 0 +14723735f mov [rsp+0x48],rdi ; out_ptr = 0 +147237364 mov [rsp+0x50],rdi ; out_len = 0 +147237369 call 0x1471995b0 ; = mov rax,[0x144b86bf8] +147237374 call [rdx+0x188] ; = mov rax,[rcx+0x360] (the CONF holder) +14723737a mov rcx,[rax+0x750] +147237381 test rcx,rcx +147237384 je 0x1472373e0 ; EARLY-OUT -- does NOT touch +0x950 +14723738e lea rdx,[rip+…] # 0x143972690 = "blazeServerClientId" +14723739a call [rax+0x48] ; cfg->GetString(key) +14723739d call 0x1470da6d0 ; OriginGetDefaultUser +1472373b9 call 0x1470db3c0 ; OriginRequestAuthCodeSync +1472373be mov DWORD PTR [rbx+0x950],eax ; *** THE ERROR WRITE *** +1472373c4 test eax,eax ; jne 0x1472373e0 ; err != 0 -> no cache +1472373c8 mov rax,[rsp+0x48] ; test rax,rax ; je ; out_ptr == 0 -> no cache +1472373d2 cmp QWORD PTR [rsp+0x50],rdi ; je ; out_len == 0 -> no cache <<< WE DIE HERE +1472373d9 mov QWORD PTR [rbx+0x948],rax ; cache the code +1472373e0 mov rax,[rbx+0x948] ; ret ; returns rbp for the classifier +``` + +`0x1470db3c0`'s logger string is `0x143936180 = "OriginRequestAuthCodeSync"`; its body is +`call 0x1470e2840` (SDK-ready gate) → `call 0x1470e67f0` (the impl). + +The sibling `0x147237440` is identical but keys on `0x1439726a8 = "blazeSdkClientId"` and caches into +the `char[0x400]` at `ebmg+0x140`, writing the same error slot at `0x1472374c5`. + +**Only three sites in the entire image write `+0x950`:** the ctor `0x147236d1b` +(`mov DWORD PTR [r14+0x950],ebp`, adjacent to `mov [r14+0x948],rbp` at `0x147236d14`; `rbp` is the +ctor's zero register — it is also written into the bool at `+0x110` via `mov [r14+0x110],bpl`), and +the two `mov [..+0x950],eax` immediately after `call 0x1470db3c0`. + +**Therefore `ebmg+0x950` can hold nothing but `OriginRequestAuthCodeSync`'s return value.** +That function's reachable returns are `0`, `0xa2000004` (INVALID_ARGUMENT), `0xa2000003` +(INVALID_USER, the gate we already cleared), or the LSXRequest's own error field via +`call [rax+0x40]`. `0xa2000012` is not among them. + +### 1.4 `0xa2000012` is a decoder-table constant — it is not, and cannot be, our value + +Live reads: + +``` +codes[] @ 0x144340ef0 (uint32[81]) ; codes[21] @ 0x144340f44 = 0xa2000012 +strings[] @ 0x144340750 (const char*[81][3], stride 0x18) + strings[21] @ 0x144340948 = { "ORIGIN_ERROR_AGE_RESTRICTED", + "ORIGIN_LEVEL_2", + "The item has age restrictions." } +``` + +Sanity anchors from the same arrays: `codes[6]=0xa2000003 -> ORIGIN_ERROR_INVALID_USER`, +`codes[7]=0xa2000004 -> ORIGIN_ERROR_INVALID_ARGUMENT` — matching the classifier and `0x1470e67f0`. + +The **only** reader is `0x14712c850`, a linear search **by value** (`cmp rdx,0x51`) returning +`&strings[3*i]`, called from `0x1470dbe00` (`ErrorCodeToDescription`, returns `triple[2]`) — a +**logging decoder**. A RIP-relative xref scan over `0x145000000-0x14a000000` for the table region +returned exactly two operand refs, both inside `0x14712c850`. Nothing indexes the table to *produce* +a code, and BRIEF4's arithmetic scan already found 0 construction sites. + +Full-address-space scan for the dword `12 00 00 a2`: 4 hits — the table entry `0x144340f44`, the +classifier's `cmp` immediate at `0x14717d6f5`, and two unaligned hits in compressed asset data. + +**The genuine underage error is a different code entirely:** `codes[57] @ 0x144340fb8 = 0xa2060005 = +`ORIGIN_ERROR_COMMERCE_UNDERAGE_USER`. The classifier never references it. + +### 1.5 LIVE PROOF of which arm fired + +``` +[0x43d317a8+0x140] = 40 bytes of 0x00 (blazeSdkClientId cache: empty) +[0x43d317a8+0x940] = 0x1 +[0x43d317a8+0x948] = 0x0 ; <-- NULL auth code => ARM 2 +[0x43d317a8+0x950] = 0xdeadbeef ; <-- NOT 0xa2000012 + +osdk state obj 0x43d189d8 (vptr 0x14395c180): + +0x80 -> 0x14395c9b0 = "OSDK_UNDERAGE_ERROR" + +0x7c = 10 + +0x260 = 16 (0x10 = the latch) +``` + +**Adjudication of the reports' one disagreement.** Two reverses read `+0x950 == 0` (~15:52), two read +`0xdeadbeef` (~15:58, and I confirm `0xdeadbeef` now). One report concluded from `0xdeadbeef` that +`vt[0x68]` **never ran** (early-return on `[cfg+0x750] == NULL`) and proposed an *ordering* fix. +**That is refuted by our own bytes:** + +* the ctor writes **`ebp` (= 0)**, not `0xdeadbeef`, to `+0x950` (`0x147236d1b`); +* the early-out at `0x147237384` jumps to `0x1472373e0` and **never touches `+0x950`**; +* so `0xdeadbeef` can only have arrived through `mov [rbx+0x950],eax` after `call 0x1470db3c0` + — i.e. `ebmg::GetAuthCode` **did** execute past the CONF gate; +* it is not allocator poison: I dumped the full 0x1000 bytes of the object and `ef be ad de` occurs + **exactly once**, at `+0x950`, with zeros on both sides. + +Ordering is fine too: `[mgr+0x360] = 0x43c47330`, `[+0x750] = 0x43c47ff0` (non-NULL, live), and +`Util::preAuth` was answered at `[15:36:36]` (`/tmp/blaze_responder.log:36234`) while the five +`GetAuthCode` requests are `/tmp/lsx.log` lines 100–120 of 127, with the log's last write at +`15:36:57`. So the auth-code fetches happened **after** the CONF map was installed. + +The two `+0x950` readings tell one consistent story: **while the LSX socket was live, the fetch +returned `0` (success) with a zero-length code** (the empty-`value` signature — exactly ARM 2/3); +**after the socket went quiet, later fetches returned a garbage `0xdeadbeef`** from the failed +request object. Neither reading is `0xa2000012`. + +### 1.6 THE CONDITION, tied to the input we control + +Response handler for `LSXRequest` is `0x1470e4ee0`. It sets +`[rbx+0x160] = 0` then calls the matcher `0x1470e2a70`, which checks root `"LSX"` (`0x143938024`), +attr `"id"` (`0x14355de00`), attr `"sender"` (`0x143938028`, byte-compared against the request's +recipient — the fix we already landed), then at `0x1470e2b63` matches child element +`0x143937ae0 = "AuthCode"` and tail-jumps `0x14712fac0 -> 0x1471312a0` = the `lsx::AuthCodeT` +deserializer. + +Live disassembly of `0x1471312a0`'s attribute build/read (read this session): + +``` +14713131f … build ns prefix for "lsx" (0x14394def0) via 0x14713f940, optional ":" (0x143559ab0) +14713133d 4c 8d 05 24 64 59 fc lea r8,[rip+0xfc596424] # 0x1436c7768 <-- live cstr = b"value" +14713134c e8 df bd ff ff call 0x14712d130 ; concat -> attribute name +… +1471313e1 e8 6a ea 00 00 call 0x14713fe50 ; get-attribute-as-string (ONE call, only call) +… +14713141b b0 01 mov al,0x1 ; *** ALWAYS RETURNS SUCCESS *** +``` + +And the getter itself tolerates a missing attribute: + +``` +14713fe50 call [rax+0x50](name) ; attribute present? +14713fe68 je 0x14713febf ; -> `xor al,al; ret` -- dest left EMPTY, no error propagated +``` + +So a reply without a `value` attribute parses as **success with an empty `std::string`**. +That string is `LSXRequest+0xb8`, size at `+0xc8` — precisely what the impl reads back: + +``` +1470e6924 mov rbx,[rdi+0xc8] ; *out_len <- 0 +1470e6965 mov [r15],rax ; *out_ptr <- non-NULL 1-byte alloc +1470e6968 mov [r12],rbx ; *out_len <- 0 + returns 0 (SUCCESS) +``` + +`out_len == 0` → `0x1472373d7 je` → `+0x948` never written → `rbp == NULL` → **ARM 2** → state 10. + +**What we actually sent this boot** (`/tmp/lsx.log`, ids 25–29, all five): + +``` + +``` + +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 `` 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 + +``` + +[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 `>> …` (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 ` and sets `0x10` on the first accepted write. +Live: `[0x43d189d8+0x260] = 0x10`. Once latched, the classifier is never re-invoked, so no field you +forge can be re-read. (`0x14717d5d0` also has **zero** direct `e8/e9` callers and zero qword vtable +slots — it is reached through image-relative jump-table entries at `0x143fef788 / 0x143fef79c / +0x143fef7ac`, dword `0x0717d5d0` — so we cannot cheaply hand-call it either.) + +**The field itself is nonetheless confirmed by pure observation, without a forge:** `ebmg+0x948` is +NULL *and* `ebmg+0x950` has never held `0xa2000012` (it is provably restricted to +`OriginRequestAuthCodeSync`'s return set), and there is exactly **one** RIP-reference to +`0x14395c9b0` in `0x145000000-0x14a000000` — `0x14717d732`. There is no other producer of the label. + +**Server-side equivalent of the forge** (this is what FIX 1 does, at boot, with no memory writes): +where the forge wrote a `char*` into `ebmg+0x948`, the responder makes the game write it itself, by +supplying a non-empty `value` attribute so that `out_len != 0` and `0x1472373d9 +(mov [rbx+0x948],rax)` executes naturally. Same destination, same pointer semantics, but taken +through the real code path — which additionally sets `+0x950 = 0` and, crucially, happens **before** +the `+0x260` latch closes. + +--- + +## 4. What still needs a live experiment + +**This fix is boot-time and cannot be validated on pid 39211. FIFA 17 must be relaunched.** +Both preconditions are unrecoverable in the current process: the state latch +(`0x43d189d8+0x260 == 0x10`) and the dead LSX socket (last write `15:36:57`). + +### Run procedure + +1. Leave `lsx_responder_v2.py` as-is (fix already at `:385`) and `blaze_responder_v3b.py` untouched. +2. Rotate the logs (`mv /tmp/lsx.log /tmp/lsx.log.prev`, same for `/tmp/blaze_responder.log`) so the + `value=` frames and the `+0x948` transition are unambiguous. **Also keep the rotation** — see + open question O1. +3. Restart both responders, launch FIFA 17, `pgrep -x FIFA17.exe` for the new pid. +4. Watch signals 1–5 from §2 in order. Poll `ebmg` and the OSDK state object with the re-find recipes + in §2 (never reuse `0x43d317a8` / `0x43d189d8` — those are this-boot heap addresses). + +### Open questions the run should settle + +* **O1 — was `0xa2000012` ever genuinely observed?** We could not reproduce it live and the code + cannot produce it in this path. It may have been inferred from the `cmp` immediate rather than + read. If a rotated `/tmp/lsx.log*` or an older `+0x950` reading really shows it, then a different + run took the `0x1470e4fbf` path (`cmovne edx,[rsp+0x40]` — an integer read straight out of the + response), which would mean a stray numeric field in one of our replies. Worth one `grep` of the + older log rotations before the run. +* **O2 — does the *second* fetcher also need to succeed?** `0x147237440` (`blazeSdkClientId`, + `FIFA17PC`) caches into `ebmg+0x140` and is gated by `cmp BYTE PTR [rcx+0x140],0` (cache-once, + live value 0/empty). After the fix, confirm **both** `+0x948` (from `FIFA17PC-SERVER`) and `+0x140` + (from `FIFA17PC`) get populated — Blaze may want the server-scoped code specifically. Note the two + fetchers **share** the single error slot `+0x950`, so a failing `blazeSdkClientId` fetch can clobber + a successful `blazeServerClientId` one; if that becomes a problem, the codes may need to differ + per `ClientId` (the responder already logs `ClientId`, so this is a one-line change). +* **O3 — does Blaze `Authentication::login` accept the placeholder?** Statically it does + (`:1040` stores `AUTH` without validation), but the wall may move: watch for + `AUTH_ERR_INVALID_PERSONA (26)` / `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` / + `AUTH_ERR_PERSONA_NOT_FOUND` in the reply we build. If FIFA rejects it, the next candidate is + making the code look Nucleus-shaped (base64/JWT-ish) rather than `OPENFUT-0000…`. +* **O4 — `[OriginSDK+0x3b0]+0x120` (the expected `sender`) is an EMPTY string.** + (`OriginSDK = *[0x144b7c7a0] = 0x25c98c50`, size 0 / cap 15.) Real Origin presumably fills a + session id there during the LSX handshake. Our empty echo satisfies the `strcmp` today, but confirm + no later verb needs it non-empty. +* **O5 — audit every other LSX verb the same mechanical way.** `GetAuthCode` was attribute-*guessed*; + so were `QueryEntitlements`, `SetPresence` and `QueryUserId`. The audit is cheap and mechanical: + find the element-name string's single xref → follow the tail-jump thunk → list the + `0x14713fe50` (string) / `0x14713ffa0` (bool) attribute-name arguments. `GetGameInfo` + (`"GameInfo"`) and `GetProfile` (`UserId`, `PersonaId`, `Persona`, `AvatarId`, `Country`, + `IsUnderAge`, `IsSubscriber`, `GeoCountry`, `CommerceCountry`, `CommerceCurrency`) are already + verified correct; the rest are not. +* **O6 — who dispatches `0x14717d5d0`?** Reached only through the jump-table slots at + `0x143fef788 / 0x143fef79c / 0x143fef7ac`. Not needed for the fix, but knowing the dispatcher would + let us re-trigger classification live in a future session instead of relaunching. diff --git a/fifa17-recon/tools/lsx_responder_v2.py b/fifa17-recon/tools/lsx_responder_v2.py new file mode 100644 index 0000000..273e4bb --- /dev/null +++ b/fifa17-recon/tools/lsx_responder_v2.py @@ -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 + 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 + * element matcher @0x147102880: + - reads attribute "sender" (literal @0x143938028) off the 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. + * 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. + * 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::HandleMessage + Origin::EventHandler::HandleMessage + (payload type `unsigned int` matches `cmp DWORD PTR [r9],1` above.) + * Structural proof that unsolicited frames are consumable: the LSX + handshake itself is one -- . + +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 IN PLAINTEXT; + client replies plaintext with response=/key=; + server replies 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 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 that already +# works, i.e. +# 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'<{element}/>' + + +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 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 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'<{body}/>' + + +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 whose `sender` attribute does not + byte-equal the `recipient` the client put on the matching (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'<{body}/>' + + 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: + # + # 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'') + return (f'' + f'{item}' + f'') + + 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']*\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']*\brecipient="([^"]*)"') + + +def serve(sock, addr): + conn = Conn(sock, addr) + hb = None + try: + # 1. plaintext Challenge + conn.send_plain(f'') + + # 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 '' 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 '/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" "/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 diff --git a/fifa17-recon/tools/root_arm.sh b/fifa17-recon/tools/root_arm.sh new file mode 100755 index 0000000..7d8780a --- /dev/null +++ b/fifa17-recon/tools/root_arm.sh @@ -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!)" diff --git a/fifa17-recon/tools/roster_server.py b/fifa17-recon/tools/roster_server.py new file mode 100644 index 0000000..4e33902 --- /dev/null +++ b/fifa17-recon/tools/roster_server.py @@ -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'\n\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() diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py new file mode 100755 index 0000000..d537e5a --- /dev/null +++ b/fifa17-recon/tools/utas_server.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room). + +CardsDLL resolves every RS4 endpoint to + path, where comes from +FUT_RS4_APIURL_ / FUT_RS4_URL_ (blaze_responder_v3b.py) and path is +moduleTable[i] with %s -> "game/". Auth is POST 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//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//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//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()