Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.
REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.
REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.
AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
-> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.
The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.
Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40 vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.
WHAT IS ACTUALLY SHIPPED HERE, all default off:
* FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
in _item(), because the save holds 246 items minted before the flag existed and the
club route serves them straight from the save. That gap was caught by reading the
served JSON, not by unit-testing the factory.
* FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
only as a record of the refutation, with the reasoning inline so nobody retries it.
* fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
preferred could never have answered this for three of five families.
* Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
(which was never bisected; value is the established suspect and that response also
carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
never copied into the record.
Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 TeamProven 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=1iptables -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.dllserves 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 hardcodesconnected="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 flipm_isLoggedIn. The login mechanism lives ONLY in FIFA17.exe's live-decrypted code.- Our
lsx_responder_v2.pyis 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-exactchallenge_response+ tail assert, extractresponse=", 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
.dlfGameToken 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
- 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 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?- 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. 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)
pkexec sh tools/../scratchpad/root_arm.sh(ptrace_scope=0, route_localnet, DNAT 159.153.51.20→42127).python3 -u tools/lsx_responder_v2.py— bind :4216 BEFORE launching FIFA.python3 -u tools/blaze_responder_v3b.py— :42127 (redir TLS) / :42130 (blaze) / :42131 (nucleus).python3 tools/autopatch.py— re-applies the two ProtoSSL cert patches to any relaunched FIFA17.exe.- Launch FIFA via
~/Desktop/launch-fifa17.sh; go Online. - 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).