#!/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 -- runs 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 Two traps this file already works around: * open_program(..., nested_project_location=False) -- otherwise pyghidra creates a NEW empty project at // 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 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)