edab23f04a
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.
Package:
- tools/openfut-fut.sh one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md the reverse-engineering write-ups
All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
197 lines
12 KiB
Markdown
197 lines
12 KiB
Markdown
# FIFA 17 Blaze Recon (Rosetta Stone for FIFA 23)
|
||
|
||
Clean-room reverse engineering: all findings derive from observing our own running
|
||
FIFA 17 client + static disassembly of the shipped binary we own. **No leaked EA
|
||
source is used or referenced.**
|
||
|
||
> ## ✅ WORKING: FIFA 17 Ultimate Team, 100% offline
|
||
> The full online + FUT stack is emulated. **Quick start → [`FUT-RUNBOOK.md`](FUT-RUNBOOK.md):**
|
||
> ```bash
|
||
> cd tools && ./openfut-fut.sh start # arm host + start all servers (re-run after reboot)
|
||
> ~/Desktop/launch-fifa17.sh # then launch FIFA FRESH and select Ultimate Team
|
||
> ```
|
||
> Proven end-to-end 2026-08-01: auth → Blaze login → device-trust → the FUT hub. The rest of this
|
||
> file is the reverse-engineering history that got there (see also `login_dump/*.md`, `docs/*.md`).
|
||
|
||
## Breakthrough — 2026-07-30: ProtoSSL cert pin DEFEATED, redirector handshake captured
|
||
|
||
FIFA 17 dials the **secure** Blaze redirector `winter15.gosredirector.ea.com` over
|
||
TLS 1.2 (RSA-kx). We MITM it with a self-signed cert and defeated DirtySDK/ProtoSSL's
|
||
cert pinning with two live `/proc/PID/mem` patches, then captured the **plaintext**
|
||
first-hop handshake.
|
||
|
||
### Key architectural finding
|
||
The secure redirector is **HTTPS + XML (ProtoHttp)**, NOT raw Fire2/Heat2:
|
||
```
|
||
POST /redirector/getServerInstance HTTP/1.1
|
||
Host: winter15.gosredirector.ea.com:42230
|
||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||
Content-Type: application/xml
|
||
<serverinstancerequest>...</serverinstancerequest>
|
||
```
|
||
Fire2/Heat2 binary is the **second hop** — the redirector replies with a
|
||
`<serverinstance>` XML naming a Blaze server IP:port; the client then connects THERE
|
||
for the binary protocol. Full request body in `captures/getServerInstance_request.http`.
|
||
|
||
## Reproduce (after reboot — all live state is volatile)
|
||
|
||
Binary maps flat at base `0x140000000` under Wine/Proton (UMU-Proton-10.0-4,
|
||
prefix `~/Games/umu/fifa17`). VAs below are stable across launches.
|
||
|
||
### 1. Root arm (scratchpad/root_arm.sh via pkexec)
|
||
- `sysctl kernel.yama.ptrace_scope=0` (enables /proc/mem WRITES)
|
||
- `sysctl net.ipv4.conf.lo.route_localnet=1`
|
||
- `iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0.0.1:42127`
|
||
(winter15 resolves to 159.153.51.20; a /etc/hosts entry for winter15 would
|
||
short-circuit the DNAT and must be ABSENT)
|
||
|
||
### 2. TLS capture server
|
||
`scratchpad/blaze_tls_capture.py` on 127.0.0.1:42127, presents `redir_cert.pem`
|
||
(self-signed, CN+SAN=winter15.gosredirector.ea.com), ciphers `ALL:@SECLEVEL=0`.
|
||
|
||
### 3. The two cert-verify patches (via scratchpad/memtool.py)
|
||
The cert handler lives at ~`0x14613252x`. Two gates:
|
||
|
||
| VA | Role | Patch |
|
||
|---|---|---|
|
||
| `0x146132548` | Gate 1: `jne 0x1461326c4` (UNKNOWN_CA branch after chain-verify `call 0x146136410`) | 6 bytes → `90 90 90 90 90 90` (NOP) |
|
||
| `0x1461361b0` | **Gate 2: the real pin** — cert verify helper; returned `-51 (0xffffffcd)` live | 3 bytes → `31 c0 c3` (`xor eax,eax; ret`) |
|
||
|
||
Gate 2 (`0x1461361b0`) is the decisive one — a shared verify helper (also called from
|
||
`0x146131f86`). Forcing it to return 0 makes `r12d=0`, the `je 0x14613262d` at
|
||
`0x14613256c` is taken, and the accept path at `0x146132675` is reached (skips the
|
||
UNKNOWN_CA alert send at `0x146135250`).
|
||
|
||
NB: last session's patch of `0x146136410` (chain-verify callee) did NOT work — it was
|
||
not the function returning the live failure. gdb breakpoint on `0x1461361b0` proved
|
||
Gate 2 was the wall (`eax=0xffffffcd`).
|
||
|
||
## Breakthrough #2 — 2026-07-30: BOTH HOPS DEFEATED, Fire2/Heat2 decoded
|
||
|
||
Built `tools/blaze_responder.py`: answers `getServerInstance` over TLS with a
|
||
`<serverinstanceinfo>` that redirects the client to a local plain Blaze port, and
|
||
captures the second-hop Fire2 binary. The client **accepted the redirect and connected**,
|
||
sending its `Util::preAuth` handshake in binary Heat2. `tools/decode_fire2.py` decodes it.
|
||
|
||
### getServerInstance response schema (the redirect)
|
||
`ServerInstanceInfo.address` is a `ServerAddress` **union**; Heat2 XML encodes a union as
|
||
`<field member="N"><valu>...</valu></field>`. Working response (member=0 = ipAddress variant):
|
||
```xml
|
||
<serverinstanceinfo>
|
||
<address member="0"><valu>
|
||
<hostname>127.0.0.1</hostname><ip>2130706433</ip><port>42130</port>
|
||
</valu></address>
|
||
<secure>0</secure>
|
||
<trialservicename></trialservicename>
|
||
<defaultdnsaddress>0</defaultdnsaddress>
|
||
</serverinstanceinfo>
|
||
```
|
||
`<ip>` is a **decimal uint32** host-order (2130706433 = 127.0.0.1). `<secure>` 0/1 picks
|
||
plaintext vs TLS for the Blaze connection. (Schema cross-confirmed clean-room vs MEC
|
||
Catalyst private-server projects; response types reversed from the client's own TDF
|
||
reflection tables at ~0x143891xxx / 0x144873xxx.)
|
||
|
||
### Fire2 frame header (16 bytes, big-endian)
|
||
```
|
||
[0:4] u32 payloadLength [6:8] u16 component [8:10] u16 command
|
||
[10:12] u16 error/msgId [12] u8 msgType [13:16] reserved
|
||
```
|
||
First RPC observed: **component 0x0009 = Util, command 0x0007 = preAuth, msgType 0x02**.
|
||
Ping/pong keep-alives: Util command 0x0002, empty payload, msgType 0x01/0x03.
|
||
|
||
### Heat2 TDF encoding (decoded in decode_fire2.py)
|
||
Per field: 3-byte tag (4 chars, 6-bit packed, char = v?v+0x20:' ') + 1 type byte + value.
|
||
Types: 0x00 int(varint, first byte 6 data bits + continue@0x80), 0x01 string(varint len incl
|
||
null + bytes), 0x02 blob, 0x03 struct(nested, 0x00 terminator), 0x04 list, 0x05 map, 0x06 union.
|
||
|
||
### preAuth codebook (Util::preAuth PreAuthRequest) — captures/blaze/preauth_decoded.txt
|
||
```
|
||
CDAT{ IITO:int LANG:int SVCN:str='fifa-2017-pc' TYPE:int }
|
||
CINF{ BSDK='15.1.1.3.0' BTIM='Jun 9 2017 16:15:40' CLNT='FIFA17' CPFT:int=4
|
||
CSKU='FIFAPC' CVER='3175939' DSDK='15.1.2.1.0' ENV='prod' LOC:int PTVR='1.1' }
|
||
FCCR{ CFID='BlazeSDK' }
|
||
LADD:int
|
||
```
|
||
Same fields as the XML getServerInstance request → XML and Fire2 are the two encodings of
|
||
the same TDFs (the Rosetta mapping).
|
||
|
||
(Fire2 header was later CORRECTED: byte[12] is the low octet of a 24-bit msgNum, not msgType;
|
||
msgType lives in byte[13] high bits = (msgType<<5)|userIndex. REPLY=1→0x20, NOTIFICATION=2→0x40.
|
||
metadataLen is u16 at [4:6]. See tools/heat2.py / blaze_responder_v3b.py.)
|
||
|
||
## Breakthrough #3 — Origin/LSX layer defeated (PreAuthResponse + login flow work)
|
||
`tools/blaze_responder_v3b.py` answers preAuth, ping, fetchClientConfig, login (1/0x0A),
|
||
getAccount(1/0x1E)=AccountInfo, getPersona/listPersonas, and pushes UserAuthenticated (0x7802/8).
|
||
But Blaze isn't the online gate — **Origin is**, via its own in-process LSX layer:
|
||
|
||
- The Steampunks `stp-origin_emu.dll` serves **LSX** (length-prefixed, NUL-terminated XML) IN-PROCESS
|
||
on 127.0.0.1:4216. It's a blind fixed-script replayer that reports OFFLINE. **Replace it**:
|
||
bind 4216 BEFORE launching FIFA (`tools/lsx_responder_v2.py`; the stub has no SO_REUSEADDR and
|
||
stands down cleanly), serve real request-driven LSX.
|
||
- **LSX crypto (reversed + verified byte-exact):** server sends `<Challenge key="<32hex>">`; client
|
||
replies `<ChallengeResponse response="<96hex>" key="<32hex>">`; **H = hex(AES128-ECB(K=000102..0f,
|
||
PKCS7pad16(clientKey_ascii)))** (32 ASCII → 48 bytes/3 blocks); server sends `<ChallengeAccepted
|
||
response="H">`; session key = srand(7) LCG of H; later msgs = hex(AES-ECB(pkcs7(xml)))+NUL.
|
||
- **LSX verbs to answer:** GetProfile(PersonaId=33068179 Persona=CAGE US), GetSetting UPPERCASE
|
||
(ENVIRONMENT→"production", LANGUAGE→"en_US", else "false"), GetGameInfo (LANGUAGES→locales,
|
||
**UPTODATE→"true"** [else "title version outdated"], FREETRIAL→"false"),
|
||
**GetInternetConnectedState→connected="1"** [the online gate], etc.
|
||
- Gates cleared this way: "log in to Origin" ✓ and "title version outdated" ✓.
|
||
|
||
## Breakthrough #4 — 2026-07-30: repack fully reversed (LSX contract is a byte-exact oracle)
|
||
The Steampunks repack ships two UPX-packed helpers; we unpacked and clean-room reversed BOTH
|
||
(multi-agent workflow, adversarially verified — full report `docs/REPACK_INTEL.md`, emu disasm
|
||
`docs/emu.asm`). Unpack recipe: `upx -d stp-origin_emu.dll` and `upx -d _fifa17.exe` (emu base
|
||
0x180000000, loader base 0x140000000; both are NORMAL PEs — objdump works, unlike the encrypted
|
||
FIFA17.exe). Findings that matter:
|
||
- **`stp-origin_emu.dll` = the reference LSX server, offline BY CONSTRUCTION.** It is a blind
|
||
18-step straight-line script with NO parser and NO dispatch branch; its ONLY unsolicited frame is
|
||
the plaintext Challenge; it hardcodes `connected="0"` and has NO `<Login>` event / no auth vocab
|
||
anywhere in its 19,456 bytes. **Structural proof (not absence-of-evidence): nothing in the repack
|
||
can flip `m_isLoggedIn`.** The login mechanism lives ONLY in FIFA17.exe's live-decrypted code.
|
||
- **Our `lsx_responder_v2.py` is CONFIRMED byte-exact** on framing (NUL-terminated, NUL counted in
|
||
send len), crypto (AES-128 K_FIXED=000102..0f, PKCS7, srand(7)→61 session-key LCG), event shape,
|
||
sender values (EALS / EbisuSDK / ""), and encryption timing (plaintext through ChallengeAccepted
|
||
id=1, encrypted from id=2). Applied hardening 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 `<Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/>`.
|
||
**Pushing it 90× did NOT flip the flag.** Breakthrough #4 RULED OUT three causes: framing, event
|
||
shape, and encryption timing are all confirmed correct. **Surviving hypotheses, narrowed:**
|
||
(1) **encrypted mid-session Events are dropped** — the emu's only Event is plaintext+pre-key, so
|
||
there is zero evidence FIFA routes an *encrypted* Event to the same parser (STRONGEST); (2) `sender`
|
||
name mismatch; (3) handler-registration timing. Deeper residual: LoginStatePCLogin @0x1471b58e0 may
|
||
gate on a session OBJECT [0x144b86bf8]->vtbl+0x60, not the flag.
|
||
|
||
### THE decisive next experiment (observe, don't guess) — new tooling ready
|
||
1. Relaunch harness+game (below), run responder with an UNBOUNDED heartbeat so pushes stay in flight:
|
||
`OPENFUT_LSX_EVENT_COUNT=100000 python3 -u tools/lsx_responder_v2.py`
|
||
2. `bash tools/trace_login.sh` — attaches gdb, traces the sender matcher (0x147102880), the <Login>
|
||
parser (0x147138660), and dispatcher case-2 (0x146f1e09e / set-1 0x146f1e0ab / set-0 0x146f1e0b8).
|
||
Answers the 3-question ladder in ONE run: does the frame reach the matcher? what sender does it
|
||
strcmp against (dumps the table entry)? does case-2 run and the flag flip?
|
||
3. If the trace shows the ENCRYPTED frame never reaches the matcher → run the A/B:
|
||
`OPENFUT_LSX_LOGIN_PLAINTEXT=1 …` pushes the Login Event in plaintext right after ChallengeAccepted.
|
||
4. `tools/dump_login_code.py` — dumps + disassembles the decrypted login machinery at true VAs for a
|
||
follow-up static pass if the trace points below the dispatcher.
|
||
|
||
## How to resume (rebuild the volatile harness)
|
||
1. `pkexec sh tools/../scratchpad/root_arm.sh` (ptrace_scope=0, route_localnet, DNAT 159.153.51.20→42127).
|
||
2. `python3 -u tools/lsx_responder_v2.py` — bind :4216 BEFORE launching FIFA.
|
||
3. `python3 -u tools/blaze_responder_v3b.py` — :42127 (redir TLS) / :42130 (blaze) / :42131 (nucleus).
|
||
4. `python3 tools/autopatch.py` — re-applies the two ProtoSSL cert patches to any relaunched FIFA17.exe.
|
||
5. Launch FIFA via `~/Desktop/launch-fifa17.sh`; go Online.
|
||
6. Watch /tmp/lsx.log (LSX) + /tmp/blaze_responder.log (Blaze); use tools/origin_login_probe.py to read
|
||
m_isLoggedIn. Everything is volatile across reboot; VAs are stable (base 0x140000000).
|
||
|
||
## Live-state note
|
||
Volatile across reboot: cert patches, responders, DNAT, ptrace_scope. `tools/autopatch.py`
|
||
re-applies both cert patches automatically to any relaunched FIFA17.exe (VAs are stable).
|
||
Everything ported here (framing, Heat2, LSX crypto, tags) applies to FIFA 23 (identical wire format).
|