fifa17-recon: FUT squad blocker solved + userInfo delivered
The client now issues PUT /squad and the hub renders coins, record and the
squad roster. Three separate root causes, all verified live.
Squad blocker (the long-standing "client never sends PUT /squad"):
AddPlayerToSquad, GetSquads and SelectSquadById issue ZERO network requests
(pure local model reads/mutations, FutSquadServiceImpl vtable 0x180233ff0);
only SaveCurrentSquad writes, and it is unguarded. The client simply needed a
populated ACTIVE squad model, which arrives via the massinfo `squad` member.
No response of ours was ever being rejected.
userMassInfo is NOT required to be {}:
0x180174630 is a FLAT {userInfo, squad, settings, userData} body -- the old
"wrapper key is user" note was wrong, and the historical freeze was the
malformed squad member, not the envelope.
clubNameChangeAllowed must be false:
sending true advertises a club-rename flow whose UI model is never populated;
the client shows a naming prompt and dies confirming it (ACCESS_VIOLATION
reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame and no request
in flight). Isolated by a single-variable run; guarded by a contract check.
Endpoint/schema corrections found in live traffic, invisible to static analysis:
* GET ut/%s/squad/list is a real endpoint and must return {"squad":[...]},
not the active-squad object (the /list suffix is appended by the caller, so
it never appeared in the request table)
* PUT lands on ut/%s/squad/<id>, not a bare ut/%s/squad
* userInfo currencies are read as name/funds/finalFunds/active -- there is no
"value" key, so coins always rendered 0
* squad-list elements take STRING formation/squadType, not ints
* the CardsDLL script-API thunk<->name table was off by one (AddPlayerToSquad
is 0x18004aa70; 0x18004aff0 is GetPotentialChemistry_Club)
FUT_MASSINFO / FUT_USERINFO ladders keep every step of the bisect reproducible.
Contract suite 311 -> 358 checks. Tooling added: PyGhidra harness (Ghidra's
Java/OSGi script path is broken on this box), minidump reader, live code grabber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
#!/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
|
||||
|
||||
DLL = "/tmp/fut/cardsdll.dll"
|
||||
PROJ_DIR, PROJ = "/tmp/ghidra_fut", "cardsdll"
|
||||
|
||||
# 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="cardsdll.dll",
|
||||
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 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