Files
OpenFUT/fifa17-recon
funman300 89da7b7609 fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.

THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.

The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.

Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.

HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.

Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.

TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.

ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
  * duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
    list at :1095. Bare ints where the element parser expects objects is a tokenizer
    desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id.

No behaviour change. utas_server.py is comment-only. 439 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:35:27 -07:00
..

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:

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):

<serverinstanceinfo>
  <address member="0"><valu>
    <hostname>127.0.0.1</hostname><ip>2130706433</ip><port>42130</port>
  </valu></address>
  <secure>0</secure>
  <trialservicename></trialservicename>
  <defaultdnsaddress>0</defaultdnsaddress>
</serverinstanceinfo>

<ip> is a decimal uint32 host-order (2130706433 = 127.0.0.1). <secure> 0/1 picks plaintext vs TLS for the Blaze connection. (Schema cross-confirmed clean-room vs MEC Catalyst private-server projects; response types reversed from the client's own TDF reflection tables at ~0x143891xxx / 0x144873xxx.)

Fire2 frame header (16 bytes, big-endian)

[0:4] u32 payloadLength   [6:8] u16 component   [8:10] u16 command
[10:12] u16 error/msgId    [12] u8 msgType       [13:16] reserved

First RPC observed: component 0x0009 = Util, command 0x0007 = preAuth, msgType 0x02. Ping/pong keep-alives: Util command 0x0002, empty payload, msgType 0x01/0x03.

Heat2 TDF encoding (decoded in decode_fire2.py)

Per field: 3-byte tag (4 chars, 6-bit packed, char = v?v+0x20:' ') + 1 type byte + value. Types: 0x00 int(varint, first byte 6 data bits + continue@0x80), 0x01 string(varint len incl null + bytes), 0x02 blob, 0x03 struct(nested, 0x00 terminator), 0x04 list, 0x05 map, 0x06 union.

preAuth codebook (Util::preAuth PreAuthRequest) — captures/blaze/preauth_decoded.txt

CDAT{ IITO:int LANG:int SVCN:str='fifa-2017-pc' TYPE:int }
CINF{ BSDK='15.1.1.3.0' BTIM='Jun  9 2017 16:15:40' CLNT='FIFA17' CPFT:int=4
      CSKU='FIFAPC' CVER='3175939' DSDK='15.1.2.1.0' ENV='prod' LOC:int PTVR='1.1' }
FCCR{ CFID='BlazeSDK' }
LADD:int

Same fields as the XML getServerInstance request → XML and Fire2 are the two encodings of the same TDFs (the Rosetta mapping).

(Fire2 header was later CORRECTED: byte[12] is the low octet of a 24-bit msgNum, not msgType; msgType lives in byte[13] high bits = (msgType<<5)|userIndex. REPLY=1→0x20, NOTIFICATION=2→0x40. metadataLen is u16 at [4:6]. See tools/heat2.py / blaze_responder_v3b.py.)

Breakthrough #3 — Origin/LSX layer defeated (PreAuthResponse + login flow work)

tools/blaze_responder_v3b.py answers preAuth, ping, fetchClientConfig, login (1/0x0A), getAccount(1/0x1E)=AccountInfo, getPersona/listPersonas, and pushes UserAuthenticated (0x7802/8). But Blaze isn't the online gate — Origin is, via its own in-process LSX layer:

  • The Steampunks stp-origin_emu.dll serves LSX (length-prefixed, NUL-terminated XML) IN-PROCESS on 127.0.0.1:4216. It's a blind fixed-script replayer that reports OFFLINE. Replace it: bind 4216 BEFORE launching FIFA (tools/lsx_responder_v2.py; the stub has no SO_REUSEADDR and stands down cleanly), serve real request-driven LSX.
  • LSX crypto (reversed + verified byte-exact): server sends <Challenge key="<32hex>">; client replies <ChallengeResponse response="<96hex>" key="<32hex>">; H = hex(AES128-ECB(K=000102..0f, PKCS7pad16(clientKey_ascii))) (32 ASCII → 48 bytes/3 blocks); server sends <ChallengeAccepted response="H">; session key = srand(7) LCG of H; later msgs = hex(AES-ECB(pkcs7(xml)))+NUL.
  • LSX verbs to answer: GetProfile(PersonaId=33068179 Persona=CAGE US), GetSetting UPPERCASE (ENVIRONMENT→"production", LANGUAGE→"en_US", else "false"), GetGameInfo (LANGUAGES→locales, UPTODATE→"true" [else "title version outdated"], FREETRIAL→"false"), GetInternetConnectedState→connected="1" [the online gate], etc.
  • Gates cleared this way: "log in to Origin" ✓ and "title version outdated" ✓.

Breakthrough #4 — 2026-07-30: repack fully reversed (LSX contract is a byte-exact oracle)

The Steampunks repack ships two UPX-packed helpers; we unpacked and clean-room reversed BOTH (multi-agent workflow, adversarially verified — full report docs/REPACK_INTEL.md, emu disasm docs/emu.asm). Unpack recipe: upx -d stp-origin_emu.dll and upx -d _fifa17.exe (emu base 0x180000000, loader base 0x140000000; both are NORMAL PEs — objdump works, unlike the encrypted FIFA17.exe). Findings that matter:

  • stp-origin_emu.dll = the reference LSX server, offline BY CONSTRUCTION. It is a blind 18-step straight-line script with NO parser and NO dispatch branch; its ONLY unsolicited frame is the plaintext Challenge; it hardcodes connected="0" and has NO <Login> event / no auth vocab anywhere in its 19,456 bytes. Structural proof (not absence-of-evidence): nothing in the repack can flip m_isLoggedIn. The login mechanism lives ONLY in FIFA17.exe's live-decrypted code.
  • Our lsx_responder_v2.py is CONFIRMED byte-exact on framing (NUL-terminated, NUL counted in send len), crypto (AES-128 K_FIXED=000102..0f, PKCS7, srand(7)→61 session-key LCG), event shape, sender values (EALS / EbisuSDK / ""), and encryption timing (plaintext through ChallengeAccepted id=1, encrypted from id=2). Applied hardening C1C3 (emu-exact challenge_response + tail assert, extract response=", partial-frame buffering). Selftest still green (session key unchanged).
  • The loader is an offline keygen/launcher (no WS2_32, no injection, no Blaze/Nucleus strings); its .dlf GameToken is a local ENTITLEMENT grant, not a session — will not help login. Shared build constants: UserId/PersonaId 33068179, MachineHash == LSX Challenge key 2b8ee7fa…e32 (fixed).

CURRENT WALL — "Unable to retrieve account information" (m_isLoggedIn stays 0)

FIFA has two Origin flags — "internet reachable" (fed by GetInternetConnectedState, DONE) and "user LOGGED IN" = OriginMgr.m_isLoggedIn @[OriginMgr+0x13], whose only setter is dispatcher case-2 @0x146f1e0ab, driven by a server-PUSHED <Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/>. Pushing it 90× did NOT flip the flag. Breakthrough #4 RULED OUT three causes: framing, event shape, and encryption timing are all confirmed correct. Surviving hypotheses, narrowed: (1) encrypted mid-session Events are dropped — the emu's only Event is plaintext+pre-key, so there is zero evidence FIFA routes an encrypted Event to the same parser (STRONGEST); (2) sender name mismatch; (3) handler-registration timing. Deeper residual: LoginStatePCLogin @0x1471b58e0 may gate on a session OBJECT [0x144b86bf8]->vtbl+0x60, not the flag.

THE decisive next experiment (observe, don't guess) — new tooling ready

  1. Relaunch harness+game (below), run responder with an UNBOUNDED heartbeat so pushes stay in flight: OPENFUT_LSX_EVENT_COUNT=100000 python3 -u tools/lsx_responder_v2.py
  2. bash tools/trace_login.sh — attaches gdb, traces the sender matcher (0x147102880), the 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).