Files
OpenFUT/fifa17-recon/docs/ENQUEUE_PLAN.md
T
funman300 edab23f04a fifa17-recon: package the working offline FUT backend
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.

Package:
- tools/openfut-fut.sh   one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh      idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
  the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
  FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md         runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md   the reverse-engineering write-ups

All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
2026-08-01 09:12:17 -07:00

318 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.