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.