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

18 KiB
Raw Blame History

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 queueretriever+0x8 and +0x10 are both 0x0 (nothing enqueued). This is the one the brief targets.
  2. No Origin default userOriginSDK[+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 SetError200 (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 failuser 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 != 0SetError(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.


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):

#!/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.