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
This commit is contained in:
funman300
2026-08-01 09:12:17 -07:00
parent 1fb664710a
commit edab23f04a
26 changed files with 7909 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
# FIFA17 First-Party Auth Enqueue — Forge & Trigger Plan
Clean-room synthesis of four independent reverses of the `FifaOnline::FirstPartyAuthTokenRetriever::DoTick`
auth-code path. Every byte/offset below is backed by a decrypted-code VA or a live `/proc/13643/mem`
read. Live pid at time of writing: **13643** (`pgrep -x FIFA17.exe`).
## TL;DR — two blockers, not one
The brief assumed the only problem is `retriever+0x8 == NULL`. It is not. There are **two** hard
gates, both live-verified this run:
1. **Empty queue**`retriever+0x8` and `+0x10` are both `0x0` (nothing enqueued). *This is the one
the brief targets.*
2. **No Origin default user**`OriginSDK[+0x3a0] == 0x0`. `DoTick` calls `GetDefaultUser()` which
returns that slot, then `OriginRequestAuthCodeSync` **rejects any request whose user is NULL**
(`test rdx,rdx; je fail` → returns `0xa2000003`, nothing hits the wire). So even a perfectly forged
node produces only an "Origin Error(a2000003)" log line unless we also set `OriginSDK[+0x3a0]`.
**Forging the node alone is necessary but not sufficient. You must set BOTH.** Every plan below has
"set `OriginSDK[+0x3a0]` non-null" as step 0.
Live confirmation (this run):
```
OnlineMgr *[0x1448a3b20] = 0x43dc3e70
retriever = +0x4e98 = 0x43dc8d08
+0x00 vptr = 0x1438f5d50 (retriever vtable, 1 real slot = deleting dtor)
+0x08 queue slot 0 = 0x0 <-- write &node here
+0x10 queue slot 1 = 0x0
guard byte [0x1448a3ac3] = 0x01 (enqueue-wrapper guard PASSES, not the blocker)
OriginSDK *[0x144b7c7a0] = 0x25c98c50
+0x3a0 defaultUser = 0x0 <-- BLOCKER: must be non-null
node vtable 0x1438f5d58: [0]AddRef 0x147e8f160 [1]Release 0x147e1c480 [2]dtor 0x146f028d0
ret gadget [0x1470e3567] = c3
```
Note there are **two different "OriginMgr" singletons** — do not confuse them:
- `*[0x1448acf50]` → login-state OriginMgr (`m_isLoggedIn` @ +0x13; the one `force_login_flag.py` pins).
- `*[0x144b7c7a0]` → OriginSDK object (default-user @ +0x3a0; the one THIS path needs).
---
## 1. `FirstPartyAuthCodeFutureImpl` NODE STRUCT
Size **0xF0 (240 bytes)** — from the enqueue allocation constant
(`0x146f5b916 mov edx,0xc390a20f; lea edx,[rdx+0x3c6f5ee1]` = `0xF0`) and matched by the ctor
`0x146eeecd0`. The clientId capacity `0x40` comes from the same ctor (`r8d = 0xc390a20f + 0x3c6f5e31 = 0x40`).
| Offset | Type | Meaning | Ctor init | Read/written by | Forge value (minimal) |
|---|---|---|---|---|---|
| `+0x00` | `void**` | primary vtable | `0x1438f5d58` | DoTick calls `[vptr+8]`=Release at end | **`0x1438f5d58`** (real) |
| `+0x08` | `void**` | secondary vtable (base) | `0x1438f5d90` | dtor adjustor thunk only | `0x1438f5d90` (real) or `0` |
| `+0x10` | `u32` | atomic refcount | `0` (xchg) | AddRef/Release | **`2`** (see refcount note) |
| `+0x14` | `u32` | pad | — | — | `0` |
| `+0x18` | `char[0x40]` | **ClientId** (inline C-string) | `strncpy(+0x18,arg,0x40)` | DoTick `lea rdx,[rsi+0x18]` → passed as `const char*`; Origin deref's byte-wise, must be non-empty | **`"FIFA17PC\0"`** (any non-empty; see Q) |
| `+0x58` | `char[0x80]` | message/error buffer | `[+0x58]=0` | `SetError` vsnprintf's here (cap 0x80, ends at 0xD8) | `0` |
| `+0xD8` | `char*` | **authCode result** (heap) | `0` | DoTick success: `mov [rsi+0xd8],rax`; dtor frees it | `0` |
| `+0xE0` | `u32` | status/error code | `0` | `SetError``200 (0xC8)` on failure | `0` |
| `+0xE4` | `u32` | kind/userIndex | `= ctor arg2` | wrapper always passes `0` | `0` |
| `+0xE8` | `u8` | **isComplete / poll flag** | `0` | DoTick sets `1` on BOTH success and failure | `0` |
| `+0xE9`..`+0xEF` | pad | — | — | — | `0` |
There is **NO `next` pointer.** The "queue" at `retriever+0x8` is a **fixed 2-slot array** of
ref-counted node pointers, not a linked list. DoTick iterates the two slots with `lea rbx,[rcx+8];
mov ebp,2; ... add rbx,8; dec rbp; jne`. No node field is ever chased as a link. (Confirmed:
`0x146f199cd/d1/e0` and tail `0x146f19ae1/e5/e8`.)
**Node vtable `0x1438f5d58`** (real, live-read):
`[0]`AddRef `0x147e8f160` · `[1]`Release `0x147e1c480` · `[2]`dtor `0x146f028d0` ·
`[5]`GetResult `0x1466cc0d0` (`mov rax,[rcx+0xd8];ret`) · `[4]`GetStatus `0x1471a0630`
(`mov eax,[rcx+0xe0];ret`).
### Minimal forged node — exact 240 bytes (little-endian)
```
off bytes meaning
0x00 58 5d 8f 43 01 00 00 00 vptr = 0x1438f5d58
0x08 90 5d 8f 43 01 00 00 00 vptr2 = 0x1438f5d90
0x10 02 00 00 00 refcount = 2 (survives one Release, never freed)
0x14 00 00 00 00 pad
0x18 46 49 46 41 31 37 50 43 00.. clientId = "FIFA17PC", NUL, rest 0 (fills to 0x58)
0x58 00 * 0x80 message buffer = 0
0xD8 00 00 00 00 00 00 00 00 authCode = 0
0xE0 00 00 00 00 status = 0
0xE4 00 00 00 00 kind = 0
0xE8 00 isComplete = 0
0xE9 00 * 7 pad to 0xF0
```
**Refcount note (important).** DoTick unconditionally ends each processed slot with
`mov rcx,[rbx]; mov [rbx],0; mov rax,[rcx]; call [rax+8]` = **Release** (`0x147e1c480`, `lock xadd`
decrement of `[node+0x10]`; on reaching zero it invokes the dtor which `free()`s the node via the
game allocator `0x1453370b0`). If you forge with **refcount = 1**, DoTick decrements to 0 and tries to
**free your node** — safe only if the node lives in game-allocator memory, a crash otherwise. Forge
**refcount = 2**: after Release it is 1, never freed. Costs a ~240-byte leak, zero crash risk.
(Alternative: use a synthetic vtable whose slot `[1]` is the ret gadget `0x1470e3567` — then Release
is a no-op and refcount is irrelevant; but the real vtable + refcount=2 is simpler and keeps the
GetResult/GetStatus accessors valid if anything polls.)
**Unknowns (marked):**
- The **real ClientId string** the game would use is unrecovered (the natural enqueue never runs live).
For our local LSX responder any non-empty string is accepted by `<GetAuthCode>`. For a genuine EA
endpoint the correct Nucleus client_id would be required. Since OpenFUT answers LSX locally, `"FIFA17PC"`
(or whatever our responder keys on) is fine.
- Whether the deeper LSX marshalling inside `0x1470e67f0` dereferences **user** object fields beyond the
null/equality check. The traced send path builds the request from the SDK object + clientId and does
**not** deref the user, but this was not exhaustively followed past the dispatch. Mitigation: set
`OriginSDK[+0x3a0]` to a real readable pointer (the SDK object itself) rather than a bare `1`.
---
## 2. DoTick PROCESSING — end to end (`0x146f199c0`)
Per slot `i` in `{+0x08, +0x10}`:
1. `rsi = *slot`. If NULL → skip (`je 0x146f19ae1`). *(Live: both NULL → does nothing, forever.)*
2. Zero two stack out-slots `[rsp+0x60]` (authCode out) and `[rsp+0x58]` (length out).
3. `call OriginGetDefaultUser()` (`0x1470da6d0`, zero-arg) → returns `OriginSDK[+0x3a0]` or NULL.
Verified: `0x1470da6f4 call 0x1470e3560 (→ *[0x144b7c7a0]); 0x1470da6f9 mov rax,[rax+0x3a0]; ret`.
4. `call OriginRequestAuthCodeSync(user=rax, clientId=&node[0x18], &outAuthCode=r8, &outLen=r9, scope=0)`
(`0x1470db3c0`, `146f19a05 lea rdx,[rsi+0x18]`, `146f19a0c mov [rsp+0x20],r14`=0 scope). The wrapper
forwards to the real impl `0x1470e67f0`, which:
- `test rdx,rdx; je fail` and `cmp rdx,[rcx+0x3a0]; jne fail` — **user must be non-NULL and == the
SDK default user** (both are the same slot, so any non-null value is self-consistent). On failure
returns `0xa2000003`, **no send**.
- clientId must be non-empty (`cmp byte[r8],0`), copies it into `LSXRequest+0x10`.
- builds the `Origin::LSXRequest<lsx::GetAuthCodeT,...>`, **transmits it** (`call [0x148e219f8]`),
registers the pending future in the SDK reqId-keyed map (`0x1470e6540`), writes future→out, reqId→out.
*This is the point `<GetAuthCode ClientId Scope>` goes on the LSX wire.*
5. DoTick inspects the result:
- `rc != 0``SetError(node, 200, "[%s] Origin Error(%d)\n", ".::DoTick", rc)` → writes `node+0xE0=200`,
`node+0xE8=1`, message into `node+0x58`.
- `rc==0 && (outAuthCode==0 || outLen==0)``SetError(node,200,"[%s] Invalid authcode\n",...)`.
- success → alloc `outLen+1` from `*[0x1448a20b8]` (vt+0x38), `mov [node+0xD8]=buf`,
`strlcpy(buf,outAuthCode)` (`0x145e27a50`), `mov byte[node+0xE8]=1`.
6. **Dequeue + release (all paths):** `mov rcx,[rbx]; mov [rbx],0` (NULL the slot) then
`mov rax,[rcx]; call [rax+8]` = Release. Each enqueued request is consumed in exactly one tick;
there is no retry/pending state.
DoTick's only caller is the per-frame online-subsystem tick `0x146f7b279`
(`lea rcx,[rsi+0x4e98]; call 0x146f199c0`), so a forged node is picked up on the **next frame**.
---
## 3. THE PLAN (ranked by likelihood-of-success × safety)
### STEP 0 (all plans): set the Origin default user — REQUIRED
```
OriginSDK = *[0x144b7c7a0] # live 0x25c98c50
write 8 bytes at OriginSDK+0x3a0 = OriginSDK # a real, readable, self-consistent non-null pointer
```
Writing the SDK object's own address (rather than a bare `0x1`) satisfies the null + equality checks
**and** points at valid memory in case anything downstream deref's the "user". GetDefaultUser and the
impl both read the same slot, so equality always holds.
---
### (a) PRIMARY — FORGE a node via `/proc/mem` and set `retriever+0x8` ★ recommended
Pure memory writes, no code execution, no Win64/SysV ABI hazard. Matches the brief exactly.
**Steps**
1. Do STEP 0.
2. Pick a **scratch VA** inside FIFA to host the 240-byte node — a currently-zero, unreferenced,
writable region (see "live items", §4). Call it `NODE`.
3. Write the 240-byte forged node (bytes in §1) at `NODE`.
4. Write `NODE` (8 bytes) into `retriever+0x8` = `0x43dc8d10`.
5. Watch `/tmp/lsx.log` for the `<GetAuthCode ClientId="FIFA17PC" .../>` request on the next frame.
**Recipe (style of `force_login_flag.py`):**
```python
#!/usr/bin/env python3
# forge_node.py — forge a FirstPartyAuthCodeFutureImpl and enqueue it. ptrace_scope=0 required.
import struct, glob, os
ONLINEMGR_PP = 0x1448a3b20 # *-> OnlineManager
RETR_OFF = 0x4e98 # +retriever
SDK_PP = 0x144b7c7a0 # *-> OriginSDK
SDK_DEFUSER = 0x3a0 # OriginSDK default-user slot (BLOCKER)
VPTR = 0x1438f5d58
VPTR2 = 0x1438f5d90
CLIENTID = b"FIFA17PC"
def pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d+'/comm').read().strip()=='FIFA17.exe': return int(d.split('/')[-1])
except: pass
raise SystemExit("FIFA17.exe not found")
def build_node():
b = bytearray(0xF0)
struct.pack_into('<Q', b, 0x00, VPTR)
struct.pack_into('<Q', b, 0x08, VPTR2)
struct.pack_into('<I', b, 0x10, 2) # refcount=2 -> never freed
b[0x18:0x18+len(CLIENTID)] = CLIENTID # clientId, NUL-terminated (rest already 0)
return bytes(b)
def main():
p = pid(); f = open(f"/proc/{p}/mem","r+b")
rq = lambda va:(f.seek(va), struct.unpack('<Q', f.read(8))[0])[1]
onlinemgr = rq(ONLINEMGR_PP); retr = onlinemgr + RETR_OFF
sdk = rq(SDK_PP)
# STEP 0: default user
f.seek(sdk+SDK_DEFUSER); f.write(struct.pack('<Q', sdk))
print(f"[+] OriginSDK={sdk:#x} default-user set -> {sdk:#x}")
# NODE scratch VA — MUST be a validated unused writable region (see plan §4).
NODE = int(os.environ.get("NODE_VA","0"),16)
if not NODE: raise SystemExit("set NODE_VA=<hex scratch VA>")
f.seek(NODE); f.write(build_node())
print(f"[+] node forged @ {NODE:#x} (clientId={CLIENTID!r})")
# enqueue: retriever+0x8 = &node
f.seek(retr+0x08); f.write(struct.pack('<Q', NODE))
print(f"[+] retriever+0x8 ({retr+0x08:#x}) -> {NODE:#x}. Watch /tmp/lsx.log for <GetAuthCode>.")
if __name__=='__main__': main()
```
**Crash risks**
- *Scratch provenance*: if `NODE` overlaps live game memory, DoTick's writes to `+0xD8/+0xE8` (and any
poller) corrupt it. Mitigate by validating the region is zero + unreferenced (§4).
- *Refcount*: refcount=2 avoids the terminal free entirely — do **not** use 1 unless `NODE` is game-alloc.
- *Deeper user deref*: covered by pointing `+0x3a0` at the real SDK object.
- *Race*: DoTick runs every frame; write the node bytes **before** setting `retriever+0x8` (the script
does), so a mid-write tick never sees a half-built node.
**Success signal**: a single `<GetAuthCode ClientId="FIFA17PC" .../>` LSXRequest on `/tmp/lsx.log`
within one frame; on failure instead expect an "Origin Error(a2000003)" trace (means STEP 0 didn't take)
or "Invalid authcode" (means our LSX responder returned empty).
---
### (a) SAFE VARIANT — let the game allocate the node (hybrid forge) ★ safest memory-wise
Instead of hosting the node in scratch memory, call the game's own enqueue
`RequestFirstPartyAuthCode(clientId)` = **`0x146f57bf0`** (guard byte `[0x1448a3ac3]` already `1`, so it
resolves `retriever = mgr+0x4e98` correctly and stores into the first free slot). This allocates a
proper 0xF0 node from the game allocator, ctors it, AddRefs, and inserts it — DoTick then processes it
with the real vtable and correct refcount/free, and wires the future back into the retriever slot.
This removes the scratch-provenance problem entirely but requires a **call** (see (b) for the Win64 ABI
caveat). Signature: `void** RequestFirstPartyAuthCode(const char* clientId /*rcx*/)`. Still needs STEP 0.
---
### (b) DIRECT CALL via gdb — fire the send without forging
Two call targets, both Win64 `__fastcall`:
- **Enqueue** `0x146f57bf0` `RequestFirstPartyAuthCode(const char* clientId /*rcx*/)` — the (a) route;
correct, wires the future into the retriever.
- **Raw sync sender** `0x1470db3c0`:
```
int32 OriginRequestAuthCodeSync(
rcx void* user, // must be !=0 AND == OriginSDK[+0x3a0] (== GetDefaultUser())
rdx const char* clientId, // non-empty
r8 void** pOutFuture,// out, non-null
r9 uint64* pOutReqId, // out, non-null
[rsp+0x28] const char* scope // optional, pass 0
) -> 0 ok / 0xa2000003 (bad user) / 0xa2000004 (null out-ptr)
```
Direct-call recipe: STEP 0, then `clientId="FIFA17PC"`; zero `outFuture,outReqId`; `rcx=OriginSDK[+0x3a0]`,
`rdx=&clientId`, `r8=&outFuture`, `r9=&outReqId`, `[rsp+0x28]=0`.
**Crash / correctness risks**
- **ABI mismatch (the big one):** FIFA17.exe is a Win64 PE under Wine (args `rcx/rdx/r8/r9`+stack); host
gdb `call` uses SysV (`rdi/rsi/rdx/rcx`). A naive `call` passes args in the wrong registers → garbage
user/clientId → fault or `0xa2000003`. Use a **forged thread context** (stop a thread, set `rip` to the
target with Win64 regs + 5th arg pushed + a return trap) or a small written trampoline, not `call`.
- The **raw sync** call fires GetAuthCode but the future lands in **your** out-param, not the retriever
node — it validates the LSX path but does **not** advance FIFA login. The **enqueue** call (0x146f57bf0)
does advance it. Prefer the enqueue.
- Re-entrancy: calling on a paused thread mid-DoTick could double-process; run when the online tick is idle.
**Success signal**: same `<GetAuthCode>` on `/tmp/lsx.log`. For the enqueue call, also expect `node+0xE8`
to flip to `1` on the following frame.
---
### (c) FIX THE REAL SKIP REASON — make FIFA enqueue naturally (cleanest, hardest)
Why the game never enqueues, root-caused to three independent walls (all live-verified or high-conf):
1. **No default user** (`OriginSDK[+0x3a0]==0`). It is populated only by the SDK connect/user-query
round trip at `0x1470e5ad5` (guarded by `0x147118d80` after a ~15 s connect-wait loop; nearby literal
"EbisuSDK"). If that LSX exchange never yields a user, the slot stays NULL and neither the natural
enqueue nor the auth send can proceed. **Fixing this legitimately (our LSX responder answering the
user-query so `+0x3a0` gets set) would unblock BOTH gates at once — the cleanest of all outcomes.**
2. **LoginStatePCLogin sub-state 0 is a hardcoded stub.** `(*[0x144b86bf0])->vt[0x60]` = `0x146f82070`
= `xor eax,eax; ret` for the live class → the state always returns NULL and falls to the
`TXT_NOT_LOGIN_TO_EBISU` branch (`0x1471b5b64`, sets `TXT_NOT_LOGIN_TO_EBISU` @ `0x1439633e8`, sub-state 1).
*Confidence medium* — needs the jump-table decode at `0x141e7f55c` to confirm index-0 mapping.
3. **Blaze-SDK's own auth-code fetchers** (`0x147237340` "blazeServerClientId" / `0x147237440`
"blazeSdkClientId") both bail at `mov rcx,[rax+0x750]; test rcx,rcx; je` — the **client-config object**
our empty `fetchClientConfig` responses never populate. Populating client-config would let this second,
retriever-independent path call `OriginRequestAuthCodeSync` directly.
**Recommended natural-fix track**: make our LSX responder answer the Origin user-query so `0x1470e5ad5`
writes `OriginSDK[+0x3a0]`, then supply a non-empty `fetchClientConfig` so `[cfg+0x750]` is non-NULL.
That is a server-side change (no memory patching) and would let FIFA drive the whole flow itself.
**Risk**: highest reverse-effort; may reveal further downstream gates (Blaze login after GetAuthCode).
---
## 4. STILL NEEDS A LIVE DUMP / EXPERIMENT
1. **Scratch VA for plan (a).** Need a validated **unused, zero, writable** ≥0x100-byte region in FIFA's
maps to host the forged node (candidates: an anon `rw` mapping with a long zero run; verify it stays
zero across several frames = unreferenced). Or sidestep entirely with plan (a)/(b) using the game
allocator. This is the one blocker to running (a) as-is.
2. **Does the transmit fp `*0x148e219f8` write the LSX socket synchronously**, or does `0x1470e6540` only
register the future while a separate pump thread flushes it? Determines whether a one-shot forced
enqueue puts bytes on the wire in the same frame.
3. **Does `0x1470e67f0` deref user fields** past the null/equality guard (deeper marshalling at
`0x1470dbfa0/0x147117fe0/0x1471186f0`)? If yes, `OriginSDK[+0x3a0]` must point at a *shaped* user
object, not just the SDK. Dump those before relying on the self-pointer trick.
4. **Real ClientId** the game/our LSX handler expects — confirm our responder's `<GetAuthCode>` handler
accepts an arbitrary non-empty string (expected: yes) or keys on a specific value.
5. **Confirm the natural-fix chain**: after our LSX responder answers the user-query, verify
`OriginSDK[+0x3a0]` actually becomes non-NULL live (proves gate #1 is server-fixable) and that
`fetchClientConfig` content lands at `[cfg+0x750]`.
6. **reqId width** written to `pOutReqId` (`[req+0xc8]`, appears 64-bit) — needed so a forged/emulated
response correlates with the request.
+399
View File
@@ -0,0 +1,399 @@
# 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)` @ `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) then `lea r8d,[rax+0x1]` / `xor r9d,r9d` / `call [0x180004188]`. First instance `0x1800022c8`; identical at `0x18000235f`, `0x1800023ed`, `0x18000249d``0x180002e1b`.
* **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 `0x180002070``0x1800020a7` 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 (`0x1800020bf``0x180002101`), 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 `0x180001d6e``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 `recv`s, `decrypt`s 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
* **`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 `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,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.
```diff
-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=`.
```diff
- 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.
```diff
- 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 `sprintf`s 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
<?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 `0x180002325``0x180002d6f` 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 `0x3330``0x3368`: `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).