Files
OpenFUT/fifa17-recon/tools/ghidra_queries/q_envelope_2.py
T
funman300 1605e6effd fifa17-recon: the envelope rule, and the one key of the auth body that is silently eaten
The open question was whether a response deserializer DESCENDS a wrapper or PROBES for
one. Three roots spend an identical three tokenizer calls before dispatch, yet we serve
some bodies wrapped and some flat, and not all of those could be right.

TOKEN ENUM, decoded from the class table at DAT_18023dd40 and the switch in the
classifier FUN_1801c67a0 (the push/pop arms key off container state 2 = object,
3 = array):

  9  START_OBJECT      case 0x64, pushes state 2
  10 END_OBJECT        case 0x65, pops state 2
  11 FIELD_NAME        confirmed independently: FUN_18013bd40 tests +0xd0 == 0xb then
                       atom-hashes the string at +0xf8
  12 START_ARRAY       case 0x66, pushes state 3
  13 END_ARRAY         case 0x67, pops state 3
  1  error             the caseD_78 sink

So the three tokens are `{`, the first FIELD_NAME, and the token opening that field's
value. The envelope is structurally required and its name is NEVER hashed, which is why
FutCreatePack's ladder has no arm for createPackResponse (0xbe) and does not need one.
Coverage for that absence: the ladder has exactly four arms (0xec, 0x16e, 0x1dd, 0x264)
and 0xbe does not occur anywhere in the full 4702-char decompile, printed in full.

The competing reading rested on a factual error. It claimed the /purchased root spends
the same three tokens. FUN_180124ee0 spends TWO and hands off to FUN_18013bd40, which
spends the third. Same total, split across two functions. /purchased never was a
counterexample.

THE BUG THIS FOUND IS NOT THE ONE THAT WAS PREDICTED. The doc expected starterPack,
squad and userData to be swallowed on POST /user. They are not. FutCreateUser
(0x18014cc60) has ladder arms for exactly the five keys we send, and four of them
dispatch correctly at the outer level. The one that does not is `login`: its name is
eaten as the anonymous envelope and its value as the third token. It has an arm, so the
client wants it, and it has never once been delivered.

The second-order consequence matters more than the first. The key order of that dict is
load-bearing and nothing said so. Put userData first and the client loses the entire
user record, silently, with no error and no log line. That warning now sits in the code
next to the dict, which is the only place someone about to reorder it would look.

No behaviour change here. The utas_server.py edit is a comment. 439 contract checks
still pass. The probable proper fix, wrapping all five keys one level down inside a
single envelope key, is a hypothesis with a mechanism rather than a proven fix, and it
touches the login path, so it is not made here and would go behind a flag defaulting
off.

Writeup is section 2 of docs/plan-2026-08-05-pack-opening.md, added by the previous
commit. Opened by this and still UNKNOWN: GET /hub is answered with a flat two-key
body, which under this rule a three-token root would silently truncate, but there is no
FutGetHubServerResponse class and neither atom has a code xref, so /hub may not go
through a generated root at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:25:01 -07:00

92 lines
3.9 KiB
Python

"""Pin the token enum, so the three-token pattern can be read rather than guessed.
Query 1 established the shapes but not the vocabulary:
FUN_180162880 FutCreatePack 3 tokenizer calls, then a key loop, exits on token 10
FUN_18014cc60 FutCreateUser 3 tokenizer calls, then a key loop
FUN_180124ee0 /purchased root TWO tokenizer calls, then FUN_18013bd40, no loop at all
That two-versus-three difference is the whole answer, but only if we know what a token IS.
Note it already refutes the claim in plan-2026-08-05-pack-opening.md section 2 that the
/purchased root "spends the same three tokens". It spends two.
FUN_1801c7f10 is only a pushback wrapper: it returns param_1[0x35] if a token was pushed
back, else classifies one byte via FUN_1801c67a0. So FUN_1801c67a0 holds the enum.
Known so far, from usage rather than from the enum: 10 ends the key loop, 0xd ends an array
(`while (iVar7 != 0xd)` around the itemList element parser), 8 is set when the input is
exhausted cleanly, 1 on error, 7 on a first-call special case.
WHAT WOULD FALSIFY THE DESCEND READING: if token 2 in the /purchased root is a
START_ARRAY rather than a key or a START_OBJECT, then `itemData` is not being consumed as a
wrapper and the two-call pattern means something else entirely.
"""
import traceback
TARGETS = [
(0x1801C67A0, "token CLASSIFIER -- the enum lives here"),
(0x18013BD40, "/purchased body sub-parser (what the 2-token root hands off to)"),
(0x180141EE0, "key reader used by the CreatePack/CreateUser loops"),
(0x1801C63E0, "parser init (called before begin-object in every root)"),
]
def dump(va, title):
try:
f = func(va)
src = dec(va)
n = f.getBody().getNumAddresses() if f else -1
print("\n" + "=" * 78)
print("%#x %s" % (va, title))
print("body %d bytes / decompile %d chars (PRINTED IN FULL)" % (n, len(src)))
print("=" * 78)
print(src)
except Exception:
print("!! failed on %#x" % va)
traceback.print_exc()
try:
for va, name in TARGETS:
dump(va, name)
# Cross-check the two-versus-three count mechanically over every response root we can
# name, rather than trusting three hand-picked examples. A root is recognised by calling
# the parser-init, begin-object and the tokenizer.
print("\n" + "=" * 78)
print("TOKEN CALLS BEFORE THE FIRST KEY READ, across all begin-object callers")
print("=" * 78)
roots = sorted({e for _, _, _, e in xrefs_to(0x1801C8270) if e})
print("begin-object callers: %d" % len(roots))
for ent in roots:
try:
f = func(ent)
if f is None:
continue
# order the call sites by address and count tokenizer calls that precede the
# first key-reader call, which is what "descend depth" actually means here
toks, keys, begin = [], [], []
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())
a = int(ad.getOffset())
if t == 0x1801C7F10:
toks.append(a)
elif t in (0x180141EE0,):
keys.append(a)
elif t == 0x1801C8270:
begin.append(a)
if not begin:
continue
b = min(begin)
first_key = min(keys) if keys else None
pre = [a for a in toks if a > b and (first_key is None or a < first_key)]
print(" %#x %-22s begin@%#x tokens_before_first_key=%d keyreads=%d"
% (ent, f.getName(), b, len(pre), len(keys)))
except Exception:
print(" %#x <failed>" % ent)
traceback.print_exc()
except Exception:
traceback.print_exc()