fifa17-recon: the consumables panel asks 41 times a session and we answer with players
Round 3, 10 agents. The headline is measured, not inferred: GET club/stats/consumables
is requested 41 times per session by the real client (ProtoHttp), and _club_stat_set()
answers it with the PLAYER stat set. The panel reads 14 consumables* names that we
have never sent, so it is told '205 players' when it asked how many contracts the club
owns, and it has nothing to show.
That also explains why last round's 126-item consumable shelf was never requested. It
serves type=contract|training|healing|development and an UNTYPED /club with no
team=/league=, and all 9 of the client's untyped requests this session carry team=. The
only +126 item(s) line in the whole log came from one of our own probes.
Vocabulary recovered: the 14 consumables* rows plus badgeDBid 0x2e, kitsHome 0x29,
kitsAway 0x2a, leagueLogos 0x2f, trophiesSeasonOnline 0x38.
Other measured surfaces the client asks for and we fob off: GET /settings 11x answered
with an empty config array (a 40-flag feature gate, the biggest untouched lever in the
project), leaderboards/options 5x with {}, user/accountinfo 4x with {}.
club/stats/staff is a DIFFERENT class (FutStaffBonus); the staff counts come from the
Stats2 store, which is why the staff screen worked while we answered {}.
Refuted: ENDPOINT_MAP's claim that objectives have no route. FUN_180151610 builds
<base>/objective/%d/reward and FUN_180147780 builds .../complete.
New modules only. utas_server.py is deliberately untouched: whether to wire the counts
depends on a free observation the human can make on the client that is already running,
and spending a restart before that is what this round exists to avoid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Census query: (a) how the client consumes /settings configs (feature-flag gate),
|
||||
# (b) the closed set of club/stats/%s kinds, (c) which URL suffix templates have
|
||||
# live xrefs and from where.
|
||||
import re
|
||||
|
||||
def find_str(s):
|
||||
hits = find_all(s.encode() + b"\x00", blocks=(".rdata", ".data", ".text"))
|
||||
return hits
|
||||
|
||||
TARGETS = [
|
||||
"/stats/%s", "/stats/%s/%d", "/stats/staff", "/consumables/%s", "/counts",
|
||||
"configs", "storeEnabled", "tradingEnabled", "enableSquadBuildingSetsFeature",
|
||||
"enableObjectives", "enableDraftMode", "friendlySeasonsEnabled",
|
||||
"/squadBuildingSets", "/sets", "/objective/", "/totw", "/loan/players",
|
||||
"/tutorialpopups", "/storymode/progress",
|
||||
]
|
||||
|
||||
for t in TARGETS:
|
||||
hits = find_str(t)
|
||||
print("=== %-34s %d string hit(s)" % (repr(t), len(hits)))
|
||||
for h in hits[:4]:
|
||||
xs = xrefs_to(h)
|
||||
print(" @%#x xrefs=%d" % (h, len(xs)))
|
||||
for (fr, ty, fn, en) in xs[:8]:
|
||||
print(" %#x %-12s %s @%#x" % (fr, ty, fn, en))
|
||||
print()
|
||||
print("#" * 70)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Decompile the URL builders + the settings config consumer.
|
||||
TARGETS = {
|
||||
"FUN_18012f4f0 club /stats/%s builder": 0x18012f4f0,
|
||||
"FUN_1801308c0 /consumables/%s builder": 0x1801308c0,
|
||||
"FutGetSettings deser 0x18013c6d0": 0x18013c6d0,
|
||||
"0x18012b083 /stats/staff caller": 0x18012b083,
|
||||
}
|
||||
for name, a in TARGETS.items():
|
||||
c = dec(a)
|
||||
print("=" * 78)
|
||||
print("### %s (len %d)" % (name, len(c)))
|
||||
print(c[:14000])
|
||||
print()
|
||||
@@ -0,0 +1,23 @@
|
||||
import re
|
||||
c = dec(0x18013c6d0)
|
||||
open("/tmp/gf_unserved_settings.c", "w").write(c)
|
||||
print("settings deser len", len(c))
|
||||
cases = re.findall(r"case (0x[0-9a-f]+):|if \(iVar2 == (0x[0-9a-f]+)\)", c)
|
||||
ids = sorted({int(a or b, 16) for a, b in cases})
|
||||
print("switch atoms (%d):" % len(ids), " ".join(hex(i) for i in ids))
|
||||
|
||||
for a, nm in ((0x18012b083, "/stats/staff caller"),
|
||||
(0x180163583, "/counts caller"),
|
||||
(0x18016fa33, "/squadBuildingSets caller"),
|
||||
(0x18017a980, "/sets caller"),
|
||||
(0x180151610, "/objective/ caller A"),
|
||||
(0x180147780, "/objective/ caller B"),
|
||||
(0x18016ef23, "/totw caller"),
|
||||
(0x18014dda3, "/loan/players caller"),
|
||||
(0x18016d813, "/tutorialpopups caller"),
|
||||
(0x18016f5f3, "/storymode/progress caller")):
|
||||
d = dec(a)
|
||||
open("/tmp/gf_unserved_%x.c" % a, "w").write(d)
|
||||
print("\n" + "=" * 70)
|
||||
print("### %s @%#x (len %d)" % (nm, a, len(d)))
|
||||
print(d[:3500])
|
||||
@@ -0,0 +1,25 @@
|
||||
import re
|
||||
# 1. consumables panel provider: which type ids does mode 6 read?
|
||||
c = dec(0x180043b90)
|
||||
open("/tmp/gf_prov.c", "w").write(c)
|
||||
print("### FUN_180043b90 provider len", len(c))
|
||||
i = c.find("case 6")
|
||||
print("--- case-6 region ---")
|
||||
print(c[i-200:i+3000] if i > 0 else "case 6 NOT FOUND; switch text:\n" + "\n".join(
|
||||
l for l in c.splitlines() if "case" in l or "switch" in l))
|
||||
|
||||
# 2. who calls the /consumables/%s builder, /sets, /squadBuildingSets
|
||||
for nm, a in (("/consumables/%s builder FUN_1801308c0", 0x1801308c0),
|
||||
("/sets builder FUN_18017a980", 0x18017a980)):
|
||||
print("\n### callers of %s" % nm)
|
||||
for (fr, ty, fn, en) in xrefs_to(a):
|
||||
print(" %#x %-12s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
# 3. the base URL used with those builders: decompile one caller each
|
||||
print("\n### FUN_1801308c0 caller decompile")
|
||||
xs = xrefs_to(0x1801308c0)
|
||||
for (fr, ty, fn, en) in xs[:2]:
|
||||
if en:
|
||||
d = dec(en)
|
||||
print("--- %s @%#x len %d ---" % (fn, en, len(d)))
|
||||
print(d[:5000])
|
||||
@@ -0,0 +1,40 @@
|
||||
# Pin the BASE template each suffix builder composes onto, by walking the vtable the
|
||||
# builder sits in and reading the sibling slot that returns the base string.
|
||||
BASES = ["ut/%s/sbs", "ut/%s/draft/mode", "ut/%s/club", "ut/%s/item", "ut/%s",
|
||||
"ut/%s/champion", "ut/%s/leaderboards", "ut/%s/season", "ut/%s/tournament",
|
||||
"ut/%s/auctionhouse", "ut/%s/trade", "ut/%s/tradePile", "ut/%s/marketdata",
|
||||
"ut/%s/purchased", "ut/%s/user", "ut/%s/squad", "ut/%s/squad/mode"]
|
||||
for b in BASES:
|
||||
hits = find_all(b.encode() + b"\x00", blocks=(".rdata", ".data", ".text"))
|
||||
print("=== %-22s %s" % (b, [hex(h) for h in hits]))
|
||||
for h in hits:
|
||||
for (fr, ty, fn, en) in xrefs_to(h):
|
||||
print(" ref %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
# Builders whose base we still need. Print the vtable that holds each.
|
||||
BUILDERS = {"objective/reward FUN_180151610": 0x180151610,
|
||||
"objective/complete FUN_180147780": 0x180147780,
|
||||
"sets FUN_18017a980": 0x18017a980,
|
||||
"consumables FUN_1801308c0": 0x1801308c0,
|
||||
"clubstats FUN_18012f4f0": 0x18012f4f0}
|
||||
for nm, a in BUILDERS.items():
|
||||
print("\n### vtable slots holding %s" % nm)
|
||||
for h in find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data")):
|
||||
print(" vt entry @%#x" % h)
|
||||
for k in range(-6, 7):
|
||||
try:
|
||||
q = qword(h + k * 8)
|
||||
except Exception:
|
||||
continue
|
||||
tag = ""
|
||||
if 0x180000000 <= q < 0x181000000:
|
||||
try:
|
||||
s = rd_str(q, 60)
|
||||
if s and all(32 <= ord(c) < 127 for c in s) and len(s) > 2:
|
||||
tag = " STR %r" % s
|
||||
except Exception:
|
||||
pass
|
||||
f = fm.getFunctionAt(addr(q))
|
||||
if f is not None and not tag:
|
||||
tag = " FN %s" % f.getName()
|
||||
print(" [%+2d] %#018x%s" % (k, q, tag))
|
||||
@@ -0,0 +1,67 @@
|
||||
# VERIFICATION pass for the "unserved screens" census. Re-derives, independently:
|
||||
# 1. FUN_180043b90 (claimed club-stats panel provider) -- FULL decompile, length,
|
||||
# brace balance, every switch arm, and case-6's row list.
|
||||
# 2. FUN_180094ce0 (the LIVE-PROVEN eight-row staff panel) -- to check that both
|
||||
# panels obtain their data object the same way, which is the only thing that
|
||||
# licenses "the JSON we send reaches those tiles".
|
||||
# 3. FUN_18012fd40 -- atom -> type-id map, specifically the consumable arms.
|
||||
# 4. FUN_18013c6d0 -- settings deser: length, brace balance, distinct atoms,
|
||||
# which getter reads `value`, and whether 0x100 appears.
|
||||
# 5. FUN_18012f4f0 -- the club/stats URL builder's closed mode set.
|
||||
import re
|
||||
|
||||
def report(name, a):
|
||||
c = dec(a)
|
||||
bal = c.count("{") - c.count("}")
|
||||
print("=== %s @%#x len=%d braces_balanced=%s ends=%r" %
|
||||
(name, a, len(c), bal == 0, c.rstrip()[-40:]))
|
||||
return c
|
||||
|
||||
print("#" * 72)
|
||||
print("# 1. FUN_180043b90")
|
||||
c = report("FUN_180043b90", 0x180043b90)
|
||||
open("/tmp/ver_43b90.c", "w").write(c)
|
||||
cases = re.findall(r"^\s*(case \w+|default):", c, re.M)
|
||||
print("switch arms seen:", cases)
|
||||
# every string literal passed as the row name, in order, with the typeid before it
|
||||
rows = re.findall(r'0x800\)\)\(\w+,([0-9a-fx]+)\);|"(CARDS_NO_[A-Z_]+)",(\w+)\)', c)
|
||||
print("--- case 6 region ---")
|
||||
i = c.find("case 6:")
|
||||
j = c.find("case 7:", i)
|
||||
if j == -1:
|
||||
j = len(c)
|
||||
seg = c[i:j] if i != -1 else "(no case 6)"
|
||||
print("case6 segment len", len(seg))
|
||||
for m in re.finditer(r'0x800\)\)\((\w+),([0-9a-fx]+)\)|"(CARDS_NO_[A-Z_]+)"', seg):
|
||||
print(" ", m.group(0))
|
||||
# where does the data object come from?
|
||||
print("--- data-object acquisition (first 30 lines) ---")
|
||||
print("\n".join(c.splitlines()[:32]))
|
||||
|
||||
print("#" * 72)
|
||||
print("# 2. FUN_180094ce0 (live-proven staff panel, for comparison)")
|
||||
c2 = report("FUN_180094ce0", 0x180094ce0)
|
||||
open("/tmp/ver_94ce0.c", "w").write(c2)
|
||||
print("\n".join(c2.splitlines()[:34]))
|
||||
|
||||
print("#" * 72)
|
||||
print("# 3. FUN_18012fd40 atom -> typeid")
|
||||
c3 = report("FUN_18012fd40", 0x18012fd40)
|
||||
open("/tmp/ver_12fd40.c", "w").write(c3)
|
||||
print(c3)
|
||||
|
||||
print("#" * 72)
|
||||
print("# 4. FUN_18013c6d0 settings deser")
|
||||
c4 = report("FUN_18013c6d0", 0x18013c6d0)
|
||||
open("/tmp/ver_13c6d0.c", "w").write(c4)
|
||||
atoms = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", c4)))
|
||||
print("distinct case atoms: %d" % len(atoms), [hex(a) for a in atoms])
|
||||
print("0x100 present:", 0x100 in atoms)
|
||||
print("getter callsites:", sorted(set(re.findall(r"FUN_1801c7[0-9a-f]{3}", c4))))
|
||||
for pat in ("0xa2", "0x354", "0x377"):
|
||||
print(" %s occurrences: %d" % (pat, c4.count(pat)))
|
||||
|
||||
print("#" * 72)
|
||||
print("# 5. FUN_18012f4f0 club/stats URL builder")
|
||||
c5 = report("FUN_18012f4f0", 0x18012f4f0)
|
||||
print(c5)
|
||||
@@ -0,0 +1,41 @@
|
||||
# VERIFICATION pass 2: the objective URL builders (RANK 9), the /consumables/%s
|
||||
# builder (RANK 10), and the xref counts behind the sbs/draft suffix claims.
|
||||
import re
|
||||
|
||||
def show(name, a):
|
||||
c = dec(a)
|
||||
bal = c.count("{") - c.count("}")
|
||||
print("=== %s @%#x len=%d balanced=%s" % (name, a, len(c), bal == 0))
|
||||
print(c)
|
||||
return c
|
||||
|
||||
for nm, a in (("FUN_180151610", 0x180151610), ("FUN_180147780", 0x180147780),
|
||||
("FUN_1801308c0", 0x1801308c0)):
|
||||
show(nm, a)
|
||||
|
||||
print("#" * 72)
|
||||
print("# xrefs to each suffix string")
|
||||
for s in ["/sets", "/sets/tag", "/setId/%d/challenges", "/squadBuildingSets",
|
||||
"/challenge/%d", "/challenge/%d/squad", "/consumables/%s", "/objective/",
|
||||
"/loan/players", "/stats/staff", "/choices/player", "/%d/draft/choose",
|
||||
"ut/%s/sbs", "ut/%s/draft/mode"]:
|
||||
hits = find_all(s.encode() + b"\x00", blocks=(".rdata", ".data", ".text"))
|
||||
for h in hits:
|
||||
xs = xrefs_to(h)
|
||||
txt = ", ".join("%s@%#x" % (fn, en) for (_, _, fn, en) in xs)
|
||||
print(" %-24s @%#x xrefs=%d %s" % (s, h, len(xs), txt))
|
||||
|
||||
print("#" * 72)
|
||||
print("# .rdata neighbourhood of the two objective vtables (identity check)")
|
||||
for vt in (0x1801fc080, 0x180206090):
|
||||
print("-- vtable %#x" % vt)
|
||||
for i in range(8):
|
||||
try:
|
||||
q = qword(vt + i * 8)
|
||||
except Exception:
|
||||
break
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
print(" +%02x %#018x %s" % (i * 8, q, f.getName() if f else ""))
|
||||
# ascii that follows
|
||||
b = read_bytes(vt + 64, 160)
|
||||
print(" trailing bytes:", re.findall(rb"[ -~]{4,}", b))
|
||||
@@ -0,0 +1,31 @@
|
||||
# VERIFICATION pass 3: resolve the address discrepancies in the suffix-string
|
||||
# census and get the FROM address / ref type of each single-xref claim.
|
||||
addrs = [0x1802262c0, 0x18022dd08, 0x180226700, 0x1802264e0, 0x18022e6d4,
|
||||
0x180226ec0, 0x18022e908, 0x180227300, 0x1802270e0, 0x18022bd88,
|
||||
0x180221728, 0x180225840, 0x18021e508, 0x18021e820, 0x180222320,
|
||||
0x1802252c8, 0x1802254c0, 0x180225638]
|
||||
print("== what string actually lives at each address ==")
|
||||
for a in addrs:
|
||||
try:
|
||||
print(" %#x %r" % (a, rd_str(a, 60)))
|
||||
except Exception as e:
|
||||
print(" %#x ERR %s" % (a, e))
|
||||
|
||||
print()
|
||||
print("== from-address + reftype for the 'single xref' strings ==")
|
||||
for s in ["/squadBuildingSets", "/loan/players", "/stats/staff", "ut/%s/sbs",
|
||||
"ut/%s/draft/mode", "/sets", "/setId/%d/challenges", "/challenge/%d",
|
||||
"/challenge/%d/squad", "/sets/tag", "/consumables/%s"]:
|
||||
for h in find_all(s.encode() + b"\x00", blocks=(".rdata", ".data", ".text")):
|
||||
for (fr, ty, fn, en) in xrefs_to(h):
|
||||
blk = mem.getBlock(addr(fr))
|
||||
print(" %-22s str@%#x from %#x [%s] %s fn=%s" %
|
||||
(s, h, fr, blk.getName() if blk else "?", ty, fn))
|
||||
|
||||
print()
|
||||
print("== vtable block around 0x1801f5968 (RANK 10 adjacency claim) ==")
|
||||
for off in range(-5, 4):
|
||||
a = 0x1801f5968 + off * 8
|
||||
q = qword(a)
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
print(" %#x %#018x %s" % (a, q, f.getName() if f else ""))
|
||||
Reference in New Issue
Block a user