fifa17-python: commit working FUT backend deployment (client/server split)
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.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user