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:
@@ -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.
|
||||
@@ -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 C1–C3** (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 1–2).
|
||||
* **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).
|
||||
Reference in New Issue
Block a user