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
18 KiB
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:
- Empty queue —
retriever+0x8and+0x10are both0x0(nothing enqueued). This is the one the brief targets. - No Origin default user —
OriginSDK[+0x3a0] == 0x0.DoTickcallsGetDefaultUser()which returns that slot, thenOriginRequestAuthCodeSyncrejects any request whose user is NULL (test rdx,rdx; je fail→ returns0xa2000003, nothing hits the wire). So even a perfectly forged node produces only an "Origin Error(a2000003)" log line unless we also setOriginSDK[+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 oneforce_login_flag.pypins).*[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
0x1470e67f0dereferences 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: setOriginSDK[+0x3a0]to a real readable pointer (the SDK object itself) rather than a bare1.
2. DoTick PROCESSING — end to end (0x146f199c0)
Per slot i in {+0x08, +0x10}:
rsi = *slot. If NULL → skip (je 0x146f19ae1). (Live: both NULL → does nothing, forever.)- Zero two stack out-slots
[rsp+0x60](authCode out) and[rsp+0x58](length out). call OriginGetDefaultUser()(0x1470da6d0, zero-arg) → returnsOriginSDK[+0x3a0]or NULL. Verified:0x1470da6f4 call 0x1470e3560 (→ *[0x144b7c7a0]); 0x1470da6f9 mov rax,[rax+0x3a0]; ret.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 impl0x1470e67f0, which:test rdx,rdx; je failandcmp 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 returns0xa2000003, no send.- clientId must be non-empty (
cmp byte[r8],0), copies it intoLSXRequest+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.
- DoTick inspects the result:
rc != 0→SetError(node, 200, "[%s] Origin Error(%d)\n", ".::DoTick", rc)→ writesnode+0xE0=200,node+0xE8=1, message intonode+0x58.rc==0 && (outAuthCode==0 || outLen==0)→SetError(node,200,"[%s] Invalid authcode\n",...).- success → alloc
outLen+1from*[0x1448a20b8](vt+0x38),mov [node+0xD8]=buf,strlcpy(buf,outAuthCode)(0x145e27a50),mov byte[node+0xE8]=1.
- Dequeue + release (all paths):
mov rcx,[rbx]; mov [rbx],0(NULL the slot) thenmov 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
- Do STEP 0.
- 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. - Write the 240-byte forged node (bytes in §1) at
NODE. - Write
NODE(8 bytes) intoretriever+0x8=0x43dc8d10. - Watch
/tmp/lsx.logfor 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
NODEoverlaps 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
NODEis game-alloc. - Deeper user deref: covered by pointing
+0x3a0at 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
0x146f57bf0RequestFirstPartyAuthCode(const char* clientId /*rcx*/)— the (a′) route; correct, wires the future into the retriever. - Raw sync sender
0x1470db3c0:Direct-call recipe: STEP 0, thenint32 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)clientId="FIFA17PC"; zerooutFuture,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 gdbcalluses SysV (rdi/rsi/rdx/rcx). A naivecallpasses args in the wrong registers → garbage user/clientId → fault or0xa2000003. Use a forged thread context (stop a thread, setripto the target with Win64 regs + 5th arg pushed + a return trap) or a small written trampoline, notcall. - 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):
- No default user (
OriginSDK[+0x3a0]==0). It is populated only by the SDK connect/user-query round trip at0x1470e5ad5(guarded by0x147118d80after 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+0x3a0gets set) would unblock BOTH gates at once — the cleanest of all outcomes. - LoginStatePCLogin sub-state 0 is a hardcoded stub.
(*[0x144b86bf0])->vt[0x60]=0x146f82070=xor eax,eax; retfor the live class → the state always returns NULL and falls to theTXT_NOT_LOGIN_TO_EBISUbranch (0x1471b5b64, setsTXT_NOT_LOGIN_TO_EBISU@0x1439633e8, sub-state 1). Confidence medium — needs the jump-table decode at0x141e7f55cto confirm index-0 mapping. - Blaze-SDK's own auth-code fetchers (
0x147237340"blazeServerClientId" /0x147237440"blazeSdkClientId") both bail atmov rcx,[rax+0x750]; test rcx,rcx; je— the client-config object our emptyfetchClientConfigresponses never populate. Populating client-config would let this second, retriever-independent path callOriginRequestAuthCodeSyncdirectly.
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
- 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
rwmapping 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. - Does the transmit fp
*0x148e219f8write the LSX socket synchronously, or does0x1470e6540only 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. - Does
0x1470e67f0deref user fields past the null/equality guard (deeper marshalling at0x1470dbfa0/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. - 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. - 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 thatfetchClientConfigcontent lands at[cfg+0x750]. - reqId width written to
pOutReqId([req+0xc8], appears 64-bit) — needed so a forged/emulated response correlates with the request.