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
42 KiB
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"@ file0x33f4,"127.0.0.1"@ file0x3400. 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
acceptcall site in the whole image (grep -c '# 0x180004198' emu.asm→ 1), not in a loop; the listener is closed immediately after (0x1800022bb closesocket). Terminal pathshutdown(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) thenlea r8d,[rax+0x1]/xor r9d,r9d/call [0x180004188]. First instance0x1800022c8; identical at0x18000235f,0x1800023ed,0x18000249d…0x180002e1b. - Receive: one blocking
recv(sock, buf, 0x1000, 0)per message, into a 0x1000 stack buffer atrbp+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 (
QueryEntitlementsis 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:
- Server pushes the plaintext Challenge, verbatim from
.data 0x180005590— it is a fixed constant, never computed or randomized, and is sent with nosprintfand no call to the encryptor:<LSX><Event sender="EALS"><Challenge key="2b8ee7faea76e8a34f5f5d20e5328e32" build="release" version="10,4,13,6637"/></Event></LSX> - Client replies plaintext
<ChallengeResponse response="<96hex>" key="<32hex>"/>. - Parse function
0x180001f10does the only string parsing in the entire DLL — twostrstrcalls, both here (0x180001f5aforresponse="@0x1800045e0, +0xA;0x180001fa6forkey="@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'sresponse=value is NEVER VERIFIED. H=hex(AES128ECB(K_FIXED, clientKey[0:16]))‖hex(AES128ECB(K_FIXED, clientKey[16:32]))‖clientResponse[64:]— twomovaps/expand/encrypt pairs at0x180001feeand0x180002024; hex loop0x180002070–0x1800020a7bounded bycmp rbx,0x20(32 bytes → 64 chars) viasprintf_s(tmp,3,"%02x")(lowercase, fmt @0x1800045d0) +strcat_s; then the echostrcat_sat0x1800020a9. Our PKCS7-3-block formula produces the identical 96 hex (§0-B). Keep it; see the diff list for the hardening.- 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: aftersprintf_sat0x180002343the code goes straight to inlined strlen + send at0x180002370with no call to the encryptor0x180001dc0; first encrypt call is0x1800023d6, first decrypt0x1800023a4. - Session key (
0x1800020bf–0x180002101), derived from the ASCII characters ofH, 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,ecxthenrep 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: hex2bin0x180001c50viasscanf_s("%02x")(so uppercase hex is accepted on input), per-block decrypt, then a validating PKCS#7 strip at0x180001d6e–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 recvs, decrypts 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,<ordinal>; 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 |
<LSX><Response id="%d" sender=""><GetSettingResponse Setting="%s"/></Response></LSX> |
0x1800050b0 |
<LSX><Response id="%d" sender=""><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"/></Response></LSX> |
0x180005170 |
<LSX><Response id="%d" sender=""><GetSettingResponse Setting="false"/></Response></LSX> |
0x1800051d0 |
<LSX><Response id="%d" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX> |
0x180005230 |
<LSX><Response id="%d" sender=""><IsProgressiveInstallationAvailableResponse ItemId="" Available="false"/></Response></LSX> |
0x1800052b0 |
<LSX><Response id="%d" sender="EbisuSDK"><GetProfileResponse IsSubscriber="true" PersonaId="%llu" AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" UserId="%llu" Persona="%s" IsUnderAge="false" CommerceCurrency="USD"/></Response></LSX> |
0x1800053b0 |
<LSX><Response id="%d" sender=""><InternetConnectedState connected="0"/></Response></LSX> |
0x180005410 |
<LSX><Response id="%d" sender="EALS"><ChallengeAccepted response="%s"/></Response></LSX> |
0x180005470 |
<LSX><Response id="%d" sender="EbisuSDK"><GetConfigResponse Config="false"/></Response></LSX> |
0x1800054d0 |
<LSX><Response id="%d" sender=""><GetGameInfoResponse GameInfo="false"/></Response></LSX> |
0x180005530 |
<LSX><Response id="%d" sender=""><GetSettingResponse Setting="production"/></Response></LSX> |
0x180005590 |
<LSX><Event sender="EALS"><Challenge key="2b8ee7faea76e8a34f5f5d20e5328e32" build="release" version="10,4,13,6637"/></Event></LSX> |
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 <ErrorSuccess Code="0" Description=""/> 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
idis 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.senderis never echoed. It is hard-coded per template, and only three values exist in the entire image:EALS→ Challenge, ChallengeAcceptedEbisuSDK→ GetConfigResponse, GetProfileResponse""(empty) → GetSettingResponse, GetGameInfoResponse, InternetConnectedState, IsProgressiveInstallationAvailableResponse, ErrorSuccess
- Attribute casing (lock this in verbatim):
connectedis lowercase insideInternetConnectedStatewhile every sibling attribute is PascalCase;response=andkey=on the challenge exchange are lowercase;build,version,id,senderlowercase; 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,axis a 16-bit multiply so& 0xFFFFis correct;movzx ecx,bx; add ecx,eaxis a 32-bit add of the zero-extended 16-bitbx.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.fromhexis already case-insensitive, matching the emu'ssscanf_s("%02x").:257/:261 Conn.send_plain/send_enc— both append exactly oneb"\0"andsendallthe whole buffer. Framing is correct, which rules out "our pushed Event was mis-framed and the reader stalled".:294 resp()defaultsender=""— 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 default0x1f89493and the decrypted.dlf<UserId>.PERSONA_NAME = "CAGE"matches the shipped ini (the code default isSTEAMPUNKS;CAGEis right for this install).:122-124 CHALLENGE_KEY/BUILD/VERSION— byte-exact against0x180005590.- The pushed-Event shape
<LSX><Event sender="X"><Y/></Event></LSX>with noidand no<Response>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.
-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=.
- 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.
- 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 <Event> 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 sprintfs 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 version="1.0" encoding="UTF-8" standalone="yes"?><License xmlns="http://ea.com/license"><CipherKey>%s</CipherKey><MachineHash>2b8ee7faea76e8a34f5f5d20e5328e32</MachineHash><ContentId>%d</ContentId><UserId>%d</UserId><GameToken>%s</GameToken><GrantTime>2017-01-01T00:00:00Z</GrantTime><StartTime>2017-01-01T00:00:00Z</StartTime></License>
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:
UserIdin the.dlf(33068179) == the emu'sPersonaIddefault0x1f89493== the shipped iniPersonaId.MachineHash(2b8ee7fa…e32) == the emu's LSX<Challenge key=…>. A fixed build constant, not per-machine, not per-session.- Note the two AES keys are unrelated: License
4132722d…≠ LSXK_FIXED 000102…0f≠ LicenseCipherKey 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:
- No login vocabulary exists.
grep -aoib 'login|isloggedin|authcode|entitlement'over the whole DLL returns zero hits. The.datastring region is fully enumerated in §1.4 — the 12 templates plus the ini path are all of it. No<Login>, noIsLoggedIn, noGetAuthCode, no token, no session concept. connected="0"is a literal with no alternative. The substringsInternetandconnectedoccur at exactly two byte offsets in the entire file (0x41d2,0x41e9), both inside the single template at0x1800053b0. 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 nojcctargeting it. There is noconnected="1"string anywhere in the image.- There is no code location where a Login event could ever be emitted. The response path
0x180002325–0x180002d6fis straight-line with zero content-dependent branches (§1.3). A dispatcher cannot make a decision it has no branch for. - 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", …)). - There is no hidden alternate template set. A relocated 8-entry pointer table exists at
0x180004530(file0x3330–0x3368:0x1800050b0,0x1800051d0,0x1800052b0,0x180005170,0x180005530,0x1800054d0,0x1800052b0,0x1800054d0) but it is dead — no instruction in.textreferences it, and even so it contains only the same offline templates. - 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.
- The loader adds nothing. No sockets (no WS2_32), no memory patching, no online/auth strings. Its
.dlfGameToken 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× <Login IsLoggedIn="true"> push did nothing:
- Framing is confirmed correct.
send(s, buf, strlen(buf)+1, 0)— the NUL is transmitted. Oursend_plain/send_encdo 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
<LSX><Event sender="EALS"><X/></Event></LSX>with noidand no<Response>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 <Event> 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/memlive-probe path (Wine maps the PE flat at0x140000000, no relaunch needed), breakpoint or trace-patch the Origin event dispatcher at0x146f1e060and the senderstrcmpat0x147102880. Then push one<Login IsLoggedIn="true">Event and answer exactly three questions, in order:
- 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.
- If it reaches, what sender string does the
strcmpat0x147102880compare against? Dump the handler table. That convertsLOGIN_EVENT_SENDERSfrom a guessed list into a read one.- If the sender matches, does case 2 at
0x146f1e0abexecute, and doesm_isLoggedInchange? 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)
- [LIVE, highest value] Trace the Origin event dispatcher
0x146f1e060+ senderstrcmp0x147102880while 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/memlive probe (flat map at0x140000000, no relaunch). - [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
<Response>-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. - [Code, low risk] Apply corrections C1–C3 (emu-exact
challenge_response+ tail assert; extractresponse="; buffer partial frames). Small, mechanical, and C1's assert turns a future silent breakage into a loud one. - [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.
- [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
SettingIdmaps toproduction/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. - [Tooling] Build a
.dlfregenerator. AES-128-CBC, IV=0, key4132722dd082efb0dc6457c57668ca09, prepend the fixed 46-byte header302c0214…762800+ zero-pad to0x41, 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). - [Experiment, one line] Corrupt one hex char of
Hand see whether the session still works. The emu never verifies the client'sresponse=, 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 theChallenge keyfrom2b8ee7fa…e32and see whether anything notices — that determines whether we may safely hardcode it across installs. - [Recon, if #1 stalls] Obtain
dbdata.dll. Not in this directory. It is the keygen that mints the 896-byte NucleusGameToken. Learning whether that token is a fixed blob, derived fromUserId, or structurally parseable would tell us whether any of its fields are reusable for BlazepreAuth. - [Recon, low priority] Trial-decrypt
.stp0(0x14001d000,0xec895) with theCipherKey8ec1f298cbee077e27517a07f3478773. Long shot, but if it unpacks it exposes the virtualized encrypt fn0x140003f20and the realgetTableDatacall sequence, removing the last inference in §2.2. - [Hygiene] Fix BRIEF.md — line 46 (three separate GetSetting templates, not one alternation), line 56 (
r0 = rand(), notrand()==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 (aSet*/Notify?). Single biggest unknown in the boot-order table; a live log fills it in for free (step 5). - Is an encrypted mid-session
<Event>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
Responseidagainst 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…e32build-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.dlfcontents (step 6). - What does EbisuSDK do with
CipherKey8ec1f298…? 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).