6ddd5e9d47
Milestone: FIFA 17 Ultimate Team boots end-to-end on our offline backend
past every EA gate into the hub and a live Squads editor (correct 4-4-2,
5-star squad, no freezes).
Key findings this session:
- userMassInfo MUST stay {} (any content desyncs the massinfo parser
0x180174630 -> tokenizer busy-loop freeze). Deliver the squad via
GET /squad/0 (fetched on Squads-tab entry) instead.
- Player cards render generic because the card view-model (0x1800d7920)
reads identity/rating/face from a resolved record at item+0x10, filled
by a lookup (0x18011cca0) in the FUT item-definition std::map at
CardsDb+0x160c0 -- which is EMPTY offline -> default blank record.
- Version advertising (itemDbVersion/checkServerDbVersion) is proven inert
(JSON fields routed to the skip handler). Owned items don't auto-trigger
a definition fetch. In-place map overwrite is dead (map stays empty).
- Definition-serving endpoints (item/resource, defid, item?idList) built +
ready; the fetch trigger lives in the packed FIFA17.exe.
New: docs/CARD_SYSTEM.md (findings + ordered next-steps plan for real
player cards: patch-POC, dbdata extractor, drive FIFA17.exe fetch, or
live-memory store injection). Plus tools: fut_seed.py (squad ladder +
definition serving), fifadrive.sh, vgamepad.py, and the login-RE toolset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
142 lines
6.0 KiB
Python
142 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture-first LSX diagnostic for FIFA 17 on 127.0.0.1:4216.
|
|
|
|
Goal: learn the REAL handshake the game speaks, losing nothing to a crash.
|
|
- Detects who sends first (server-initiates vs client-initiates).
|
|
- Sends our best-guess Challenge, then captures the client's ChallengeResponse
|
|
(its key + exact XML format) -- FLUSHED TO DISK BEFORE we send anything risky.
|
|
- Then runs the full instrumented handshake (H, session key, encrypted loop),
|
|
logging every computed value + every frame both directions.
|
|
So even if the game crashes on our ChallengeAccepted, the key capture is saved.
|
|
"""
|
|
import socket, time, threading, os, sys
|
|
import lsx_responder as L # reuse crypto + build_reply; importing does NOT run main
|
|
|
|
LOG = "/tmp/lsx_capture.log"
|
|
RAWDIR = "/tmp/lsx_raw"
|
|
os.makedirs(RAWDIR, exist_ok=True)
|
|
_n = 0
|
|
|
|
def log(m):
|
|
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
|
|
print(line, flush=True)
|
|
with open(LOG, "a") as f:
|
|
f.write(line + "\n"); f.flush(); os.fsync(f.fileno())
|
|
|
|
def dump(tag, b):
|
|
log("%s : %dB" % (tag, len(b)))
|
|
for i in range(0, min(len(b), 512), 16):
|
|
c = b[i:i+16]
|
|
hx = " ".join("%02x" % x for x in c)
|
|
asc = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
|
log(" %04x: %-47s %s" % (i, hx, asc))
|
|
try:
|
|
p = "%s/%s_%d.bin" % (RAWDIR, tag.replace(" ", "_").replace("#", "").replace(":", ""), int(time.time()*1000) % 1000000)
|
|
open(p, "wb").write(b)
|
|
except Exception:
|
|
pass
|
|
|
|
def serve(conn, addr):
|
|
global _n; _n += 1; n = _n
|
|
log("================= CONN #%d from %s =================" % (n, addr))
|
|
# Phase 0 -- does the CLIENT speak first? (would mean lsx_responder has the
|
|
# initiator backwards, a prime crash suspect)
|
|
conn.settimeout(3.0)
|
|
try:
|
|
pre = conn.recv(8192)
|
|
if pre:
|
|
dump("C%d PRE (client spoke FIRST)" % n, pre)
|
|
log("C%d: !!! client initiates -- our server-sends-Challenge model is WRONG" % n)
|
|
except socket.timeout:
|
|
log("C%d: silent 3s -> server initiates (send Challenge)" % n)
|
|
pre = b""
|
|
except Exception as e:
|
|
log("C%d pre-recv err: %s" % (n, e)); pre = b""
|
|
|
|
# Phase 1 -- send our best-guess plaintext Challenge, capture the reply.
|
|
chal = ('<LSX><Event sender="EALS"><Challenge key="%s" build="%s" version="%s"/>'
|
|
'</Event></LSX>' % (L.CHALLENGE_KEY, L.BUILD, L.VERSION))
|
|
try:
|
|
conn.sendall(chal.encode() + b"\0")
|
|
log("C%d >> Challenge sent (%dB): %s" % (n, len(chal)+1, chal))
|
|
except Exception as e:
|
|
log("C%d send-Challenge err: %s" % (n, e)); return
|
|
|
|
try:
|
|
rep = conn.recv(8192)
|
|
except Exception as e:
|
|
log("C%d recv-after-Challenge err: %s" % (n, e)); return
|
|
if not rep:
|
|
log("C%d: client closed after our Challenge (no ChallengeResponse) -- "
|
|
"Challenge format likely rejected" % n); return
|
|
dump("C%d CHALLENGE-RESPONSE (client)" % n, rep) # <-- THE KEY CAPTURE, already flushed
|
|
|
|
# Phase 2 -- log what WE would compute (do NOT let a crypto error abort logging)
|
|
txt = rep.split(b"\0")[0].decode(errors="replace")
|
|
log("C%d client-reply text: %s" % (n, txt))
|
|
import re
|
|
mk = re.search(r'key="([^"]*)"', txt)
|
|
mr = re.search(r'response="([^"]*)"', txt)
|
|
client_key = mk.group(1) if mk else L.CHALLENGE_KEY
|
|
client_resp = mr.group(1) if mr else None
|
|
log("C%d parsed: client_key=%r client_response=%r" % (n, client_key, client_resp))
|
|
try:
|
|
our_h = L.challenge_response(client_key)
|
|
log("C%d our computed H (ChallengeAccepted.response) = %s" % (n, our_h))
|
|
if client_resp:
|
|
log("C%d MATCH client_response==our_H ? %s" % (n, client_resp == our_h))
|
|
skey = L.derive_session_key(our_h)
|
|
log("C%d derived session_key = %s" % (n, skey.hex()))
|
|
except Exception as e:
|
|
log("C%d crypto compute err: %s" % (n, e)); our_h = None; skey = None
|
|
|
|
# Phase 3 -- OPTIONAL: send ChallengeAccepted + run the encrypted loop.
|
|
# Guarded by env so the first run can stay capture-only (no risky send).
|
|
if os.environ.get("LSX_FULL") == "1" and our_h and skey:
|
|
try:
|
|
conn.sendall(L.resp(1, 'ChallengeAccepted response="%s"' % our_h, "EALS").encode() + b"\0")
|
|
log("C%d >> ChallengeAccepted sent" % n)
|
|
except Exception as e:
|
|
log("C%d send-Accepted err: %s" % (n, e)); return
|
|
while True:
|
|
try:
|
|
data = conn.recv(65536)
|
|
except Exception as e:
|
|
log("C%d recv-loop err: %s" % (n, e)); break
|
|
if not data:
|
|
log("C%d: client closed" % n); break
|
|
dump("C%d ENC-IN" % n, data)
|
|
for chunk in filter(None, data.split(b"\0")):
|
|
try:
|
|
xml = L.lsx_decrypt(chunk + b"\0", skey)
|
|
log("C%d << decrypted: %s" % (n, xml))
|
|
except Exception as e:
|
|
log("C%d decrypt fail: %s (raw %s)" % (n, e, chunk[:40])); continue
|
|
mm = L.REQ_RE.search(xml)
|
|
if mm:
|
|
reply = L.build_reply(mm.group(1), mm.group(2),
|
|
dict(L.ATTR_RE.findall(mm.group(3))))
|
|
try:
|
|
conn.sendall(L.lsx_encrypt(reply, skey))
|
|
log("C%d >> %s" % (n, reply))
|
|
except Exception as e:
|
|
log("C%d send-reply err: %s" % (n, e)); break
|
|
else:
|
|
log("C%d: capture-only (set LSX_FULL=1 to attempt full handshake). Holding 10s." % n)
|
|
time.sleep(10)
|
|
try: conn.close()
|
|
except Exception: pass
|
|
|
|
def main():
|
|
s = socket.socket()
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind(("127.0.0.1", 4216)); s.listen(8)
|
|
log("=== capture_lsx listening on 127.0.0.1:4216 (LSX_FULL=%s) ===" %
|
|
os.environ.get("LSX_FULL", "0"))
|
|
while True:
|
|
c, a = s.accept()
|
|
threading.Thread(target=serve, args=(c, a), daemon=True).start()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|