afdbb364ca
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.
THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.
Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
* duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
int list documented at :1095 and :218. Control that this is not a misread:
dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
inner object loop. We serve [], so this is a docs bug today and a live freeze the
moment somebody implements it from the map as written.
* FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
top-level id. :968-971 is wrong twice over.
packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.
The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.
Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.
futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.
utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.
Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
5.9 KiB
Python
154 lines
5.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""ADVERSARIAL VERIFY 1: the packOpeningAnimationEnabled gate chain (D4 claim 1 + 2).
|
|
|
|
HYPOTHESES UNDER ATTACK
|
|
H1 FUN_18013c6d0 case 0x20e writes param_2[0x1d]
|
|
H2 FUN_18011dc50 line ~40 writes +0x1fd45 = param_2[0x1d] == 1
|
|
H3 vtable 0x18021c2a0 slot +0x2e0 -> 0x18011c590 -> movzx eax,[rcx+0x1fd45]
|
|
H4 FUN_18006cc60 never uses slot 0x2e0 (ABSENCE -- attacked with a different method:
|
|
I enumerate EVERY vtable-slot displacement the publisher calls, from the DISASSEMBLY,
|
|
not from the decompile text.)
|
|
H5 exactly one reader of slot +0x2e0 in CardsDLL (ABSENCE)
|
|
|
|
CONTROLS
|
|
* class_deser("FutSquadSave") must be 0x180171a60 and class_deser("FutSquadList")
|
|
0x180172140. If those come back empty the whole harness is suspect.
|
|
* vtable slots +0x2b0 and +0x2c8 must decode to 0x1fd3a and 0x1fd3d, which is what the
|
|
known IS_FRIENDLY_SEASON_ENABLED / IS_DRAFT_MODE_ENABLED publisher demands.
|
|
* the byte scan for `call [reg+0x2e0]` is run alongside the SAME scan for +0x2b0, which
|
|
has a known-present site inside FUN_18006cc60.
|
|
"""
|
|
import traceback, struct, re
|
|
|
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
|
|
|
def dump(name, s):
|
|
p = "%s/v_%s.txt" % (OUT, name)
|
|
with open(p, "w") as f:
|
|
f.write(s)
|
|
print("[wrote %s %d chars]" % (p, len(s)))
|
|
|
|
try:
|
|
print("=" * 78)
|
|
print("CONTROL: class_deser")
|
|
for n, exp in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140),
|
|
("FutCreateMatch", 0x180120380)):
|
|
try:
|
|
r = class_deser(n)
|
|
except Exception as e:
|
|
r = "EXC %s" % e
|
|
print(" class_deser(%-16s) = %s expected %#x" % (n, r, exp))
|
|
|
|
print("=" * 78)
|
|
print("H1: FUN_18013c6d0 settings deserializer -- FULL decompile length + case 0x20e")
|
|
s = dec(0x18013c6d0)
|
|
print("len(src) = %d chars, %d lines <-- FULL, not truncated" % (len(s), s.count("\n") + 1))
|
|
dump("q1_settings_deser", s)
|
|
for i, ln in enumerate(s.split("\n")):
|
|
if "0x20e" in ln or "[0x1d]" in ln or "0x1d]" in ln:
|
|
print(" L%-4d %s" % (i + 1, ln.strip()))
|
|
|
|
print("=" * 78)
|
|
print("H2: FUN_18011dc50 applier -- FULL decompile")
|
|
s2 = dec(0x18011dc50)
|
|
print("len(src) = %d chars, %d lines" % (len(s2), s2.count("\n") + 1))
|
|
dump("q1_applier", s2)
|
|
print(s2)
|
|
|
|
print("=" * 78)
|
|
print("H3: vtable 0x18021c2a0 slots decoded from raw bytes")
|
|
VT = 0x18021c2a0
|
|
for slot in range(0x260, 0x310, 8):
|
|
try:
|
|
p = qword(VT + slot)
|
|
except Exception as e:
|
|
print(" +%#05x qword failed %s" % (slot, e)); continue
|
|
if not p:
|
|
continue
|
|
try:
|
|
b = read_bytes(p, 12)
|
|
except Exception:
|
|
b = b""
|
|
bb = bytes(bytearray([(x & 0xff) for x in b]))
|
|
disp = None
|
|
kind = ""
|
|
if len(bb) >= 7 and bb[0] == 0x0f and bb[1] == 0xb6 and bb[2] == 0x81:
|
|
disp = struct.unpack_from("<I", bb, 3)[0]; kind = "movzx eax,byte[rcx+%#x]" % disp
|
|
elif len(bb) >= 6 and bb[0] == 0x8b and bb[1] == 0x81:
|
|
disp = struct.unpack_from("<I", bb, 2)[0]; kind = "mov eax,[rcx+%#x]" % disp
|
|
print(" slot +%#05x -> %#x %s %s %s" % (slot, p, bb.hex(), kind, fname(p) or ""))
|
|
|
|
print("=" * 78)
|
|
print("H4: FUN_18006cc60 publisher -- FULL decompile, then DISASSEMBLY slot list")
|
|
s3 = dec(0x18006cc60)
|
|
print("len(src) = %d chars, %d lines" % (len(s3), s3.count("\n") + 1))
|
|
dump("q1_publisher", s3)
|
|
print(s3)
|
|
f = func(0x18006cc60)
|
|
print("--- disassembly-derived indirect-call displacements in %s ---" % f.getName())
|
|
it = listing.getInstructions(f.getBody(), True)
|
|
slots = []
|
|
while it.hasNext():
|
|
ins = it.next()
|
|
t = str(ins)
|
|
if t.startswith("CALL") and "[" in t and "+" in t:
|
|
m = re.search(r"\+\s*(0x[0-9a-fA-F]+)\]", t)
|
|
if m:
|
|
slots.append((int(m.group(1), 16), int(ins.getAddress().getOffset())))
|
|
# also LEA/MOV of a string arg is noise; skip
|
|
print(" indirect-call displacements used:", sorted(set(x[0] for x in slots)))
|
|
for d, a in slots:
|
|
print(" %#x at %#x" % (d, a))
|
|
print(" 0x2e0 present? ", 0x2e0 in set(x[0] for x in slots))
|
|
print(" 0x2b0 present? ", 0x2b0 in set(x[0] for x in slots), " <-- CONTROL, must be True")
|
|
|
|
print("=" * 78)
|
|
print("H5: xrefs to the stub 0x18011c590")
|
|
try:
|
|
for r in xrefs_to(0x18011c590):
|
|
print(" ", r)
|
|
except Exception as e:
|
|
print(" xrefs_to raised", e)
|
|
print("callers(0x18011c590):")
|
|
try:
|
|
print(" ", callers(0x18011c590))
|
|
except Exception as e:
|
|
print(" ", e)
|
|
|
|
print("=" * 78)
|
|
print("H5b: whole-.text disassembly scan for CALL [reg+0x2e0] and CALL [reg+0x2b0]")
|
|
blk = None
|
|
for b in mem.getBlocks():
|
|
if b.getName() == ".text":
|
|
blk = b
|
|
print(" .text %s - %s" % (blk.getStart(), blk.getEnd()))
|
|
from ghidra.program.model.address import AddressSet
|
|
aset = AddressSet(blk.getStart(), blk.getEnd())
|
|
it = listing.getInstructions(aset, True)
|
|
found = {0x2e0: [], 0x2b0: [], 0x2c8: []}
|
|
n = 0
|
|
while it.hasNext():
|
|
ins = it.next()
|
|
n += 1
|
|
t = str(ins)
|
|
if t[0] != "C" or not t.startswith("CALL"):
|
|
continue
|
|
if "[" not in t:
|
|
continue
|
|
m = re.search(r"\+\s*(0x[0-9a-fA-F]+)\]", t)
|
|
if not m:
|
|
continue
|
|
d = int(m.group(1), 16)
|
|
if d in found:
|
|
found[d].append((int(ins.getAddress().getOffset()), t))
|
|
print(" instructions walked: %d" % n)
|
|
for d in (0x2e0, 0x2b0, 0x2c8):
|
|
print(" --- displacement %#x : %d call sites ---" % (d, len(found[d])))
|
|
for a, t in found[d]:
|
|
fn = fname(a)
|
|
print(" %#x in %-24s %s" % (a, fn, t))
|
|
|
|
except Exception:
|
|
traceback.print_exc()
|
|
print("QUERY DONE")
|