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
9.0 KiB
Python
246 lines
9.0 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, AND WHAT IT ACTUALLY IS. 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.
|
|
|
|
The "4-byte header" is not a length prefix or a refcount. It is literally the
|
|
ASCII string `RS4:`. The full literal is `RS4:FutXServerResponse`, and searching
|
|
for the bare class name lands four bytes into it. Knowing that, the rule stops
|
|
being a magic constant to remember and becomes obvious, and it also means you
|
|
can search for `RS4:` + the class name directly and skip the arithmetic.
|
|
(Established 2026-08-04 by a verification agent that had been told to distrust
|
|
the rule; it did, and found the reason instead of the offset.)
|
|
|
|
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)
|