70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
622 lines
30 KiB
Python
622 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OpenFUT clean-room LSX responder for FIFA 17 -- v2 (EVENT-PUSHING).
|
|
|
|
v2 vs v1 (lsx_responder.py): v1 was REQUEST-DRIVEN ONLY. It answered every verb
|
|
the client asked for and never sent an unsolicited frame. That is exactly why
|
|
the client never issued GetAuthCode and never sent Blaze Authentication::login.
|
|
|
|
THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS:
|
|
|
|
(1) "internet is reachable" -> OriginMgr online byte [0x1448a3ac0]
|
|
fed by the REQUEST verb GetInternetConnectedState -> connected="1"
|
|
(v1 already beat this; live-confirmed == 1)
|
|
|
|
(2) "a user is LOGGED IN" -> OriginMgr.m_isLoggedIn [OriginMgr+0x13]
|
|
fed ONLY by a server-PUSHED <Event sender="LOGIN_EVENT"><Login/>
|
|
There is NO request verb that can set it.
|
|
|
|
v1 fed (1) and never fed (2), so m_isLoggedIn was 0 for the whole session,
|
|
FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever
|
|
(both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately,
|
|
OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH
|
|
could never be filled -> no Blaze login -> "Unable to retrieve account
|
|
information."
|
|
|
|
BINARY EVIDENCE (all re-verified byte-for-byte from our own live dumps, not
|
|
from any leak; see the PROVENANCE block at the bottom of this docstring):
|
|
|
|
* Origin event dispatcher @0x146f1e060, case edx==2 (OriginEventT::Login) is
|
|
the ONLY case in the whole dispatcher that mutates state:
|
|
146f1e09e: 41 83 39 01 cmp DWORD PTR [r9],0x1 ; IsLoggedIn==1
|
|
146f1e0ab: c6 41 13 01 mov BYTE PTR [rcx+0x13],1 ; m_isLoggedIn=TRUE
|
|
146f1e0af: c7 41 14 00.. mov DWORD PTR [rcx+0x14],0 ; clear login error
|
|
146f1e0b8: c6 41 13 00 mov BYTE PTR [rcx+0x13],0 ; else FALSE
|
|
* <Login> element matcher @0x147102880:
|
|
- reads attribute "sender" (literal @0x143938028) off the <Event> node
|
|
via vtbl+0x70; `test rax,rax; je fail` -> sender MUST be present
|
|
- inline strcmp of that value against the handler's registered sender
|
|
-> a mismatched sender is SILENTLY DROPPED
|
|
- then requires the child element name == "Login" (@0x14393d0ac)
|
|
* Handler sender strings come from the service-name tables. NOTE there are
|
|
TWO parallel structures, so do not "fix" one stride into the other:
|
|
- the const char* INIT table @0x144341420 is STRIDE 8;
|
|
- the runtime std::string array the SDK actually indexes (sdk+0x3b0, via
|
|
GetServiceName @0x1470e4870 with `shl rax,0x5`) is STRIDE 0x20, max
|
|
index 0x21.
|
|
Both resolve index 14 == LOGIN_EVENT, so the conclusion is the same.
|
|
Verified contents of the index space:
|
|
idx 0 SDK 1 PROFILE 2 PRESENCE 3 FRIENDS 4 COMMERCE
|
|
idx 5 RECENTPLAYER 6 IGO 7 MISC 8 LOGIN
|
|
idx 9 UTILITY 10 XMPP 11 CHAT 12 IGO_EVENT
|
|
idx13 EALS_EVENTS 14 LOGIN_EVENT 15 INVITE_EVENT
|
|
idx16 PROFILE_EVENT ... 27 ONLINE_STATUS_EVENT
|
|
"LOGIN_EVENT" (@0x14394c790) is referenced from EXACTLY ONE place in the
|
|
whole image: table slot 0x144341490 == index 14. Likewise
|
|
"ONLINE_STATUS_EVENT" (@0x14394c868) only from 0x1443414f8 == index 27.
|
|
* <Login> attribute parser @0x147138660: opens namespace "lsx", reads attribute
|
|
"IsLoggedIn" (@0x14394e0f0), then @0x14713ffa0 does
|
|
strcmp(value,"false"); setne al; mov BYTE PTR [rdi],al
|
|
-> ANY value except the literal string "false" means TRUE.
|
|
* <OnlineStatusEvent> parser @0x147139e00 reads attribute "isOnline"
|
|
(@0x14394e180, lower-case i) in the same shape.
|
|
* Symbols proving the handler templates are instantiated in this build:
|
|
Origin::EventHandler<struct lsx::LoginT,unsigned int>::HandleMessage
|
|
Origin::EventHandler<struct lsx::OnlineStatusEventT,bool>::HandleMessage
|
|
(payload type `unsigned int` matches `cmp DWORD PTR [r9],1` above.)
|
|
* Structural proof that unsolicited <Event> frames are consumable: the LSX
|
|
handshake itself is one -- <LSX><Event sender="EALS"><Challenge/></Event>.
|
|
|
|
WHAT WE DELIBERATELY DID NOT CHANGE
|
|
* The crypto (challenge / session-key derivation / AES-ECB+PKCS7+hex+NUL) is
|
|
byte-verified against a captured real session; it is copied verbatim.
|
|
* Every verb v1 answered is answered identically. Nothing was removed.
|
|
|
|
WIRE PROTOCOL (unchanged, reversed from stp-origin_emu.dll @ 0x6ffffc930000):
|
|
transport : TCP 127.0.0.1:4216, each message NUL-terminated (send strlen+1).
|
|
handshake : server sends <Challenge key="..."> IN PLAINTEXT;
|
|
client replies plaintext with response=/key=;
|
|
server replies <ChallengeAccepted response="H"> where
|
|
H = hex(AES128_ECB(K_FIXED, PKCS7pad16(clientKeyAscii)))
|
|
K_FIXED = 000102...0f (emu .rdata 0x935038)
|
|
session : every later frame (BOTH directions, Responses AND Events) is
|
|
hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"
|
|
SESSION_KEY derived from H via the MSVCR srand/rand LCG.
|
|
|
|
USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's bind() fails.
|
|
(This file does NOT auto-start anything; the main session owns processes.)
|
|
|
|
PROVENANCE / CLEAN ROOM: every constant and algorithm here was recovered by our
|
|
own static+dynamic analysis of binaries we own (FIFA17.exe unpacked in our own
|
|
process, stp-origin_emu.dll as loaded) plus traffic we ourselves captured.
|
|
Nothing is derived from the 2021 EA/FIFA leak.
|
|
"""
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
from Crypto.Cipher import AES
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from fut_account import ACCOUNT # noqa: E402
|
|
|
|
# ---------------------------------------------------------------- identity
|
|
# SOURCED FROM fut_account.ACCOUNT, shared with blaze_responder_v3b.py,
|
|
# fut_store.py, fut_seed.py and utas_server.py.
|
|
#
|
|
# THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX
|
|
# reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what
|
|
# UTAS serves as userInfo.personaId. (The previous comment blamed a mismatch for
|
|
# AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA /
|
|
# AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are
|
|
# the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or
|
|
# dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They
|
|
# stay the defaults because they are what the working stack asserts.)
|
|
PERSONA_ID = ACCOUNT.persona_id
|
|
PERSONA_NAME = ACCOUNT.persona_name
|
|
USER_ID = ACCOUNT.user_id # derived from persona_id
|
|
CONTENT_ID = ACCOUNT.CONTENT_ID # FIFA 17 EA offer id
|
|
ENTITLEMENT_TAG = ACCOUNT.ENTITLEMENT_TAG
|
|
LOCALE = ACCOUNT.locale
|
|
|
|
AUTHCODE_FILE = "/tmp/openfut_authcode.txt"
|
|
CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt"
|
|
|
|
# Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038
|
|
K_FIXED = bytes(range(16)) # 000102030405060708090a0b0c0d0e0f
|
|
|
|
# Emu's own advertised challenge (any 32 hex chars work; the client echoes it back)
|
|
CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32"
|
|
BUILD = "release"
|
|
VERSION = "10,4,13,6637"
|
|
|
|
# ------------------------------------------------------------- event tuning
|
|
# Pushes are idempotent state notifications, so re-sending is harmless and is
|
|
# cheap insurance against FIFA registering its <Login> handler later than our
|
|
# first push. Set OPENFUT_LSX_EVENTS=0 to fall back to v1 behaviour (useful as
|
|
# an A/B control if you want to prove the events are what moved the needle).
|
|
EVENTS_ENABLED = os.environ.get("OPENFUT_LSX_EVENTS", "1") != "0"
|
|
EVENT_HEARTBEAT_SECS = float(os.environ.get("OPENFUT_LSX_EVENT_PERIOD", "5"))
|
|
EVENT_HEARTBEAT_COUNT = int(os.environ.get("OPENFUT_LSX_EVENT_COUNT", "24"))
|
|
|
|
# EXPERIMENT: push the Login Event in PLAINTEXT right after ChallengeAccepted
|
|
# (before the stream goes encrypted) instead of via the encrypted heartbeat.
|
|
# Tests the workflow's strongest remaining hypothesis -- that FIFA drops
|
|
# encrypted mid-session Events (the emu's only Event, the Challenge, is plaintext
|
|
# and pre-key). See serve() step 3b and REPACK_INTEL.md sec.4 step 2.
|
|
LOGIN_PLAINTEXT = os.environ.get("OPENFUT_LSX_LOGIN_PLAINTEXT", "0") != "0"
|
|
|
|
# A/B-control integrity: v1 (lsx_responder.py) answered GetGameInfo
|
|
# FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
|
|
# silently changed it to "true", which meant OPENFUT_LSX_EVENTS=0 was NOT a
|
|
# byte-identical control any more. Keep it OFF by default so events-off ==
|
|
# v1 exactly; flip OPENFUT_LSX_FULLGAME=1 to run the FULLGAME="true" experiment
|
|
# on its own.
|
|
FULLGAME_PURCHASED_TRUE = os.environ.get("OPENFUT_LSX_FULLGAME", "0") != "0"
|
|
|
|
|
|
def log(*a):
|
|
print("[lsx]", *a, flush=True)
|
|
|
|
|
|
_SECRET_ATTR_RE = re.compile(
|
|
r'(?i)\b(AuthCode|AuthToken|SessionKey|Token|Sid)="[^"]*"')
|
|
_AUTH_CODE_ATTR_RE = re.compile(r'(?i)\b(value|Code|Return)="[^"]*"')
|
|
_CHALLENGE_ATTR_RE = re.compile(r'(?i)\b(response)="[^"]*"')
|
|
|
|
|
|
def safe_xml_for_log(xml):
|
|
"""Redact credential-bearing LSX attributes from ordinary diagnostics."""
|
|
safe = _SECRET_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), xml)
|
|
if "<AuthCode " in safe:
|
|
safe = _AUTH_CODE_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), safe)
|
|
if "<ChallengeAccepted " in safe:
|
|
safe = _CHALLENGE_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), safe)
|
|
return safe
|
|
|
|
|
|
# ---------------------------------------------------------------- crypto
|
|
# (verbatim from v1 -- verified end-to-end by decrypting captured
|
|
# captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.)
|
|
def msvcr_rand(seed):
|
|
"""MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61)."""
|
|
s = seed & 0xFFFFFFFF
|
|
while True:
|
|
s = (s * 214013 + 2531011) & 0xFFFFFFFF
|
|
yield (s >> 16) & 0x7FFF
|
|
|
|
|
|
def derive_session_key(resp_hex: str) -> bytes:
|
|
"""Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101).
|
|
|
|
srand(7); r0 = rand() -> r0 == 61
|
|
bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap)
|
|
srand(bx + r0)
|
|
key[i] = (uint8_t)rand() for i in 0..15
|
|
"""
|
|
r0 = next(msvcr_rand(7)) # == 61
|
|
bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF
|
|
g = msvcr_rand((bx + r0) & 0xFFFFFFFF)
|
|
return bytes(next(g) & 0xFF for _ in range(16))
|
|
|
|
|
|
# AES128-ECB(K_FIXED, 0x10*16) -- the constant the emu appends as the 3rd hex
|
|
# block (== PKCS7 pad block of an aligned 32-byte key). See REPACK_INTEL.md sec.0-B.
|
|
_TAIL_CONST = AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex()
|
|
|
|
|
|
def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str:
|
|
"""Emu-exact ChallengeAccepted.response (stp-origin_emu.dll 0x180001f10).
|
|
|
|
The emu computes only TWO AES blocks from the 32-ASCII client key, then
|
|
strcat_s's the client's OWN response[64:] verbatim (@0x1800020a9) -> 96 hex.
|
|
Our older 3-block PKCS7 form is numerically identical *while the client
|
|
PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed
|
|
byte-exact). We now reproduce the emu exactly and, when the client's
|
|
response= is available, echo its tail and assert the constant so a future
|
|
client that randomises block 3 fails LOUDLY instead of silently."""
|
|
two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex()
|
|
if len(client_response_attr) >= 64:
|
|
tail = client_response_attr[64:]
|
|
assert tail == _TAIL_CONST, f"unexpected ChallengeResponse tail {tail!r}"
|
|
return two + tail
|
|
return two + _TAIL_CONST
|
|
|
|
|
|
def lsx_encrypt(xml: str, key: bytes) -> bytes:
|
|
"""pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated."""
|
|
b = xml.encode()
|
|
pad = 16 - (len(b) % 16) # emu always pads (pad==16 when aligned)
|
|
b += bytes([pad]) * pad
|
|
return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0"
|
|
|
|
|
|
def lsx_decrypt(data: bytes, key: bytes) -> str:
|
|
h = data.split(b"\0")[0].strip()
|
|
raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode()))
|
|
pad = raw[-1]
|
|
if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]):
|
|
raw = raw[:-pad]
|
|
return raw.split(b"\0")[0].decode(errors="replace")
|
|
|
|
|
|
# ------------------------------------------------------- PUSHED EVENTS (NEW)
|
|
#
|
|
# Frame shape is identical to the server-initiated <Challenge> that already
|
|
# works, i.e. <LSX><Event sender="..."><Element .../></Event></LSX>
|
|
# No id attribute (the Challenge has none; the matcher never reads one).
|
|
#
|
|
# `sender` is strcmp'd against the handler's registered service name. A
|
|
# mismatch is silently dropped -- costs us nothing -- so for the Login element
|
|
# we emit BOTH candidate senders: "LOGIN_EVENT" (table index 14, the one the
|
|
# event-handler factory uses) and "LOGIN" (table index 8, the plain service
|
|
# name). Exactly one of them will match; the other is a no-op.
|
|
# Event handlers are keyed on serviceNames[facility] too (registrar 0x14710df80);
|
|
# with our empty GetConfigResponse those names are "", so the handlers expect
|
|
# sender="". "" first; the named variants are harmless no-ops (dropped silently)
|
|
# and become correct once GetConfigResponse populates the table (RANK 2).
|
|
LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN")
|
|
ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")
|
|
|
|
|
|
def event(sender: str, element: str) -> str:
|
|
return f'<LSX><Event sender="{sender}"><{element}/></Event></LSX>'
|
|
|
|
|
|
def login_event_frames() -> list:
|
|
"""The frames that flip OriginMgr.m_isLoggedIn ([OriginMgr+0x13]) to 1.
|
|
|
|
IsLoggedIn is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE.
|
|
Keep the value literally "true" anyway: it is what a real Origin client
|
|
sends and it keeps the log readable."""
|
|
out = [event(s, 'Login IsLoggedIn="true"') for s in LOGIN_EVENT_SENDERS]
|
|
out += [event(s, 'OnlineStatusEvent isOnline="true"')
|
|
for s in ONLINE_EVENT_SENDERS]
|
|
return out
|
|
|
|
|
|
class Conn:
|
|
"""Socket + session key + a send lock.
|
|
|
|
The lock matters: pushes come from a heartbeat thread while the request
|
|
loop may be writing a Response. LSX frames are NUL-delimited, so two
|
|
interleaved sendall()s would corrupt the stream and the client would drop
|
|
the connection (which would look exactly like a protocol bug)."""
|
|
|
|
def __init__(self, sock, addr):
|
|
self.sock = sock
|
|
self.addr = addr
|
|
self.key = None
|
|
self.lock = threading.Lock()
|
|
self.alive = True
|
|
self.pushed_login = False
|
|
# Set True once GetAuthCode has been issued, so the heartbeat stops
|
|
# re-pushing Login/OnlineStatus events. Re-pushing after the auth code
|
|
# is granted re-enters FIFA's state-mutating Origin event dispatcher
|
|
# (case 2 @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts
|
|
# on the FE bus) ~24 more times DURING Blaze login, which we do not want.
|
|
self.stop_events = False
|
|
|
|
def send_plain(self, xml: str):
|
|
with self.lock:
|
|
self.sock.sendall(xml.encode() + b"\0")
|
|
|
|
def send_enc(self, xml: str):
|
|
with self.lock:
|
|
self.sock.sendall(lsx_encrypt(xml, self.key))
|
|
|
|
def push_login_state(self, why: str):
|
|
if not EVENTS_ENABLED:
|
|
return
|
|
for frame in login_event_frames():
|
|
try:
|
|
self.send_enc(frame)
|
|
except Exception as e:
|
|
self.alive = False
|
|
log("push failed:", e)
|
|
return
|
|
log(f"PUSH ({why}) >> {frame}")
|
|
if not self.pushed_login:
|
|
self.pushed_login = True
|
|
log("*** first <Login IsLoggedIn=\"true\"> pushed. Watch for "
|
|
"GetAuthCode next. ***")
|
|
|
|
def heartbeat(self):
|
|
"""Re-push the login state a bounded number of times.
|
|
|
|
FIFA builds its Origin event handlers lazily; if our first push lands
|
|
before the <Login> handler is registered the matcher simply finds no
|
|
handler and drops it. Re-pushing removes that race without needing to
|
|
guess the exact registration moment."""
|
|
for _ in range(EVENT_HEARTBEAT_COUNT):
|
|
time.sleep(EVENT_HEARTBEAT_SECS)
|
|
if not self.alive or self.stop_events:
|
|
return
|
|
self.push_login_state("heartbeat")
|
|
|
|
|
|
# ---------------------------------------------------------------- responses
|
|
def resp(mid, body, sender=""):
|
|
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
|
|
|
|
|
|
def build_reply(mid, req_name, attrs, conn, recipient=""):
|
|
"""Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script).
|
|
|
|
CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
|
|
0x1471189b0 rejects any <Response> whose `sender` attribute does not
|
|
byte-equal the `recipient` the client put on the matching <Request> (it
|
|
reads serviceNames[facility]; with our empty GetConfigResponse all 34 names
|
|
are "" so recipient="" for every verb after GetConfig, which itself uses the
|
|
hard-coded literal "EbisuSDK"). We were answering GetProfile/GetAuthCode/
|
|
QueryEntitlements with sender="EbisuSDK" -> silently discarded -> GetProfile
|
|
(the SOLE writer of OriginSDK+0x3a0 default-user) never took -> the whole
|
|
online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO the request's
|
|
recipient back as the response sender. This local `resp` shadows the module
|
|
one and makes `sender` default to `recipient`."""
|
|
def resp(mid, body, sender=None):
|
|
s = recipient if sender is None else sender
|
|
return f'<LSX><Response id="{mid}" sender="{s}"><{body}/></Response></LSX>'
|
|
|
|
if req_name == "GetInternetConnectedState":
|
|
# FLAG (1): "internet is reachable". Stub hardcoded connected="0"
|
|
# -> "log in to Origin". This is NOT the logged-in flag; see the
|
|
# module docstring.
|
|
return resp(mid, 'InternetConnectedState connected="1"')
|
|
|
|
if req_name == "GetAuthCode":
|
|
# Request shape is built at 0x14713b8d0:
|
|
# <GetAuthCode ClientId="..." Scope="..."/>
|
|
# Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
|
|
#
|
|
# THE ATTRIBUTE NAME IS "value" -- verified, not guessed:
|
|
# the "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
|
|
# -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
|
|
# attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
|
|
# @0x1436c7768, concat at 0x14712d130) and does exactly ONE
|
|
# get-attribute-as-string call 0x14713fe50(node, "value", &dest).
|
|
# dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands at
|
|
# +0xc8 -- which is what OriginRequestAuthCodeSync's impl 0x1470e67f0
|
|
# reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as *out_len.
|
|
# Code=/Return= are NEVER read; with them alone the parsed string is
|
|
# empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the OSDK classifier
|
|
# 0x14717d5d0 falls into its `test rbp,rbp / je` arm and reports
|
|
# OSDK_UNDERAGE_ERROR (a mislabelled "no auth code" fallback).
|
|
# Code=/Return= are kept only as harmless padding.
|
|
client_id = attrs.get("ClientId", "")
|
|
scope = attrs.get("Scope", "")
|
|
code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
|
|
# Only touch the run's success-signal files on a REAL request (conn is a
|
|
# live socket). --selftest calls build_reply(..., conn=None); if it
|
|
# wrote these files it would pre-satisfy watch-step "authcode.txt becomes
|
|
# non-empty" and make a non-event read as success on the next live run.
|
|
if conn is not None:
|
|
for path, val in ((AUTHCODE_FILE, code), (CLIENTID_FILE, client_id)):
|
|
try:
|
|
with open(path, "w") as fh:
|
|
fh.write(val)
|
|
except Exception:
|
|
pass
|
|
# GetAuthCode has fired: stop the heartbeat so we do not keep
|
|
# re-pushing Login/OnlineStatus events during Blaze login.
|
|
conn.stop_events = True
|
|
log("*** GetAuthCode ISSUED ***")
|
|
log(f" ClientId={client_id!r} Scope={scope!r}")
|
|
log(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)")
|
|
return resp(mid,
|
|
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
|
|
|
|
if req_name == "QueryEntitlements":
|
|
item = (f'<OriginItem ItemId="{ENTITLEMENT_TAG}" EntitlementId="1" '
|
|
f'ResourceId="{CONTENT_ID}" OfferId="{CONTENT_ID}" '
|
|
f'GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>')
|
|
return (f'<LSX><Response id="{mid}" sender="{recipient}">'
|
|
f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
|
|
f'</Response></LSX>')
|
|
|
|
if req_name == "GetProfile":
|
|
# This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
|
|
# (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
|
|
# @0x1470da680 are bare reads of those fields, written only by
|
|
# OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
|
|
# ONLY PersonaId/UserId/Persona are substituted from ACCOUNT; the rest
|
|
# of this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/
|
|
# AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4
|
|
# and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim.
|
|
return resp(mid,
|
|
f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
|
|
f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
|
|
f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" '
|
|
f'CommerceCurrency="USD"')
|
|
|
|
if req_name == "GetGameInfo":
|
|
gi = attrs.get("GameInfoId")
|
|
if gi == "LANGUAGES":
|
|
return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,'
|
|
'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,'
|
|
'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"')
|
|
if gi == "UPTODATE":
|
|
# MUST be true or the client shows "Your title version is
|
|
# outdated" and blocks all online features.
|
|
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
|
|
if gi == "FULLGAME_PURCHASED" and FULLGAME_PURCHASED_TRUE:
|
|
# OFF by default: v1 answered "false" here (fell through to default).
|
|
# Keeping this gated makes OPENFUT_LSX_EVENTS=0 byte-identical to v1.
|
|
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
|
|
# FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a trial;
|
|
# matches v1 exactly)
|
|
return resp(mid, 'GetGameInfoResponse GameInfo="false"')
|
|
|
|
if req_name == "GetSetting":
|
|
sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE
|
|
if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
|
|
return resp(mid, 'GetSettingResponse Setting="production"')
|
|
if sid == "LANGUAGE":
|
|
return resp(mid, f'GetSettingResponse Setting="{LOCALE}"')
|
|
return resp(mid, 'GetSettingResponse Setting="false"')
|
|
|
|
if req_name == "GetConfig":
|
|
return resp(mid, 'GetConfigResponse Config="false"')
|
|
|
|
if req_name == "IsProgressiveInstallationAvailable":
|
|
return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
|
|
'Available="false"')
|
|
|
|
return resp(mid, 'ErrorSuccess Code="0" Description=""')
|
|
|
|
|
|
# Trigger points: push right after answering these verbs. GetProfile is the
|
|
# earliest safe moment -- by then the SDK has built its handler set and has a
|
|
# default user, so a Login event has somewhere to land.
|
|
PUSH_AFTER = {
|
|
"GetProfile": "after GetProfile",
|
|
"GetInternetConnectedState": "after GetInternetConnectedState",
|
|
"GetGameInfo": "after GetGameInfo UPTODATE",
|
|
}
|
|
|
|
REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
|
|
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
|
|
# The response `sender` must byte-equal the request's `recipient` (matcher
|
|
# 0x1471189b0). Captured separately (default "") so a frame that ever lacks
|
|
# `recipient` still gets answered fast instead of a 15s stall.
|
|
RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"')
|
|
|
|
|
|
def serve(sock, addr):
|
|
conn = Conn(sock, addr)
|
|
hb = None
|
|
try:
|
|
# 1. plaintext Challenge
|
|
conn.send_plain(f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
|
|
f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
|
|
|
|
# 2. plaintext ChallengeResponse from client. The emu parses response="
|
|
# BEFORE key=" (0x180001f10); extract both so challenge_response can
|
|
# echo the client's own 3rd block (REPACK_INTEL.md C1/C2).
|
|
data = sock.recv(4096)
|
|
txt = data.decode(errors="replace")
|
|
mk = re.search(r'key="([^"]*)"', txt)
|
|
mr = re.search(r'response="([^"]*)"', txt)
|
|
client_key = mk.group(1) if mk else CHALLENGE_KEY
|
|
client_resp = mr.group(1) if mr else ""
|
|
h = challenge_response(client_key, client_resp)
|
|
conn.key = derive_session_key(h)
|
|
log("handshake accepted; session crypto initialized")
|
|
|
|
# 3. plaintext ChallengeAccepted
|
|
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
|
|
|
|
# 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
|
|
# unsolicited Event is the plaintext, pre-session-key Challenge; there
|
|
# is zero evidence an *encrypted mid-session* Event routes to the same
|
|
# parser (REPACK_INTEL.md sec.4 step 2). So push the Login Event here,
|
|
# in PLAINTEXT, right after ChallengeAccepted -- before the stream goes
|
|
# encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
|
|
if LOGIN_PLAINTEXT and EVENTS_ENABLED:
|
|
conn.stop_events = True
|
|
for frame in login_event_frames():
|
|
conn.send_plain(frame)
|
|
log(f"PUSH (plaintext post-accept) >> {frame}")
|
|
|
|
# 4. encrypted request/response loop
|
|
buf = b""
|
|
while True:
|
|
data = sock.recv(65536)
|
|
if not data:
|
|
break
|
|
# Buffer partial frames: a 64 KiB recv can straddle a NUL boundary,
|
|
# and split() would silently drop the trailing partial (C3).
|
|
buf += data
|
|
*frames, buf = buf.split(b"\0")
|
|
for chunk in filter(None, frames):
|
|
try:
|
|
xml = lsx_decrypt(chunk + b"\0", conn.key)
|
|
except Exception as e:
|
|
log("decrypt fail:", e)
|
|
continue
|
|
mm = REQ_RE.search(xml)
|
|
if not mm:
|
|
log("<<", safe_xml_for_log(xml))
|
|
continue
|
|
mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
|
|
attrs = dict(ATTR_RE.findall(rest))
|
|
rm = RECIP_RE.search(xml)
|
|
recip = rm.group(1) if rm else ""
|
|
reply = build_reply(mid, name, attrs, conn, recip)
|
|
log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
|
|
log(">>", safe_xml_for_log(reply))
|
|
conn.send_enc(reply)
|
|
|
|
why = PUSH_AFTER.get(name)
|
|
if why and EVENTS_ENABLED:
|
|
# For GetGameInfo only fire on UPTODATE, otherwise we would
|
|
# push three times per boot for FREETRIAL/LANGUAGES too.
|
|
if name != "GetGameInfo" or attrs.get("GameInfoId") == "UPTODATE":
|
|
conn.push_login_state(why)
|
|
if hb is None:
|
|
hb = threading.Thread(target=conn.heartbeat,
|
|
daemon=True)
|
|
hb.start()
|
|
except Exception as e:
|
|
log("connection error:", e)
|
|
finally:
|
|
conn.alive = False
|
|
try:
|
|
sock.close()
|
|
except Exception:
|
|
pass
|
|
log("connection closed", addr)
|
|
|
|
|
|
def main():
|
|
s = socket.socket()
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind((os.environ.get("OPENFUT_BIND", "127.0.0.1"), 4216))
|
|
s.listen(8)
|
|
log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)")
|
|
log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}"
|
|
f" (period={EVENT_HEARTBEAT_SECS}s count={EVENT_HEARTBEAT_COUNT})")
|
|
while True:
|
|
c, a = s.accept()
|
|
log("connection from", a)
|
|
threading.Thread(target=serve, args=(c, a), daemon=True).start()
|
|
|
|
|
|
# ---------------------------------------------------------------- self-test
|
|
def selftest():
|
|
"""No live game needed. Proves the crypto is untouched and the new event
|
|
frames encrypt/decrypt cleanly through our own codec."""
|
|
h = challenge_response("18a70055a3541fb27ab8e0f47afad18c")
|
|
assert h.startswith("e4f5166209929e15"), h
|
|
k = derive_session_key(h)
|
|
assert k.hex() == "6a9da3e78615153cc2f10eec25ae6382", k.hex()
|
|
print("[ok] crypto matches the captured 2026-07-30 session verbatim")
|
|
frames = login_event_frames()
|
|
assert len(frames) == len(LOGIN_EVENT_SENDERS) + len(ONLINE_EVENT_SENDERS)
|
|
for f in frames:
|
|
assert lsx_decrypt(lsx_encrypt(f, k), k) == f
|
|
print("[ok] round-trip:", f)
|
|
# "" sender first (correct for the current empty service-name table)
|
|
assert '<Event sender=""><Login IsLoggedIn="true"/></Event>' in frames[0]
|
|
r = build_reply(42, "GetAuthCode", {"ClientId": "X", "Scope": "Y"}, None)
|
|
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
|
|
# actually reads; Code=/Return= are legacy padding.
|
|
assert '<AuthCode value=' in r, r
|
|
redacted = safe_xml_for_log(
|
|
'<AuthCode value="secret" Code="secret" Return="secret"/>')
|
|
assert "secret" not in redacted and redacted.count("[REDACTED]") == 3, redacted
|
|
status = safe_xml_for_log('<ErrorSuccess Code="0" Description=""/>')
|
|
assert 'Code="0"' in status, status
|
|
print("[ok] GetAuthCode response shape and log redaction")
|
|
print("[ok] selftest passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--selftest" in sys.argv:
|
|
selftest()
|
|
else:
|
|
main()
|