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
37 KiB
FUT / UTAS (RS4) CONNECT PLAN — FIFA 17, clean-room
Synthesis of four independent reversals of CardsDLL_Win64_retail.dll (PE base 0x180000000,
live-mapped at 0x6ffffc140000, slide 0x6FFE7C140000) plus live /proc/932452/mem reads and
/tmp/blaze_responder.log / /tmp/roster_server.log.
Scope: get FUT to open a socket to us, authenticate, and stop showing "error connecting to
FIFA 17 Ultimate Team". Not full playable FUT.
Headline correction to BRIEF8. The brief's hypothesis ("we serve the base-URL keys EMPTY, so CardsDLL can't build a URL") is wrong. The URL is built fine. CardsDLL falls back to a hardcoded default base URL and that host is dead:
default base = "http://easw.easports.com:8099/" .rdata file 0x21d680 = VA 0x18021e280
default ptr = VA 0x1802caa08 -> live 0x6ffffc35e280 -> same string
live descriptors: Counter({(b'http://easw.easports.com:8099/', ks=0): 126}) # ALL 126 entries
$ getent hosts easw.easports.com -> rc=2 (NXDOMAIN), no /etc/hosts entry
So resolution succeeds, DNS fails, connect() is never reached, no SYN — exactly the observed
symptom. The in-process telemetry ring confirms the flow ran and died with no HTTP status at all:
{"type":"utas","status":"start_flow"} then {"type":"utas","status":"error","status_code":"0"}.
This changes the fix from "make a URL exist" to "point the URL at us" — and it opens a zero-config
fallback (§2.0): just make easw.easports.com resolve to 127.0.0.1 and listen on :8099.
1. THE URL + AUTH CHAIN
1.1 Two tables drive everything
Module (group) table — 48 entries, VA 0x18021df80, stride 0x10 = {char* pathTemplate; char* NAME}
(dumped from the file; entries 45–47 have a NULL path):
| idx | path template | NAME | idx | path template | NAME |
|---|---|---|---|---|---|
| 0 | ut/%s/auctionhouse |
AUCTIONHOUSE | 24 | ut/%s/season/%%s/reset |
SEASONRESET |
| 1 | ut/%s/clubUser |
CLUB_USER | 25 | ut/%s/season/friendly |
FRIENDLYSEASON |
| 2 | ut/%s/user/list |
CLUB_INFO | 26 | ut/%s/purchased |
PURCHASED |
| 3 | ut/%s/club |
CLUB | 27 | ut/%s/store |
STORE |
| 4 | ut/%s/defid |
DREAM | 28 | ut/%s/watchList |
WATCHLIST |
| 5 | ut/%s/squad |
SQUAD | 29 | ut/delete/%s/watchList |
DELETEWATCHLIST |
| 6 | ut/delete/%s/squad |
DELETE_SQUAD | 30 | ut/%s/tradePile |
TRADEPILE |
| 7 | ut/%s/leaderboards/options |
LBOPTIONS | 31 | ut/%s/trade |
TRADE |
| 8 | ut/%s/leaderboards |
LBDEFAULT | 32 | ut/delete/%s/trade |
DELETETRADE |
| 9 | ut/%s/activeMessage |
PAFPRACTICE | 33 | ut/%s/marketdata |
MARKETDATA |
| 10 | ut/%s |
UT | 34 | ut/%s/clientdata |
CLIENTDATA |
| 11 | ut/%s/user |
USER | 35 | ut/auth |
AUTH |
| 12 | ut/delete/%s/user |
DELETEUSER | 36 | ut/delete/auth |
DELETE_AUTH |
| 13 | ut/%s/item |
ITEMS | 37 | ut/%s/phishing |
PHISHING |
| 14 | ut/%s/item/resource |
ITEMS_BY_RES | 38 | ut/%s/captcha |
CAPTCHA |
| 15 | ut/delete/%s/item |
DELETEITEMS | 39 | ut/%s/tfa |
TFA |
| 16 | ut/%s/match |
MATCH | 40 | ut/%s/squad/mode |
SQUADMODE |
| 17 | ut/%s/sbs |
SBC | 41 | ut/%s/draft/mode |
DRAFT |
| 18 | ut/%s/tournament |
TOURNAMENT | 42 | ut/%s/champion |
CHAMPIONS |
| 19 | ut/%s/tournament/user |
TOURNAMENTUSER | 43 | ut/v2/%s/store |
V2STORE |
| 20 | ut/delete/%s/tournament/user |
TOURNAMENTQUIT | 44 | ut/%s/livemessage |
LIVEMESSAGE |
| 21 | ut/%s/season |
SEASON | 45 | (null) | ADMIN |
| 22 | ut/%s/season/user |
SEASONUSER | 46 | (null) | DEBUG |
| 23 | ut/%s/season/%%s/user |
SEASONUSER_ALTER | 47 | (null) | MAINTENANCE |
Call (endpoint) descriptor table — 125 live entries + terminator, VA 0x1802caa20, stride 0x30:
+0x00 char* callName e.g. "GetSettings"
+0x08 u64 moduleIdx index into the 48-entry table above
+0x10 char* CALL_TAG e.g. "GETSETTINGS" (uppercase, used in the config key)
+0x18 char* baseUrl <-- what resolve() writes; live = the easw default for all 126
+0x20 u32 preAuthFlag 1 for GetSettings + the market/IS* calls, 0 otherwise
+0x21 u8 killSwitch live = 0 for all 126
+0x28 fnptr setKillSwitch one thunk per call, e.g. 0x180124020 writes 0x1802cae31
Verified entries relevant to boot: 21 GetSettings mod=10 UT flag=1, 22 Authentication mod=35 AUTH,
23 Login mod=11 USER, 24 Logout mod=36 DELETE_AUTH, 27 CreateUser mod=11 USER,
28 GetUserInfo mod=11 USER, 35 GetUserCredits mod=11, 36 GetUserData mod=11,
14 LoadActiveSquad mod=5 SQUAD, 54 KeepAlive mod=16 MATCH, 100 GetHubData mod=10 UT,
101 GetUserMassInfo mod=10 UT, 5 GetClubInfo mod=2 CLUB_INFO.
1.2 Base-URL resolution — RS4::ServerSettings::resolve @ 0x180124270
Runs once, at EnterFUT, after all fetchClientConfig traffic (blaze log: whole CFID sweep at
21:58:11, resolver object at 0x1802e6408 already populated). Three passes, last non-empty wins:
- Hardcoded default —
mov r14,[rip+0x1a672c] # 0x1802caa08@0x1801242d5, splashed into every descriptor's+0x18by the loop @0x180124350. FUT_RS4_APIURL_<MODULE_NAME>— loop @0x180124390,edi < 0x30(48),rbxwalks the module table's NAME field, key format string0x18021fa70.FUT_RS4_URL_<CALL_TAG>— loop @0x180124440,r12 = 0x87,rbxwalks descriptor+0x10, key format string0x18021faa0.
Each pass requires HasKey (vt+0xe8) == true AND GetString (vt+0x30) non-empty
(lea r8,[rip+0xc58bb] # 0x1801e9caf is the default = a NUL byte; cmp BYTE PTR [rbp-0x80],0x0; je skip
@ 0x1801243ff). Out buffer 0x200 bytes.
FUT/MODULE_BASEURL_%s (0x18021fa58) and FUT/SINGLE_BASEURL_%s (0x18021fa88) are DEAD CODE
in this build — independently confirmed by three reversers and re-verified here: both are snprintf'd
into [rbp+0x180] (0x18012439f, 0x18012444f) and that buffer is never passed to HasKey/
GetString; the lookups take lea rdx,[rsp+0x40], which holds the FUT_RS4_* form. Serving them is
harmless but useless.
FUT_TARGET_HOSTNAME / FUT_TARGET_PORT are NOT the API base — they belong to a separate
ICMP/traceroute latency probe (@0x180180876, siblings FIFA_FUT_ALLOW_PING, FUT_DNS_TIMEOUT,
FUT_PING_TIMEOUT, FUT_MAX_HOPS, FUT_PUMP_RATE_MILLSEC). Do not serve FUT_TARGET_PORT: a
copy/paste bug @0x1801808e8 makes its GetInt read the key FUT_MAX_HOPS instead, so its presence
poisons the port with the hop count.
1.3 Value format and URL assembly (@ 0x180177a89–0x180177b62, re-verified)
scan value for ':' cmp al,0x3a @0x180177aa0
bytes[colon+1] == '/' && [colon+2] == '/' -> strcpy_s verbatim @0x180177acf
else -> sprintf("http://%s") fmt @0x18022dc38
force trailing '/' mov BYTE PTR [rbp+rax-0x60],0x2f @0x180177b28
strcat_s(path) @0x180177b62
buffer 0x200 everywhere
So http://127.0.0.1:8099/, http://127.0.0.1:8099 and 127.0.0.1:8099 all end up identical.
Scheme: plain HTTP is native (EA's own default is http://…:8099/); there is no scheme rewrite.
HTTPS would also be accepted verbatim (ProtoSSL verify is patched) but adds a variable — use http.
1.4 The platform token — game/fifa17
BuildEndpointPath @ 0x180123da0:
movsxd rdi,[r14+rcx*8+0x2caa28] ; moduleIdx from the descriptor
call QWORD PTR [rax+0x250] ; engine/config service -> const char* token
lea r8,[rip+0xfbc7e] # 0x18021fac8 ; "game/%s" -> snprintf into a 0x40 buffer
mov r8,[r14+r8*8+0x21df80] ; moduleTable[moduleIdx].pathTemplate
snprintf(out, len, pathTemplate, "game/<token>")
The %s in FUT_RS4_APIURL_%s / FUT_RS4_URL_%s is a module/call NAME, not a platform token —
that was the brief's second wrong assumption. The only path-level token is this one, and the evidence
says fifa17: live FIFA17.exe .rdata 0x1438dac20 = b'fifa17\0\0 2017\0', adjacent
0x1438dbe58 = 'ut/game/%s/', 0x1438dbe88 = 'FUT_RS4_BASE_URL'; process env GAMEID=fifa17;
our own roster server is already serving /fifa17/fut/rosterupdate.xml. Not proven (the getter
lives in the encrypted exe) — mitigate by wildcarding the segment. Final shapes:
ut/auth (AUTH — no platform segment at all)
ut/delete/auth (LOGOUT)
ut/game/fifa17 (UT -> + "/settings", "/hub", "/userMassInfo")
ut/game/fifa17/user (USER -> + "/credits", …)
ut/game/fifa17/squad (SQUAD -> + "/active?active=true" | "/active/user/<personaId>")
ut/game/fifa17/user/list (CLUB_INFO -> + "?personaIdList=…")
ut/game/fifa17/match (MATCH -> + "/keepalive")
In the JSON body the platform is the literal "pc" (VA 0x18021fe9c, the only \0pc\0 in the
whole DLL) with "sku":"FFA17PCC" (VA 0x18021fe90). No runtime platform formatting exists.
1.5 The auth handshake
Request — module 35 ut/auth, JSON body ⇒ POST (the literal method string is set through a
ProtoHttp enum, not a "POST" literal — treat method as unproven and accept anything).
Headers (template @ 0x180220150, file 0x21f550): Accept: application/json + Content-Type: application/json + Hash: %s only when POW is on (feature flag "POW" @ 0x180235ce0, read via
config vt+0x50, cached to 0x1802ef5d0/…d8 — we simply never serve the key, so no POW).
No X-UT-SID on this first request (there is no sid yet).
Body, built by 0x180125900 in this exact key order, then 0x1801a26d0 appends the identification:
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc",
"clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,
"nucleusPersonaDisplayName":"CAGE","locale":"en-us","regionCode":"…",
"deviceId":"…","macAddress":"…",
"method":"authcode","identification":{"authCode":"OPENFUT-000…"}}
Key literals verified: isReadOnly0x1802201c0, priorityLevel0x1802201d0 (from global 0x1802cc528,
live 6), sku0x1802201e0, nucleusPersonaPlatform0x1802201e8, clientVersion0x180220200 (immediate 3),
nuc0x180220210, nucleusPersonaId0x180220218, nucleusPersonaDisplayName0x180220230 (fallback
literal "mememe"0x18022024c), locale0x180220254, regionCode0x1802201b0, deviceId0x180220260,
macAddress0x180220270, method0x18023641c = authcode0x180236428, identification0x180236438,
authCode0x180236448. The optional "fat"0x180236454 is never emitted (its gate vt+0xb0 =
0x1800cd820 = xor al,al; ret), and the derived-extra-fields hook vt+0xa0 = 0x180122420 = ret.
Persona values come from FIFA17.exe via import thunks — with our Blaze LoginResponse they will be
33068179 / "CAGE" (blaze log SESS.BUID / PDTL.DSNM).
Response parser @ 0x1801a2880 — a rapidjson member walk that recognises exactly three keys:
| key | VA | action |
|---|---|---|
sid |
0x180236458 | strlen + string-assign into ServerCall+0x1e8 (0x1801a2ae8) |
serverTime |
0x180236460 | six atoi at char offsets 0,5,8,11,14,17 → +0x208/+0x210 |
lastOnlineTime |
0x180236470 | same → +0x218/+0x220 |
Everything else is skipped. Return value is setne on the document terminating cleanly — there is no
status-code check in the parser, but the layer above requires a parseable body (see 1.7).
Datetime shape is YYYY-MM-DD HH:MM:SS (a T separator parses identically — atoi at fixed offsets).
sid is the whole ballgame. Header builder 0x180126080 (vtable slot 0x110) copies template
0x180220100 = "Accept: application/json\r\nContent-Type: application/json\r\nX-UT-SID: %s\r\n"
(file 0x21f500 — one string; strings(1) split it, which is why the brief lists X-UT-SID: %s alone),
fills %s from [this+0x1e8] (0x18012611b) and appends it with fourCC 'apnd' (0x61706e64)
via [this+0x18]->vt[0x70] (0x180126231). A getter for the same field is slot 0x108 = 0x180125520.
The field is an eastl::string initialised empty in the ctor 0x1801a2210.
401/403 handling @ 0x1801a33a0 / 0x1801a3447 — on 401 or 403 for a normal API call the client
silently re-auths (max 3, counter [rcx+0x1e0]); a 401 on ut/auth itself truncates the stored
sid and re-auths (max 2, counter [rbx+0x350]). Never answer 401/403 or we burn retries and then error.
X-UT-VALIDATE: true (VA 0x18022b398) exists in the DLL but is not part of this path.
1.6 What the brief's other strings actually are (all four reversers agree)
%s/pow/mm/(0x18021fab0) +/fut/(0x18021fabc) — a content/CDN base built at0x18012450efrom a different vtable slot (vt+0x3f8); live it is EMPTY (obj+0x30 = 0,obj+0x38 = "") and the code skips the whole block when empty. Production shape (leftover in memory):http://content.lt.easfc.ea.com:8080//fifa/fltOnlineAssets/2013/pow/mm/…. Not the auth flow.TRANSACTION_STATE_WAIT_FOR_AUTH_CODE(0x1ef360) — the FIFA-Points store purchase state machine (contiguous enum with_WAIT_FOR_CART,_WAIT_FOR_CHECKOUT,_WAIT_FOR_CONSUME_SERVER)./user/hub,/user/registration— FUT Champions suffixes (module CHAMPIONS →ut/game/fifa17/champion/user/hub), not initial load./active/user/%lld,/active?active=true— the active-squad loader (0x18014DAD0,RS4:FutSquadLoadServerResponse).http-rs4(0x18022b4a8) — a telemetry tag passed next tofut-rs4-server(0x18022b4b8), not a scheme.- There is a second, exe-side FUT client (
FutServerCall,FifaFutServiceImplementation) with a single keyFUT_RS4_BASE_URL(no%s), pathut/game/%s/, sub-pathsusers/club?sku=,utStats?sku=,user/accountinfo, and a different auth header:Easw-Session-Data-Nucleus-Id: %lld(no X-UT-SID). Serve that key too.
1.7 Generic response pipeline and where "error connecting" is raised
OnResponse @ 0x18016D230:
HTTP 204 -> skip parsing entirely (accepted)
empty body -> err = 0x63
JSON parse failure -> err = 0x3E6
else -> err = call->vt[0x60](resp, body) ; per-class error extractor
epilogue: [resp+0x1c] = err [resp+0x20] = httpStatus
[resp+0x1c] == 0 is the universal success test.
The popup itself: FUT::WebServiceImpl @ 0x180122440 fires
FireEvent(target="_global" (0x1801ed8a8), event="ServerFatalError" (0x1801ed890), param="OSDK_LOST_CON_TO_EA" (0x18021dae0)) at 0x1801224d0 (alternate param
"Unknown_FCC_Error" 0x1801ed878 at 0x180122503) — all four VAs verified by direct byte read.
Gate @ 0x1801224a7: cmp [rsp+0x58],0x2 / cmp eax,0x3 else fire.
It is not FUTOnlineGameModeBase (that string is a debug formatter, one xref, raises nothing).
The surrounding flow is a front-end .nav state machine, dispatched by string on event 0x3D
(0x180019BA0, field "StrParam" 0x1801ed788): beginFUTLogin → retrieveUserData →
CheckRewards → loginComplete → FinalizeLogin → telemetry ('GAME','ACTN','MODE',"enter_hub")
→ transition into the FUT hub. Live strings in the process confirm the flow files:
loading nav/fut/futFlow.nav, /fut/futLogInFlow.nav, /fut/futGameHubFlow.nav.
A 404 on the first user lookup is an accepted, non-fatal answer (0x18007AF20:
[rdx+0x1c]==0 ? ok : [rdx+0x20]==0x194 ? ok : ServerFatalError) — it is the new-user branch
that routes to createClub.
2. THE FIX (minimal)
2.0 Path B first — the zero-config, zero-relaunch experiment
Because every descriptor already holds http://easw.easports.com:8099/, the cheapest possible test
needs no responder change at all:
echo "127.0.0.1 easw.easports.com" | sudo tee -a /etc/hosts
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/utas_server.py # binds 127.0.0.1:8099
# re-enter Ultimate Team (a game restart is safest — the resolver may have negatively cached)
Do this in addition to Path A below; it costs nothing and it removes the config store from the
equation (we have not proven CardsDLL reads the same merged _all section the exe uses).
2.1 (a) blaze_responder_v3b.py — fetchClientConfig additions
Insert after the OSDK_ROSTER block (~line 520) and change client_config_for:
# --- FUT / UTAS (RS4) BASE URL --------------------------------------------
# CardsDLL RS4::ServerSettings::resolve @0x180124270 writes each of the 125
# endpoint descriptors' baseUrl (VA 0x1802caa20 + i*0x30 + 0x18) in 3 passes,
# last non-empty wins:
# 1. hardcoded default @0x1802caa08 = "http://easw.easports.com:8099/" (DEAD HOST)
# 2. cfg["FUT_RS4_APIURL_<MODULE_NAME>"] key fmt @0x18021fa70
# 3. cfg["FUT_RS4_URL_<CALL_TAG>"] key fmt @0x18021faa0
# A key only counts if HasKey(vt+0xe8) AND GetString(vt+0x30) is non-empty.
# The value is used verbatim when it contains "://" (@0x180177ab3), a trailing
# '/' is force-appended (@0x180177b28), then the endpoint path is strcat'd.
# FUT/MODULE_BASEURL_%s and FUT/SINGLE_BASEURL_%s are formatted but NEVER looked
# up (dead code). FUT_TARGET_PORT must NOT be served: bug @0x1801808e8 makes its
# GetInt read the key FUT_MAX_HOPS instead.
UTAS_BASE = "http://127.0.0.1:8099/"
FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
"DELETEUSER", "ITEMS", "ITEMS_BY_RES", "DELETEITEMS", "MATCH", "SBC",
"TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASON", "SEASONUSER",
"SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE",
"WATCHLIST", "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE",
"MARKETDATA", "CLIENTDATA", "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA",
"TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE",
"ADMIN", "DEBUG", "MAINTENANCE",
] # 48, VA 0x18021df80 NAME field
# Per-call overrides (pass 3, runs last and wins). Not required if pass 2 lands,
# but free insurance for the boot set.
FUT_RS4_CALLS_BOOT = [
"GETSETTINGS", "AUTHENTICATION", "LOGIN", "LOGOUT", "CREATEUSER",
"GETUSERINFO", "GETUSERDATA", "GETUSERCREDITS", "USERRELIABILITYINFO",
"GETHUBDATA", "GETUSERMASSINFO", "LOADACTIVESQUAD", "SQUADLIST",
"GETSQUADINFO", "GETCLUBINFO", "KEEPALIVE", "SEASONHISTORY",
]
FUT_RS4_CONFIG = (
[("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES]
+ [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS_BOOT]
+ [("FUT_RS4_BASE_URL", UTAS_BASE)] # exe-side FutServerCall @0x1438dbe88
)
def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] ..."""
if cfid == "BlazeSDK":
return sorted(blazesdk_config() + FUT_RS4_CONFIG)
# FUT_RS4_* keys ride on EVERY CFID: we have not proven which section
# CardsDLL's accessor (engineTable[0x170] -> vt+0x118) reads.
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG)
Timing is safe: the whole CFID sweep (OSDK_CORE … OSDK_ROSTER) completes at login
(/tmp/blaze_responder.log 21:58:11, lines 909-1338), long before EnterFUT runs the resolver.
Do not add: FUT_TARGET_PORT (poisons the port via the FUT_MAX_HOPS bug),
FUT/MODULE_BASEURL_*, FUT/SINGLE_BASEURL_* (dead), POW (leave absent so no Hash: header /
proof-of-work round-trip is required).
2.2 (b) Minimal UTAS server — fifa17-recon/tools/utas_server.py
Plain HTTP on 127.0.0.1:8099. Rules distilled from §1.7:
- Never 401/403 (triggers the silent re-auth storm).
- Every body must be parseable JSON (parse failure ⇒ err
0x3E6⇒ ServerFatalError). 204with no body is explicitly accepted and skips parsing — good for fire-and-forget calls.404is accepted only on the first user lookup (new-user branch).- Accept any HTTP method on any route (the method enum was not decoded to a literal).
- SKU segment is wildcarded (
/ut/game/<anything>/…).
#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import datetime, json, os, re, http.server
ADDR = ("127.0.0.1", 8099)
LOG = "/tmp/utas_server.log"
SID = "OPENFUT-SID-0000000000000001"
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
PERSONA_NAME = "CAGE" # PDTL.DSNM
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
# ---- payloads -------------------------------------------------------------
def auth_body():
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
def user_info():
# Deserializer 0x18013EC10; every member optional (unknown key ids are
# skipped via 0x180135FF0), so {} also parses.
return {
"personaId": PERSONA_ID,
"clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
"clubNameChangeAllowed": True,
"currencies": [{"name": "coins", "value": 15000},
{"name": "points", "value": 0}],
"won": 0, "draw": 0, "loss": 0,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False,
"feature": {"trade": True},
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
"actives": [], "squadList": [],
}
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member. The member
# NAME is never compared, but the nesting level is required.
USER_GET = {"userInfo": user_info()}
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
USER_POST = {"login": True, "userData": user_info(),
"squad": {}, "starterPack": {}, "bonusPacks": []}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
G = r"/ut/game/[^/]+"
ROUTES = [
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())),
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
(re.compile(G + r"/user/credits"), lambda m, h: (200, {"credits": 15000})),
(re.compile(G + r"/user/list"), lambda m, h: (200, {})),
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})),
(re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)),
(re.compile(G + r"/squad"), lambda m, h: (200, {})),
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, {})),
(re.compile(G + r"/season"), lambda m, h: (200, {})),
(re.compile(G + r"/club"), lambda m, h: (200, {})),
]
def user_route(h):
if h.command == "POST":
return 200, USER_POST
if NEW_USER:
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
return 404, {}
return 200, USER_GET
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _handle(self):
n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n) if n else b""
log("%s %s" % (self.command, self.path))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
if body:
log(" body: %s" % body[:1200].decode("utf-8", "replace"))
code, payload = 200, {}
for rx, fn in ROUTES:
if rx.search(self.path):
code, payload = fn(rx, self)
break
else:
log(" !! UNMAPPED PATH -> catch-all 200 {}")
raw = b"" if payload is None else json.dumps(payload).encode()
self.send_response(code)
if raw:
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD" and raw:
self.wfile.write(raw)
log(" -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle
def log_message(self, *a):
pass
if __name__ == "__main__":
open(LOG, "a").close()
log("=== utas_server http://%s:%d ===" % ADDR)
http.server.ThreadingHTTPServer(ADDR, H).serve_forever()
Exact wire responses for the two that matter:
POST /ut/auth
HTTP/1.1 200 OK
Content-Type: application/json
{"protocol":1,"sid":"OPENFUT-SID-0000000000000001",
"serverTime":"2026-07-31 22:40:00","lastOnlineTime":"2026-07-31 22:40:00"}
GET /ut/game/fifa17/settings
HTTP/1.1 200 OK
Content-Type: application/json
{"configs":[]}
Every subsequent request must be answered 2xx and must carry (from the client)
X-UT-SID: OPENFUT-SID-0000000000000001.
2.3 Fastest validation with no relaunch (live poke)
CardsDLL live base 0x6ffffc140000 (slide 0x6FFE7C140000). Write a pointer to your own
NUL-terminated URL string into every descriptor's baseUrl field:
for i in 0..125: *(u64*)(0x1802caa20 + i*0x30 + 0x18 + slide) = <ptr to "http://127.0.0.1:8099/">
then re-enter Ultimate Team. /proc/PID/mem writes work under ptrace_scope=1 (already proven by
openfut-poke). This bypasses both the responder and the DNS question in one step.
3. OBSERVABLE SUCCESS SIGNALS (strongest first)
- A 4th FIFA socket —
ss -tnp | grep FIFA17showsSYN-SENT/ESTABto127.0.0.1:8099(today there is none: three Blaze/roster sockets only). This alone proves URL resolution +connect(), i.e. the whole of §1.2–1.4 landed. /tmp/utas_server.logshows a real request line with the literal SKU:GET /ut/game/fifa17/settings(orPOST /ut/auth) plus headersAccept: application/json,Content-Type: application/json,User-Agent: ProtoHttp …. This is also how we read the SKU instead of guessing it.POST /ut/authwith a body containing"method":"authcode"and our"identification":{"authCode":"OPENFUT-000…"}— proves the Blaze authCode reached CardsDLL.- Subsequent requests carry
X-UT-SID: OPENFUT-SID-0000000000000001— proves the sid was stored atServerCall+0x1e8and re-emitted by0x180126080; i.e. CardsDLL considers itself authenticated. If a later request arrives with an emptyX-UT-SID:, the sid is per-call and we have another site to find. - No re-auth storm — the same
POST /ut/authmust not repeat 2–3 times in a row (that pattern means we answered 401/403 somewhere). - In-game: the "error connecting to FIFA 17 Ultimate Team" popup does not appear; the nav flow
advances
beginFUTLogin → retrieveUserData → loginComplete → FinalizeLoginand emits the telemetry('GAME','ACTN','MODE',"enter_hub"), landing on the FUT hub (or the starter-squad screen if we answer 404 /NEW_USER=True). - Live re-reads (no relaunch needed):
- descriptor baseUrl at
0x1802caa20 + i*0x30 + 0x18 + slidenow readshttp://127.0.0.1:8099/instead ofhttp://easw.easports.com:8099/→ the config keys were consumed; grepprocess memory forut/game/→ currently zero hits; any hit provesBuildEndpointPath@0x180123da0ran;- the telemetry ring should now read
{"type":"utas","status":"start_flow"}without the following{"type":"utas","status":"error","status_code":"0"}.
- descriptor baseUrl at
4. WHAT STILL NEEDS A LIVE EXPERIMENT
| # | Unknown | Why it matters | Cheapest resolution |
|---|---|---|---|
| 1 | The SKU token returned by engine->vt[0x250] (feeds game/%s). fifa17 is inferred from FIFA17.exe 0x1438dac20 + GAMEID=fifa17, never read directly (the getter lives in the encrypted exe). |
Wrong token ⇒ our routes 404. | Wildcarded in the server (/ut/game/[^/]+/…); the first log line tells us the truth. |
| 2 | Which config section CardsDLL reads (engineTable[0x170] → vt+0x118, HasKey +0xe8 / GetString +0x30) vs the exe's merged _all section (getSection @0x14719e050). |
If it is a different store, the keys never land. | Already mitigated: keys attached to every CFID + the /etc/hosts Path B fallback. |
| 3 | Report 3's finding that ut/game/ has zero hits in 1.87 GB of process memory, i.e. no RS4 path was ever built — which would put the block upstream of URL resolution (the nav flow never firing beginFUTLogin). |
If true, config keys alone will not produce a socket. | Discriminator: after the fix, if the descriptors show our URL but still no SYN, instrument 0x180018F50 (beginFUTLogin) / 0x180122440 (the fatal-error site). Counter-evidence: the utas start_flow/error status_code 0 telemetry pair says the flow did run and got no HTTP status — consistent with a DNS failure after the path was built into a since-freed heap buffer. |
| 4 | HTTP method for each call (the ProtoHttp method enum was not decoded to a literal POST/GET). |
None if we accept all methods. | Server accepts every verb; log records the truth. |
| 5 | Boot order of the initial-load calls. GetSettings is the only non-market call with preAuthFlag=1 (descriptor +0x20), which strongly implies it is first, but the driver (CardsAdaptor vt[0x758]/[0x768]) lives outside CardsDLL. |
Determines which response must be right first. | Read the access log. |
| 6 | Killswitch semantics — desc+0x21 is live 0 for all 126 (verified). If 0 means "disabled" rather than "not killed", {"configs":[]} would starve every call. |
Could block everything after GetSettings. | If GetSettings returns and nothing else is ever requested, populate configs from the setter thunks (e.g. 0x180124020 → 0x1802cae31 = GetSettings' own byte) and the literal FUT_INSTRUCTIONS_KILLSWITCH (0x180202FB0). |
| 7 | Hash: %s value (auth-only header, from svc->vt[0x420], hex digest — alphabet 0123456789abcdef @0x21f598) and the POW feature flag. |
Only if EA-side validation were mirrored; we control the flag. | Leave POW unserved; if a Hash: header shows up anyway, ignore it server-side. |
| 8 | The per-class error extractor call->vt[0x60] (0x18016D47B) that turns a parsed body into [resp+0x1c]. Key ids located: code(0x92), reason(0x279), debug(0xcb), string(0x2f6), errorState(0x10d); the function itself was not reversed. |
If a 200 with a body still yields ServerFatalError, this is the next target — most likely our body contains a code member it reads as an error. |
Keep bodies minimal (no code/reason/debug keys) — the supplied payloads already avoid them. |
| 9 | The content/CDN base (vt+0x3f8 → <X>/pow/mm/, <X>/fut/), live EMPTY. |
May cause a secondary failure after the API connects (LiveMessage / hub assets). | If a post-auth failure mentions pow/mm, add a config value shaped like http://127.0.0.1:8099//fifa/fltOnlineAssets/2013. |
| 10 | The exe-side FutServerCall (user/accountinfo, header Easw-Session-Data-Nucleus-Id: %lld, no X-UT-SID) — its response shape (userAccountInfo, personas, userClubList, clubName, established, assetId, returningUser) is read from live strings, not from a reversed parser. |
A second, independent client that may error separately. | Route already stubbed to 200 {}; iterate from its log line. |
The next gate after this one
Assuming we clear auth, the flow reaches retrieveUserData → FinalizeLogin. Two outcomes:
- Existing-club path (
NEW_USER=False): the hub loads against{"userInfo":{…}}with an empty club and empty squad ("actives":[],"squadList":[]). The hub will render, but the moment the UI asks for real content —LoadActiveSquad,SquadList,ViewCards/ut/game/fifa17/item,GetUserMassInfo— it needs an actual item set. Empty arrays should be structurally valid (every member is optional, unknown key ids are skipped) but will likely surface as an empty club screen or a "squad required" wall. - New-user path (
NEW_USER=True, or a 404 on the first GET):GotoStarterSquad/SetNewUserContextData→POST ut/game/fifa17/user(CreateUser) with{"login":true,"userData":{…},"starterPack":{…},"squad":{…},"bonusPacks":[]}— that is where a real starter pack + a legal 11-player squad must be produced.
Either way, the next gate is content, not protocol — which is exactly openfut-core's job
(data/cards/, data/packs/, the squad/club models). The clean handoff is: utas_server.py stays a
thin router that proxies /ut/game/*/… to openfut-core on :8080, keeping only ut/auth and the
X-UT-SID session locally. The item-JSON schema for that work is already recoverable — CardsDLL parses
by hashed key ids (FNV-1a 0x811c9dc5 @ 0x180180D00, red-black map @ 0x1802E6598, unknown ⇒
id 0x38C and silently skipped) against a 907-entry key dictionary at VA 0x1802D2760 (file
0x2D0D60, alphabetical: index 6 = accountCreatedPlatformName … 0x38a = yellowCards). That
table is the complete FUT JSON vocabulary and should be dumped into openfut-core as the schema
reference for the content phase.
Appendix A — disagreements between the four reversals, resolved
| Claim | Verdict |
|---|---|
| "Config keys are served empty ⇒ no URL" (report 2) | Rejected. Live: all 126 descriptors hold the easw default; DNS NXDOMAIN is the failure. Confirmed independently by reports 1, 3, 4 and re-verified here. |
Descriptor table at 0x1802caa28 +0x10 (r1) vs 0x1802caa20 +0x18 (r3/r4) |
Same address (0x2caa38). Normalised to base 0x1802caa20, stride 0x30, baseUrl at +0x18. |
FUT/MODULE_BASEURL_%s live vs dead |
Dead — verified in disasm here: formatted into [rbp+0x180], lookups take [rsp+0x40]. |
%s = platform token (brief, report 2) vs module/call NAME (r1, r3, r4) |
Module/call NAME. The platform token appears only in game/%s. |
serverTime "YYYY-MM-DD HH:MM:SS" (r2/r4) vs "YYYY-MM-DDTHH:MM:SS" (r3) |
Both parse — six atoi at fixed char offsets 0,5,8,11,14,17. Use the space form. |
| Port 8082 (r3) vs 8099 (r1, r4) | 8099, so the easw.easports.com → 127.0.0.1 hosts trick works with zero config. |
/user/hub, /user/registration, /active/user/%lld, %s/pow/mm/ as auth/initial-load (brief) |
All misattributed — FUT Champions, active-squad loader, and the FIFA-Points store respectively. |
| 125 vs 126 endpoint descriptors | 125 real (idx 0–124) + one terminator at idx 125 (moduleIdx = 48, empty name). |
Appendix B — the 125 call tags (for FUT_RS4_URL_<TAG> if ever needed)
ISSEARCH, ISOFFERTRADE, ISSTART, RELISTALL, GETCLUBUSERS, GETCLUBINFO, CLUBSEARCH, CLUBSTATS, STAFFSTATS, CONSUMABLESSEARCH, DREAMSQUADSEARCH, GETSQUADINFO, UPDATESQUADNAME, RETRIEVESQUAD, LOADACTIVESQUAD, DELETESQUAD, SAVESQUAD, SQUADLIST, GETLBOPTIONS, GETLBENTRIES, GETLBENTRYDATA, GETSETTINGS, AUTHENTICATION, LOGIN, LOGOUT, RESETUSER, USERRELIABILITYINFO, CREATEUSER, GETUSERINFO, SETUSERINFO, GETHISTORICAL, SETTUTDATA, GETTOWDATA, SETTOWDATA, SETFAVDATA, GETUSERCREDITS, GETUSERDATA, VIEWCARDS, ASSIGNCARD, APPLYCARD, APPLYCARDBYRES, ACTIVATECARD, CONSUMECARD, DISCARDCARD, DISCARDCARDBYRES, DISCARDACARD, MOVECARD, MOVECARDBYRES, SWAPCARD, CREATEMATCH, MATCHREADY, DESTROYMATCH, PLAYGAME, RESETMATCH, KEEPALIVE, LOADCATEGORYDETAILS, LOADSETCHALLENGES, STARTCHALLENGE, LOADSQUADCHALLENGE, SAVESQUADCHALLENGE, SUBMITCHALLENGE, TAGSETS, SETSBCDATA, TOURNAMENTLIST, TOURNAMENTTEAMS, GETACTIVETOURNAMENTS, UPDATETOURNAMENT, TOURNAMENTLOADDATA, TOURNAMENTQUIT, SEASONLIST, SEASONUPDATE, SEASONLOADDATA, SEASONQUIT, SEASONHISTORY, PURCHASEDITEMS, PURCHASEPACK, PURCHASEITEMS, STOREPACKTYPES, STOREPACKQUANTITIES, ISREMOVEWATCH, ISWATCHTRADE, ISWATCHLIST, ISVIEWTRADE, GETTRADEPILE, GETAUCTIONCOUNT, ISREMOVETRADE, GETSUGGESTEDPRICING, CHANGECLUBNAME, GETPHISHINGQUESTION, SETPHISHINGANSWER, VALIDATEPHISHINGANSWER, GETTRUSTEDCONSOLELIST, GETCAPTCHA, EXCHANGECAPTCHA, VALIDATECAPTCHA, VALIDATETFA, UPDATEUSERACTION, GETUSERACTION, GETMANAGERQUESTREWARD, SETMANAGERQUESTCOMPLETE, GETHUBDATA, GETUSERMASSINFO, FIFAPOINTSTRANSFER, FRIENDLYSEASONUPDATE, FRIENDLYSEASONLOAD, FRIENDLYSEASONHISTORY, GETAVAILABLELOANPLAYERS, SIGNLOANPLAYER, GETCHEMISTRYATTR, GETDRAFTCURRENTSTATE, GETDRAFTCHOICES, GETDRAFTSTATS, GETDRAFTAWARD, PURCHASEDRAFTMODE, PICKDRAFTCHOICE, PICKDRAFTAUTOCHOICE, GETSTORYMODEREWARD, CHAMPIONSHUB, CHAMPIONSTOPX, CHAMPIONSRANK, CHAMPIONSFRIENDS, GRANTPRIZECHAMPIONUSER, REGISTERCHAMPIONSLEAGUE, GetChampionsUserCountry, LIVEMESSAGE
(Note: tag 124 is spelled GetChampionsUserCountry in mixed case in the binary — the key would be
FUT_RS4_URL_GetChampionsUserCountry, byte-for-byte.)