Files
OpenFUT/fifa17-recon/docs/REPACK_INTEL.md
T
funman300 edab23f04a fifa17-recon: package the working offline FUT backend
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
2026-08-01 09:12:17 -07:00

42 KiB
Raw Blame History

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" @ file 0x33f4, "127.0.0.1" @ file 0x3400. 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 accept call site in the whole image (grep -c '# 0x180004198' emu.asm → 1), not in a loop; the listener is closed immediately after (0x1800022bb closesocket). Terminal path shutdown(sock,1) @ 0x180002e89WSACleanup @ 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) then lea r8d,[rax+0x1] / xor r9d,r9d / call [0x180004188]. First instance 0x1800022c8; identical at 0x18000235f, 0x1800023ed, 0x18000249d0x180002e1b.
  • Receive: one blocking recv(sock, buf, 0x1000, 0) per message, into a 0x1000 stack buffer at rbp+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 (QueryEntitlements is 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:

  1. Server pushes the plaintext Challenge, verbatim from .data 0x180005590 — it is a fixed constant, never computed or randomized, and is sent with no sprintf and no call to the encryptor:
    <LSX><Event sender="EALS"><Challenge key="2b8ee7faea76e8a34f5f5d20e5328e32" build="release" version="10,4,13,6637"/></Event></LSX>
    
  2. Client replies plaintext <ChallengeResponse response="<96hex>" key="<32hex>"/>.
  3. Parse function 0x180001f10 does the only string parsing in the entire DLL — two strstr calls, both here (0x180001f5a for response=" @0x1800045e0, +0xA; 0x180001fa6 for key=" @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's response= value is NEVER VERIFIED.
  4. H = hex(AES128ECB(K_FIXED, clientKey[0:16]))hex(AES128ECB(K_FIXED, clientKey[16:32]))clientResponse[64:] — two movaps/expand/encrypt pairs at 0x180001fee and 0x180002024; hex loop 0x1800020700x1800020a7 bounded by cmp rbx,0x20 (32 bytes → 64 chars) via sprintf_s(tmp,3,"%02x") (lowercase, fmt @0x1800045d0) + strcat_s; then the echo strcat_s at 0x1800020a9. Our PKCS7-3-block formula produces the identical 96 hex (§0-B). Keep it; see the diff list for the hardening.
  5. 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: after sprintf_s at 0x180002343 the code goes straight to inlined strlen + send at 0x180002370 with no call to the encryptor 0x180001dc0; first encrypt call is 0x1800023d6, first decrypt 0x1800023a4.
  6. Session key (0x1800020bf0x180002101), derived from the ASCII characters of H, 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,ecx then rep 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: hex2bin 0x180001c50 via sscanf_s("%02x") (so uppercase hex is accepted on input), per-block decrypt, then a validating PKCS#7 strip at 0x180001d6e0x180001d94 (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 0x1800023250x180002d6f 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 0x180002db00x180002e7b 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

  • id is 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.
  • sender is never echoed. It is hard-coded per template, and only three values exist in the entire image:
    • EALS → Challenge, ChallengeAccepted
    • EbisuSDK → GetConfigResponse, GetProfileResponse
    • "" (empty) → GetSettingResponse, GetGameInfoResponse, InternetConnectedState, IsProgressiveInstallationAvailableResponse, ErrorSuccess
  • Attribute casing (lock this in verbatim): connected is lowercase inside InternetConnectedState while every sibling attribute is PascalCase; response= and key= on the challenge exchange are lowercase; build, version, id, sender lowercase; 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 0x180005dd0the 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 (0x180002d3d0x180002d56) 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,ax is a 16-bit multiply so & 0xFFFF is correct; movzx ecx,bx; add ecx,eax is a 32-bit add of the zero-extended 16-bit bx. 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.fromhex is already case-insensitive, matching the emu's sscanf_s("%02x").
  • :257/:261 Conn.send_plain/send_enc — both append exactly one b"\0" and sendall the whole buffer. Framing is correct, which rules out "our pushed Event was mis-framed and the reader stalled".
  • :294 resp() default sender="" — 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 default 0x1f89493 and the decrypted .dlf <UserId>. PERSONA_NAME = "CAGE" matches the shipped ini (the code default is STEAMPUNKS; CAGE is right for this install).
  • :122-124 CHALLENGE_KEY/BUILD/VERSION — byte-exact against 0x180005590.
  • The pushed-Event shape <LSX><Event sender="X"><Y/></Event></LSX> with no id and 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. WinMain0x140003cc0 (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!getTableDatathe 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:

  • UserId in the .dlf (33068179) == the emu's PersonaId default 0x1f89493 == the shipped ini PersonaId.
  • 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… ≠ LSX K_FIXED 000102…0f ≠ License CipherKey 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:

  1. No login vocabulary exists. grep -aoib 'login|isloggedin|authcode|entitlement' over the whole DLL returns zero hits. The .data string region is fully enumerated in §1.4 — the 12 templates plus the ini path are all of it. No <Login>, no IsLoggedIn, no GetAuthCode, no token, no session concept.
  2. connected="0" is a literal with no alternative. The substrings Internet and connected occur at exactly two byte offsets in the entire file (0x41d2, 0x41e9), both inside the single template at 0x1800053b0. 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 no jcc targeting it. There is no connected="1" string anywhere in the image.
  3. There is no code location where a Login event could ever be emitted. The response path 0x1800023250x180002d6f is straight-line with zero content-dependent branches (§1.3). A dispatcher cannot make a decision it has no branch for.
  4. 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", …)).
  5. There is no hidden alternate template set. A relocated 8-entry pointer table exists at 0x180004530 (file 0x33300x3368: 0x1800050b0, 0x1800051d0, 0x1800052b0, 0x180005170, 0x180005530, 0x1800054d0, 0x1800052b0, 0x1800054d0) but it is dead — no instruction in .text references it, and even so it contains only the same offline templates.
  6. 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.
  7. The loader adds nothing. No sockets (no WS2_32), no memory patching, no online/auth strings. Its .dlf GameToken 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. Our send_plain/send_enc do 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 no id and 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/mem live-probe path (Wine maps the PE flat at 0x140000000, no relaunch needed), breakpoint or trace-patch the Origin event dispatcher at 0x146f1e060 and the sender strcmp at 0x147102880. Then push one <Login IsLoggedIn="true"> Event and answer exactly three questions, in order:

  1. 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.
  2. If it reaches, what sender string does the strcmp at 0x147102880 compare against? Dump the handler table. That converts LOGIN_EVENT_SENDERS from a guessed list into a read one.
  3. If the sender matches, does case 2 at 0x146f1e0ab execute, and does m_isLoggedIn change? 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)

  1. [LIVE, highest value] Trace the Origin event dispatcher 0x146f1e060 + sender strcmp 0x147102880 while 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/mem live probe (flat map at 0x140000000, no relaunch).
  2. [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.
  3. [Code, low risk] Apply corrections C1C3 (emu-exact challenge_response + tail assert; extract response="; buffer partial frames). Small, mechanical, and C1's assert turns a future silent breakage into a loud one.
  4. [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.
  5. [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 SettingId maps to production/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.
  6. [Tooling] Build a .dlf regenerator. AES-128-CBC, IV=0, key 4132722dd082efb0dc6457c57668ca09, prepend the fixed 46-byte header 302c0214…762800 + zero-pad to 0x41, 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).
  7. [Experiment, one line] Corrupt one hex char of H and see whether the session still works. The emu never verifies the client's response=, 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 the Challenge key from 2b8ee7fa…e32 and see whether anything notices — that determines whether we may safely hardcode it across installs.
  8. [Recon, if #1 stalls] Obtain dbdata.dll. Not in this directory. It is the keygen that mints the 896-byte Nucleus GameToken. Learning whether that token is a fixed blob, derived from UserId, or structurally parseable would tell us whether any of its fields are reusable for Blaze preAuth.
  9. [Recon, low priority] Trial-decrypt .stp0 (0x14001d000, 0xec895) with the CipherKey 8ec1f298cbee077e27517a07f3478773. Long shot, but if it unpacks it exposes the virtualized encrypt fn 0x140003f20 and the real getTableData call sequence, removing the last inference in §2.2.
  10. [Hygiene] Fix BRIEF.md — line 46 (three separate GetSetting templates, not one alternation), line 56 (r0 = rand(), not rand()==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 (a Set*/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 12).
  • Does FIFA validate the Response id against 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…e32 build-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 .dlf contents (step 6).
  • What does EbisuSDK do with CipherKey 8ec1f298…? 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).