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:
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/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)),
|
||||
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
|
||||
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
|
||||
# Returning trusted=true makes FUT SKIP the security question.
|
||||
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
|
||||
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
|
||||
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
||||
(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()
|
||||
Reference in New Issue
Block a user