5d5198f5d1
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.
WORKING END TO END (live-verified this session):
* match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
(0x180121b60). Play a match, get coins, W/D/L updates.
* packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
* quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
were destroyed for 0 coins. Now credits discardValue.
* POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
ROSTERUPDATE_URL. FUT_POW=1.
* account backend -- fut_account.py replaces 7 hardcoded copies of the persona
across 5 files; club/persona/online-profile editable via CLI.
CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
* FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
take externalPriceId(0x11a), not amount/currency.
* FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
unique among FUT deserializers) and parses only itemData -> dreamSquads.
* class -> deserializer resolution: the name literal is preceded by a 4-BYTE
HEADER and the factory LEA points at the header, so look up name_addr - 4.
Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
Draft schemas.
* live-only endpoints the request table never lists: ut/%s/squad/list,
ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
table is a floor, not a ceiling -- the log is the only ground truth.
* 163 RS4 call names exist; we served 17. All now served.
FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).
UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).
Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).
Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
237 lines
8.4 KiB
Python
237 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""OpenFUT Ghidra helper: opens the analysed cardsdll.dll program once and exposes
|
|
decompile / xref / vtable helpers, so each RE question is a small python file
|
|
instead of a JVM restart + OSGi compile.
|
|
|
|
Usage: ghidra_env.py <query.py> -- runs <query.py> with the helpers in scope
|
|
(see tools/ghidra_queries/ for worked examples)
|
|
|
|
WHY PYGHIDRA: this box's Ghidra 12.1.2 cannot compile .java scripts at all --
|
|
analyzeHeadless -postScript Foo.java dies with "Failed to get OSGi bundle
|
|
containing script" for EVERY script, including ones that ran before (it is the
|
|
in-process OSGi/javac path that is broken, not the scripts). PyGhidra bypasses it.
|
|
Setup once:
|
|
python3 -m venv gvenv
|
|
gvenv/bin/pip install --no-index \
|
|
--find-links /opt/ghidra/Ghidra/Features/PyGhidra/pypkg/dist pyghidra
|
|
gvenv/bin/python ghidra_env.py <query.py>
|
|
|
|
Two traps this file already works around:
|
|
* open_program(..., nested_project_location=False) -- otherwise pyghidra creates
|
|
a NEW empty project at <loc>/<name>/ and re-imports (losing the analysis).
|
|
* os._exit(0) at the end -- JVM teardown under jpype deadlocks forever.
|
|
* read_bytes() uses a Java byte[]; passing a Python bytearray to Memory.getBytes
|
|
silently reads NOTHING and every scan comes back with 0 hits.
|
|
"""
|
|
import os, sys
|
|
|
|
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/opt/ghidra")
|
|
import pyghidra
|
|
|
|
pyghidra.start(verbose=False)
|
|
|
|
from ghidra.app.decompiler import DecompInterface # noqa: E402
|
|
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
|
|
|
|
# Defaults target CardsDLL; override for another binary, e.g. powdll (the EASFC/POW
|
|
# layer, which is UNPACKED unlike FIFA17.exe):
|
|
# GHIDRA_DLL=/tmp/pow/powdll_Win64_retail.dll GHIDRA_PROJ_DIR=/tmp/pow \
|
|
# GHIDRA_PROJ=powproj ghidra_env.py <query.py>
|
|
DLL = os.environ.get("GHIDRA_DLL", "/tmp/fut/cardsdll.dll")
|
|
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/tmp/ghidra_fut")
|
|
PROJ = os.environ.get("GHIDRA_PROJ", "cardsdll")
|
|
PROG = os.environ.get("GHIDRA_PROG", os.path.basename(DLL))
|
|
|
|
# nested_project_location=False -> use /tmp/ghidra_fut/cardsdll.gpr itself (the
|
|
# already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/.
|
|
_ctx = pyghidra.open_program(DLL, project_location=PROJ_DIR, project_name=PROJ,
|
|
analyze=False, program_name=PROG,
|
|
nested_project_location=False)
|
|
flat = _ctx.__enter__()
|
|
prog = flat.getCurrentProgram()
|
|
mon = ConsoleTaskMonitor()
|
|
fm = prog.getFunctionManager()
|
|
listing = prog.getListing()
|
|
mem = prog.getMemory()
|
|
refs = prog.getReferenceManager()
|
|
|
|
_dec = DecompInterface()
|
|
_dec.openProgram(prog)
|
|
|
|
|
|
def addr(a):
|
|
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
|
|
|
|
|
|
def func(a):
|
|
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
|
|
|
|
|
|
def dec(a, timeout=180):
|
|
"""Decompiled C for the function containing address a."""
|
|
f = func(a)
|
|
if f is None:
|
|
return "// no function at %#x" % int(a)
|
|
r = _dec.decompileFunction(f, timeout, mon)
|
|
if r is None or not r.decompileCompleted():
|
|
return "// decompile failed for %s" % f.getName()
|
|
return str(r.getDecompiledFunction().getC())
|
|
|
|
|
|
def xrefs_to(a):
|
|
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
|
|
out = []
|
|
it = refs.getReferencesTo(addr(a))
|
|
while it.hasNext():
|
|
r = it.next()
|
|
f = fm.getFunctionContaining(r.getFromAddress())
|
|
out.append((int(r.getFromAddress().getOffset()), str(r.getReferenceType()),
|
|
f.getName() if f else "?",
|
|
int(f.getEntryPoint().getOffset()) if f else 0))
|
|
return out
|
|
|
|
|
|
def qword(a):
|
|
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
|
|
|
|
|
|
def dword(a):
|
|
return mem.getInt(addr(a)) & 0xFFFFFFFF
|
|
|
|
|
|
import jpype # noqa: E402
|
|
_JBYTE = jpype.JArray(jpype.JByte)
|
|
|
|
|
|
def read_bytes(a, n):
|
|
"""Bulk read n bytes at a. MUST use a Java byte[] -- passing a Python
|
|
bytearray to Memory.getBytes silently reads nothing (this bug quietly
|
|
zeroed several earlier scans)."""
|
|
buf = _JBYTE(int(n))
|
|
got = mem.getBytes(addr(a), buf)
|
|
return bytes((int(x) & 0xFF) for x in buf[:got])
|
|
|
|
|
|
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
|
|
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
|
|
hits = []
|
|
for b in mem.getBlocks():
|
|
if b.getName() not in blocks or not b.isInitialized():
|
|
continue
|
|
s = int(b.getStart().getOffset())
|
|
size = int(b.getEnd().getOffset()) - s + 1
|
|
off = 0
|
|
chunk = 1 << 20
|
|
while off < size:
|
|
ln = min(chunk, size - off)
|
|
try:
|
|
data = read_bytes(s + off, ln)
|
|
except Exception:
|
|
off += ln
|
|
continue
|
|
i = data.find(pattern)
|
|
while i != -1:
|
|
hits.append(s + off + i)
|
|
i = data.find(pattern, i + 1)
|
|
off += ln - (len(pattern) - 1) if ln == chunk else ln
|
|
return hits
|
|
|
|
|
|
def rd_str(a, maxlen=200):
|
|
b = bytearray()
|
|
p = int(a)
|
|
for _ in range(maxlen):
|
|
c = mem.getByte(addr(p)) & 0xFF
|
|
if c == 0:
|
|
break
|
|
b.append(c)
|
|
p += 1
|
|
return b.decode("utf-8", "replace")
|
|
|
|
|
|
def vtable(a, n=64):
|
|
"""[(slot_offset, target_addr, function_name)] reading n qwords at a."""
|
|
out = []
|
|
for i in range(n):
|
|
try:
|
|
t = qword(int(a) + i * 8)
|
|
except Exception:
|
|
break
|
|
f = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None
|
|
out.append((i * 8, t, f.getName() if f else ""))
|
|
return out
|
|
|
|
|
|
def class_deser(cls):
|
|
"""FutXServerResponse class name -> [(deserializer, vtable, factory), ...].
|
|
|
|
THE -4 RULE. A response class's name literal is preceded by a 4-BYTE HEADER,
|
|
and the factory's `lea r8,[rip+...]` points at THAT header, not at the text.
|
|
So the reference to look up is `name_addr - 4`. Six attempts at class->deser
|
|
resolution failed before this was noticed -- four of them returned zero
|
|
candidates and were nearly written up as "the class has no deserializer".
|
|
Ghidra does create the reference, so no manual instruction decoding is needed.
|
|
|
|
From the factory, the object's vtable is the .rdata address it references whose
|
|
first two qwords are functions; the deserializer is vtable slot +0x08.
|
|
|
|
Verified against known-good controls: FutSquadSave -> 0x180171a60,
|
|
FutSquadList -> 0x180172140, FutCreateMatch -> 0x180120380 (3/3 correct when it
|
|
resolves). It DOES produce false negatives -- FutDestroyMatch and
|
|
FutSeasonLoadData return nothing despite having known deserializers -- so treat
|
|
an empty result as "unknown", never as "no deserializer exists". Always include
|
|
a control with a known answer in any batch.
|
|
"""
|
|
res = []
|
|
for a in find_all(cls.encode() + b"\x00"):
|
|
for frm, typ, fn, ent in xrefs_to(a - 4):
|
|
if not ent:
|
|
continue
|
|
f = func(ent)
|
|
if f is None:
|
|
continue
|
|
for ad in f.getBody().getAddresses(True):
|
|
ins = listing.getInstructionAt(ad)
|
|
if ins is None:
|
|
continue
|
|
for r in ins.getReferencesFrom():
|
|
t = int(r.getToAddress().getOffset())
|
|
if not (0x1801E5000 <= t <= 0x1802891FF):
|
|
continue
|
|
try:
|
|
v0, v1 = qword(t), qword(t + 8)
|
|
except Exception:
|
|
continue
|
|
if (fm.getFunctionAt(addr(v0)) and fm.getFunctionAt(addr(v1))):
|
|
res.append((v1, t, ent))
|
|
return res
|
|
|
|
|
|
def fname(a):
|
|
f = func(a)
|
|
return f.getName() if f else "?"
|
|
|
|
|
|
def callees(a):
|
|
f = func(a)
|
|
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
|
for c in f.getCalledFunctions(mon)}) if f else []
|
|
|
|
|
|
def callers(a):
|
|
f = func(a)
|
|
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
|
for c in f.getCallingFunctions(mon)}) if f else []
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) > 1:
|
|
g = dict(globals())
|
|
g["__name__"] = "__main__"
|
|
exec(open(sys.argv[1]).read(), g)
|
|
else:
|
|
print("loaded:", prog.getName(), fm.getFunctionCount(), "functions")
|
|
sys.stdout.flush()
|
|
# JVM teardown deadlocks under jpype here -- skip it, all output is flushed.
|
|
os._exit(0)
|