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.
246 lines
11 KiB
Python
246 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT
|
|
# sourced from fut_account.py here. The live pair is lsx_responder_v2.py +
|
|
# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only.
|
|
"""
|
|
OpenFUT clean-room LSX responder for FIFA 17 (replaces the Steampunks stp-origin_emu
|
|
in-process stub on 127.0.0.1:4216).
|
|
|
|
PROVENANCE / CLEAN-ROOM: every constant and algorithm here was recovered by static +
|
|
dynamic analysis of binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in
|
|
our own running process). Nothing is derived from the 2021 EA/FIFA leak.
|
|
|
|
WIRE PROTOCOL (reversed from stp-origin_emu.dll @ base 0x6ffffc930000):
|
|
transport : TCP 127.0.0.1:4216, each message is a NUL-terminated byte string
|
|
(send length == strlen(msg)+1).
|
|
handshake : server sends <Challenge key="..."> IN PLAINTEXT.
|
|
client replies (plaintext) with response="..." and key="..." attrs.
|
|
server replies <ChallengeAccepted response="H"> where
|
|
H = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED))
|
|
K_FIXED = 000102030405060708090a0b0c0d0e0f (emu .rdata 0x935038)
|
|
session : every later message is
|
|
hex_lower( AES128_ECB_encrypt( pkcs7_pad16( xml ) ) )
|
|
under SESSION_KEY, which both sides derive from H (see derive_session_key).
|
|
Incoming messages are hex-decoded, decrypted, pad-stripped.
|
|
|
|
USAGE: bind this BEFORE launching FIFA 17. The stub's bind() then fails and its
|
|
server thread returns cleanly (it has no SO_REUSEADDR and no retry), so the
|
|
game's OriginSDK connects to us instead.
|
|
"""
|
|
import socket, sys, re, os, threading
|
|
from Crypto.Cipher import AES
|
|
|
|
# ---------------------------------------------------------------- identity
|
|
# SHARED CONSTANTS -- must stay byte-identical to stp-origin_emu.ini [Globals]
|
|
# AND to the same block at the top of blaze_responder_v3.py. A mismatch between
|
|
# what LSX reports here and what Blaze returns in LoginResponse.SESS.PDTL is
|
|
# exactly what raises AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_
|
|
# PERSONA / AUTH_ERR_PERSONA_NOT_FOUND.
|
|
PERSONA_ID = 33068179
|
|
PERSONA_NAME = "CAGE"
|
|
USER_ID = 33068179
|
|
CONTENT_ID = "1027460" # FIFA 17 EA offer id
|
|
ENTITLEMENT_TAG = "ONLINE_ACCESS"
|
|
|
|
# TWO-LAYER ORDERING: this file is LAYER 1. It must be listening on
|
|
# 127.0.0.1:4216 BEFORE FIFA 17 starts. Only once GetInternetConnectedState
|
|
# answers connected="1" does origin.nav take the OriginIsOnlineTrue exit into
|
|
# futBlazeLogin; only then does the client call GetAuthCode and put the result
|
|
# in Blaze LoginRequest.AUTH (1/0x0A). Until then it sends
|
|
# Authentication::logout (1/0x46) and blaze_responder_v3.py can do nothing.
|
|
# The auth code we hand out is echoed to AUTHCODE_FILE purely so the Blaze log
|
|
# can be correlated; blaze_responder_v3 accepts whatever AUTH arrives and never
|
|
# validates it against Nucleus.
|
|
AUTHCODE_FILE = "/tmp/openfut_authcode.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"
|
|
|
|
|
|
# ---------------------------------------------------------------- crypto
|
|
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))
|
|
|
|
|
|
def challenge_response(client_key_ascii: str) -> str:
|
|
"""H = hex(AES128-ECB(K_FIXED, PKCS7pad16(clientKeyAscii))).
|
|
|
|
VERIFIED against a captured client ChallengeResponse (2026-07-30): the 32-char
|
|
ASCII key is PKCS7-padded to 48 bytes (3 AES blocks, 96 hex), NOT zero-padded
|
|
to 32. With server challenge key '2b8ee7fa...' this reproduces the client's
|
|
response '00b9c8af...216684899' exactly."""
|
|
b = client_key_ascii.encode()
|
|
pad = 16 - (len(b) % 16) # 32 -> +16 full block -> 48 bytes
|
|
b += bytes([pad]) * pad
|
|
return AES.new(K_FIXED, AES.MODE_ECB).encrypt(b).hex()
|
|
|
|
|
|
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")
|
|
|
|
|
|
# ---------------------------------------------------------------- responses
|
|
def resp(mid, body, sender=""):
|
|
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
|
|
|
|
|
|
def build_reply(mid, req_name, attrs):
|
|
"""Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script)."""
|
|
if req_name == "GetInternetConnectedState":
|
|
# THE ONLINE GATE. Stub hardcoded connected="0" -> "log in to Origin".
|
|
return resp(mid, 'InternetConnectedState connected="1"')
|
|
|
|
if req_name == "GetAuthCode":
|
|
# Stub never implemented this at all. Element name is <AuthCode> (confirmed
|
|
# in FIFA17.exe element table @0x143937ae0). Emit both plausible value attrs;
|
|
# the client reads the one it knows and ignores the other.
|
|
code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
|
|
try:
|
|
with open(AUTHCODE_FILE, "w") as fh:
|
|
fh.write(code)
|
|
except Exception:
|
|
pass
|
|
print(f"[lsx] *** GetAuthCode issued: {code} -- this is what should "
|
|
f"arrive as Blaze LoginRequest.AUTH (1/0x0A) ***")
|
|
return resp(mid, f'AuthCode Code="{code}" Return="{code}"', sender="EbisuSDK")
|
|
|
|
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="EbisuSDK">'
|
|
f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
|
|
f'</Response></LSX>')
|
|
|
|
if req_name == "GetProfile":
|
|
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"', sender="EbisuSDK")
|
|
|
|
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":
|
|
# "is the title up to date?" -- MUST be true or the client shows
|
|
# "Your title version is outdated" and blocks all online features.
|
|
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
|
|
# FREETRIAL etc. -> false (retail, not a trial)
|
|
return resp(mid, 'GetGameInfoResponse GameInfo="false"')
|
|
|
|
if req_name == "GetSetting":
|
|
sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE (ENVIRONMENT/LANGUAGE)
|
|
if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
|
|
return resp(mid, 'GetSettingResponse Setting="production"')
|
|
if sid == "LANGUAGE":
|
|
return resp(mid, 'GetSettingResponse Setting="en_US"')
|
|
return resp(mid, 'GetSettingResponse Setting="false"')
|
|
|
|
if req_name == "GetConfig":
|
|
return resp(mid, 'GetConfigResponse Config="false"', sender="EbisuSDK")
|
|
|
|
if req_name == "IsProgressiveInstallationAvailable":
|
|
return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
|
|
'Available="false"')
|
|
|
|
return resp(mid, 'ErrorSuccess Code="0" Description=""')
|
|
|
|
|
|
REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
|
|
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
|
|
|
|
|
|
def serve(conn):
|
|
# 1. plaintext Challenge
|
|
chal = (f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
|
|
f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
|
|
conn.sendall(chal.encode() + b"\0")
|
|
|
|
# 2. plaintext ChallengeResponse from client
|
|
data = conn.recv(4096)
|
|
m = re.search(r'key="([^"]*)"', data.decode(errors="replace"))
|
|
client_key = m.group(1) if m else CHALLENGE_KEY
|
|
h = challenge_response(client_key)
|
|
key = derive_session_key(h)
|
|
print(f"[lsx] client key={client_key} response={h[:16]}... session_key={key.hex()}")
|
|
|
|
# 3. plaintext ChallengeAccepted
|
|
conn.sendall(resp(1, f'ChallengeAccepted response="{h}"', "EALS").encode() + b"\0")
|
|
|
|
# 4. encrypted request/response loop
|
|
while True:
|
|
data = conn.recv(65536)
|
|
if not data:
|
|
break
|
|
for chunk in filter(None, data.split(b"\0")):
|
|
try:
|
|
xml = lsx_decrypt(chunk + b"\0", key)
|
|
except Exception as e:
|
|
print("[lsx] decrypt fail:", e)
|
|
continue
|
|
mm = REQ_RE.search(xml)
|
|
if not mm:
|
|
print("[lsx] <<", xml)
|
|
continue
|
|
mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
|
|
attrs = dict(ATTR_RE.findall(rest))
|
|
reply = build_reply(mid, name, attrs)
|
|
print(f"[lsx] << id={mid} {name} {attrs}\n[lsx] >> {reply}")
|
|
conn.sendall(lsx_encrypt(reply, key))
|
|
|
|
|
|
def main():
|
|
s = socket.socket()
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind(("127.0.0.1", 4216))
|
|
s.listen(8)
|
|
print("[lsx] listening on 127.0.0.1:4216 (start FIFA 17 now)")
|
|
while True:
|
|
c, a = s.accept()
|
|
print("[lsx] connection from", a)
|
|
threading.Thread(target=serve, args=(c,), daemon=True).start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|