# 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).