Files
OpenFUT/fifa17-recon/tools/lsx_force_online.py
T
funman300 6ddd5e9d47 fifa17-recon: offline FUT squad-shell working + full card-system RE
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
2026-08-01 20:24:30 -07:00

342 lines
13 KiB
Python

#!/usr/bin/env python3
"""
lsx_force_online.py -- force FIFA 17's Origin/LSX layer to report ONLINE.
THIS IS LAYER 1. It must succeed before ANY Blaze work (blaze_responder_v3.py)
is reachable. The client's own flow graph gates FUT behind Origin:
{"name":"launchFUTFlow","file":"/online/origin.nav",
"outputs":{"OriginIsOnlineTrue":"startFutBlazeLogin","quit":"mainMenu"}}
so while Origin says offline the client shows
"Unable to connect to the EA Servers ... log in to Origin in Online Mode"
(TXT_ORIGIN_OFFLINE_POPUP_TEXT) and sends Authentication::logout (1/0x46) to
Blaze instead of login (1/0x0A).
PREFERRED FIX IS NOT THIS FILE. Prefer `lsx_responder.py`: bind 127.0.0.1:4216
BEFORE launching FIFA 17. The Steampunks stub's socket setup
(sub_0x6ffffc932130) does bind -> listen -> accept with NO SO_REUSEADDR and, on
bind failure, branches to 0x6ffffc932245 -> freeaddrinfo/closesocket/WSACleanup/
return 1 -- i.e. it stands down CLEANLY and the game's OriginSDK connects to us.
That is a real request-driven LSX server and can also answer GetAuthCode,
GetProfile and QueryEntitlements, which no memory patch can synthesise.
USE THIS FILE when the game is ALREADY RUNNING and you only want to flip the
online verdict (e.g. to confirm `OriginIsOnlineTrue` fires at all).
------------------------------------------------------------------------------
PATCH (A) -- the emu's response template. DEFAULT.
------------------------------------------------------------------------------
Region : stp-origin_emu.dll unpacked image, 0x6ffffc931000-0x6ffffc93d000,
already mapped rwxp (no mprotect needed).
Template: 0x6ffffc9353b0
<LSX><Response id="%d" sender=""><InternetConnectedState
connected="0"/></Response></LSX>
VA : 0x6ffffc9353f4 (the '0' inside connected="0")
BEFORE : 30 ('0')
AFTER : 31 ('1')
The format string is re-read on every use, so the patch applies to every
future emission -- but the stub is a BLIND FIXED-SCRIPT REPLAYER (18 canned
responses in a fixed order, then ErrorSuccess forever, loop 0x6ffffc932dd3).
Template 17 is the InternetConnectedState one. If the game has already
passed step 17, the stub is parked in the ErrorSuccess loop and will never
emit this template again -- the patch then does nothing, and Q-to-reconnect
does NOT help. Patch BEFORE the game boots past the Origin probe, or use
patch (B) / lsx_responder.py.
------------------------------------------------------------------------------
PATCH (B) -- g_originOnline, the parsed verdict itself. --flag / --hold
------------------------------------------------------------------------------
Module : FIFA17.exe, mapped flat at 0x140000000 under Wine/Proton.
VA : 0x1443337f8 (g_originOnline, one byte)
BEFORE : 00 (or stale garbage -- see caveat below)
AFTER : 01
Sole reader : 0x146f38aa9 movzx eax, BYTE PTR [0x1443337f8] (the popup /
OriginIsOnline predicate; a bare global read, no refresh)
Sole writer : 0x146f1e6d9 mov BYTE PTR [0x1443337f8], al
-- the LSX GetInternetConnectedState callback (0x146f1e6b0),
which also broadcasts FE::FIFA::OriginOnlineEvent.
CAVEAT (measured live): this byte currently reads 0x01 already, yet the flow
still fails. The stub answered the FIRST GetInternetConnectedState (id 17)
with a well-formed connected="0" but answered the LATER polls (ids 19-22)
with a type-mismatched generic ErrorSuccess, so the SDK found no `connected`
attribute and stored stale garbage. Therefore: a 1 in this byte is NOT
sufficient on its own -- the FE::FIFA::OriginOnlineEvent broadcast that the
writer performs is what actually drives origin.nav. --hold keeps the byte at
1 so it cannot be clobbered, but only a real LSX reply (lsx_responder.py)
makes the callback run and fire the event. Treat (B) as diagnostic.
------------------------------------------------------------------------------
CLEAN ROOM: every address above 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 derives from the 2021 EA/FIFA leak.
USAGE
python3 lsx_force_online.py # apply (A); idempotent
python3 lsx_force_online.py --flag # apply (A) + (B) once
python3 lsx_force_online.py --hold # (A) + rewrite (B) every 0.5s
python3 lsx_force_online.py --status # read both, change nothing
python3 lsx_force_online.py --restore # put the saved originals back
python3 lsx_force_online.py --watch # wait for FIFA17.exe, then apply
Requires /proc/PID/mem write access (kernel.yama.ptrace_scope=0, or run as the
same user with ptrace_scope=1 which is already known to work here).
"""
import glob
import os
import sys
import time
# ------------------------------------------------------------------ patches
# (A) stp-origin_emu.dll InternetConnectedState template.
EMU_TEMPLATE_VA = 0x6FFFFC9353B0
EMU_PATCH_VA = 0x6FFFFC9353F4
EMU_BEFORE = b"0" # 0x30
EMU_AFTER = b"1" # 0x31
EMU_TEMPLATE_HEAD = b'<LSX><Response id="%d" sender=""><InternetConnectedState'
EMU_REGION = (0x6FFFFC931000, 0x6FFFFC93D000) # rwxp unpacked image
# (B) FIFA17.exe g_originOnline.
FLAG_VA = 0x1443337F8
FLAG_AFTER = b"\x01"
BACKUP_DIR = "/tmp/lsx_force_online"
LOGFILE = "/tmp/lsx_force_online.log"
def log(m):
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
print(line, flush=True)
try:
with open(LOGFILE, "a") as fh:
fh.write(line + "\n")
except Exception:
pass
# ------------------------------------------------------------------ process
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
return None
def rd(pid, va, n):
with open("/proc/%d/mem" % pid, "rb") as f:
f.seek(va)
return f.read(n)
def wr(pid, va, b):
with open("/proc/%d/mem" % pid, "r+b") as f:
f.seek(va)
f.write(b)
def backup(va, orig):
os.makedirs(BACKUP_DIR, exist_ok=True)
p = os.path.join(BACKUP_DIR, "orig_%x.bin" % va)
if not os.path.exists(p): # never overwrite the true original
with open(p, "wb") as fh:
fh.write(orig)
return p
# ------------------------------------------------------ template relocation
#
# The unpacked emu image address has been stable at 0x6ffffc931000 across our
# runs, but it is a runtime mapping -- do not trust it blindly. Verify the
# template is where we expect; if not, rescan the rwxp regions for it and
# recompute the patch offset from the template head.
def locate_emu_patch(pid):
"""-> (patch_va, template_va) or (None, None)."""
want = EMU_TEMPLATE_HEAD
try:
head = rd(pid, EMU_TEMPLATE_VA, len(want))
if head == want:
return EMU_PATCH_VA, EMU_TEMPLATE_VA
except Exception:
pass
log("template not at 0x%x -- rescanning writable+executable maps"
% EMU_TEMPLATE_VA)
delta = EMU_PATCH_VA - EMU_TEMPLATE_VA # +0x44
try:
maps = open("/proc/%d/maps" % pid).read().splitlines()
except Exception as e:
log("cannot read maps: %s" % e)
return None, None
for line in maps:
try:
rng, perms = line.split()[0], line.split()[1]
if "w" not in perms or "r" not in perms:
continue
lo, hi = (int(x, 16) for x in rng.split("-"))
if hi - lo > 64 * 1024 * 1024:
continue
blob = rd(pid, lo, hi - lo)
except Exception:
continue
off = blob.find(want)
while off != -1:
tva = lo + off
pva = tva + delta
try:
if rd(pid, pva, 1) in (EMU_BEFORE, EMU_AFTER):
log("template relocated: 0x%x (patch byte 0x%x)" % (tva, pva))
return pva, tva
except Exception:
pass
off = blob.find(want, off + 1)
return None, None
# ------------------------------------------------------------------ actions
def show_template(pid, tva):
try:
raw = rd(pid, tva, 96).split(b"\x00")[0]
log(" template @0x%x: %s" % (tva, raw.decode("ascii", "replace")))
except Exception:
pass
def apply_emu(pid):
pva, tva = locate_emu_patch(pid)
if pva is None:
log("PATCH (A): template NOT FOUND -- is stp-origin_emu loaded? "
"(is the game past the Origin probe already?)")
return False
cur = rd(pid, pva, 1)
if cur == EMU_AFTER:
log("PATCH (A): already applied at 0x%x (connected=\"1\")" % pva)
show_template(pid, tva)
return True
if cur != EMU_BEFORE:
log("PATCH (A): UNEXPECTED byte %s at 0x%x (want %s) -- refusing"
% (cur.hex(), pva, EMU_BEFORE.hex()))
return False
backup(pva, cur)
wr(pid, pva, EMU_AFTER)
now = rd(pid, pva, 1)
log("PATCH (A): 0x%x %s -> %s %s"
% (pva, cur.hex(), now.hex(), "OK" if now == EMU_AFTER else "FAILED"))
show_template(pid, tva)
return now == EMU_AFTER
def apply_flag(pid):
cur = rd(pid, FLAG_VA, 1)
if cur == FLAG_AFTER:
log("PATCH (B): g_originOnline @0x%x already 0x01" % FLAG_VA)
return True
backup(FLAG_VA, cur)
wr(pid, FLAG_VA, FLAG_AFTER)
now = rd(pid, FLAG_VA, 1)
log("PATCH (B): g_originOnline @0x%x %s -> %s %s"
% (FLAG_VA, cur.hex(), now.hex(), "OK" if now == FLAG_AFTER else "FAILED"))
return now == FLAG_AFTER
def status(pid):
pva, tva = locate_emu_patch(pid)
if pva is None:
log("STATUS (A): template not found in this process")
else:
b = rd(pid, pva, 1)
log("STATUS (A): 0x%x = %s -> connected=\"%s\"%s"
% (pva, b.hex(), b.decode("ascii", "replace"),
" [PATCHED]" if b == EMU_AFTER else ""))
show_template(pid, tva)
b = rd(pid, FLAG_VA, 1)
log("STATUS (B): g_originOnline @0x%x = %s (%s)"
% (FLAG_VA, b.hex(),
"online" if b == b"\x01" else "offline/garbage"))
log("NOTE: a 1 in (B) is NOT proof of success -- see the CAVEAT in this "
"file's docstring. Only a well-formed LSX connected=\"1\" reply makes "
"the callback broadcast FE::FIFA::OriginOnlineEvent, which is what "
"origin.nav actually consumes.")
def restore(pid):
if not os.path.isdir(BACKUP_DIR):
log("RESTORE: nothing saved in %s" % BACKUP_DIR)
return
for fn in sorted(os.listdir(BACKUP_DIR)):
if not fn.startswith("orig_"):
continue
va = int(fn[5:].split(".")[0], 16)
orig = open(os.path.join(BACKUP_DIR, fn), "rb").read()
try:
wr(pid, va, orig)
log("RESTORE: 0x%x <- %s (now %s)"
% (va, orig.hex(), rd(pid, va, len(orig)).hex()))
except Exception as e:
log("RESTORE: 0x%x FAILED: %s" % (va, e))
# ------------------------------------------------------------------ main
def main():
argv = sys.argv[1:]
want_flag = "--flag" in argv or "--hold" in argv
hold = "--hold" in argv
if "--watch" in argv:
log("=== WATCH: waiting for FIFA17.exe ===")
seen = set()
while True:
pid = find_pid()
if pid and pid not in seen:
try:
if apply_emu(pid):
if want_flag:
apply_flag(pid)
seen.add(pid)
except Exception as e:
log("pid %d not ready yet (%s)" % (pid, e))
time.sleep(1)
pid = find_pid()
if not pid:
raise SystemExit("FIFA17.exe not running (use --watch to wait for it)")
log("=== lsx_force_online pid=%d ===" % pid)
if "--status" in argv:
status(pid)
return
if "--restore" in argv:
restore(pid)
return
apply_emu(pid)
if want_flag:
apply_flag(pid)
if hold:
log("HOLD: rewriting g_originOnline every 0.5 s (Ctrl-C to stop)")
try:
while True:
try:
if rd(pid, FLAG_VA, 1) != FLAG_AFTER:
wr(pid, FLAG_VA, FLAG_AFTER)
log("HOLD: g_originOnline was clobbered, reset to 0x01")
except Exception as e:
log("HOLD: process gone (%s)" % e)
break
time.sleep(0.5)
except KeyboardInterrupt:
log("HOLD: stopped")
if __name__ == "__main__":
main()