b0bbc2a07f
The oracle is three-valued, and all three fingerprints are now confirmed live
against a running client rather than read out of Ghidra:
NAMED our sentinel rating 7 survives and a real name appears. The id is
real, and teamid/nation/leagueId come back FILLED by the game
because we send them as zero.
placeholder rating 7 survives but the name is 'Jamal Blackman', team 0. The
players-table row exists and is an empty slot. This is the trap:
169193 does this and it was in VERIFIED_ASSET_IDS.
MISS rating 0x32, teamid 0x78d, nation 0xe, position 2, name ' '. That
is the binary's miss-fill, byte for byte, and it is exactly the
blank card photographed in a pack today.
Scale: 5000 candidates per response ingests cleanly; 20000 was served and then
silently not ingested (the map did not change at all), so the ceiling is between
them and auto chunks default to 5000.
Auto-advance: the client PAGES the club, so one visit yields several fetches.
'auto:lo-hi:step' hands out the next chunk per fetch. Item ids derive from the
candidate's offset in the WHOLE range, not its index in the chunk, so chunks never
collide and results accumulate across fetches for a single probe at the end.
sweep_collect.py accumulates into data/players.json and rejects the placeholder
name as a matter of course. Yield in 20000-24999 was 19 real ids per 5000, which
is why auto-advance matters: the real roster clusters in 150000-240000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
542 lines
21 KiB
Python
542 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Read FIFA 17's live FUT club-stat store -- STRICTLY READ-ONLY.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
Static analysis produced two contradictory positions about the "MY CLUB / 0 TOTAL
|
|
PLAYERS" bug:
|
|
|
|
(A) our /club/stats response body IS parsed and IS stored, and the panel simply
|
|
reads a bucket (mode) we never populate -> the bug is SELECTION;
|
|
(B) our body never lands in the store at all -> the bug is DELIVERY/SCHEMA.
|
|
|
|
Those two differ by one observable fact: what the store actually holds while the
|
|
game is sitting on the FUT hub. This tool reads it out of the running process.
|
|
|
|
THE CHAIN, RESOLVED STATICALLY THIS SESSION (CardsDLL, image base 0x180000000)
|
|
-----------------------------------------------------------------------------
|
|
FUN_18011a830: return DAT_1802e6398; <- CardsDb singleton
|
|
CardsDb vtable = 0x18021c2a0
|
|
vt+0x7f0 -> 0x18011bbb0: `lea rax,[rcx+0x1f8b0]; ret` <- THE STORE IS A
|
|
SUBOBJECT, not a
|
|
separate alloc.
|
|
So store == CardsDb + 0x1F8B0. No virtual call is needed to reach it,
|
|
which is what makes this probe possible from outside the process.
|
|
vt+0x7f8 -> 0x18011bb10: stat_get(this, contextValue, typeId)
|
|
vt+0x800 -> 0x18011bba0: `jmp vt+0x7f8(this, 0, typeId)` <- the panel's getter;
|
|
contextValue is
|
|
HARD-WIRED to 0.
|
|
|
|
0x18011bb10 (getter_7f8, 47 lines, read in full) walks:
|
|
outer map head = this+0x1f8e8 (== store+0x38)
|
|
outer map root = this+0x1f8f8 (== store+0x48)
|
|
outer node: child0 +0x00, child1 +0x08, parent +0x10,
|
|
key = uint32 @ +0x20 (== contextValue)
|
|
inner map head = node+0x30, root = node+0x40, size = int32 @ node+0x50
|
|
inner node: child0 +0x00, child1 +0x08, parent +0x10,
|
|
key = int32 @ +0x20 (== type id)
|
|
value = int32 @ +0x24 (== typeValue)
|
|
returns 0 when either key is absent -- so a MISSING entry and a STORED ZERO
|
|
are indistinguishable to the panel, but NOT to this tool.
|
|
|
|
0x180130150 (FutStickerBookStats2 deserializer, 6.7 KB, read in full) writes into
|
|
exactly those two maps (`lVar12+0x30` outer base, node+0x28 inner base, inner
|
|
node alloc size 0x28 with key@+0x20 / value@+0x24), and on END_OBJECT does
|
|
`*(byte *)(store + 0x28) = 1` -- a PARSE-COMPLETED flag this tool reports.
|
|
|
|
FUN_18012fbe0 (request completion) writes store+0x78/+0x7c/+0x80 from
|
|
request+0xc4/+0xc8/+0xcc -- the mode tag the panel provider FUN_180043b90
|
|
switches on.
|
|
|
|
WHICH BUCKET EACH PANEL MODE READS (FUN_180043b90, 11675 chars, read in full)
|
|
----------------------------------------------------------------------------
|
|
This corrects an earlier, wrong case map. Verified line by line:
|
|
|
|
mode 1 club vt+0x800 -> bucket 0 only. PLAYERS_EMPLOYED, BALLS_EARNED,
|
|
KITS_AVAILABLE, STADIA_OWNED, STAFF_EMPLOYED,
|
|
TROPHIES_WON.
|
|
mode 2 year rows with IS_TEAM_CATEGORY=false -> bucket 0 (balls, stadia,
|
|
managers, headcoaches, physio, gkcoaches, fitness,
|
|
4 trophy variants);
|
|
rows with IS_TEAM_CATEGORY=true -> bucket = row's NATION_ID
|
|
(bronze/silver/gold, PLAYERS = their SUM, rare,
|
|
kits, badges).
|
|
mode 3 country/id bucket = each row's LEAGUE_ID. NEVER bucket 0.
|
|
mode 4 league/id bucket = each row's TEAM_ID. NEVER bucket 0.
|
|
mode 5 newcards bucket 0 only.
|
|
mode 6 consumables bucket 0 only.
|
|
|
|
So in modes 2 (team rows), 3 and 4, everything we put in bucket 0 is invisible:
|
|
the panel asks for buckets keyed by league / team / nation ids. The URL id
|
|
(store+0x7c) is NOT the bucket key either -- the key comes from the row list the
|
|
UI passes in as param_2.
|
|
|
|
READ-ONLY GUARANTEE
|
|
-------------------
|
|
/proc/PID/mem is opened "rb" and the only operations performed on it are seek()
|
|
and read(). assert_read_only() re-checks the handle's mode at startup and aborts
|
|
if anything ever made it writable. There is no ptrace attach, no write path, and
|
|
no code that constructs one.
|
|
|
|
HOW TO READ THE OUTPUT
|
|
----------------------
|
|
bucket 0 holds type 1 (players) = 114
|
|
-> our body LANDED. The store has the number. The panel showing 0 is then a
|
|
SELECTION problem (wrong mode tag / wrong bucket / stale panel), not a
|
|
delivery or schema problem.
|
|
bucket 0 exists but type 1 is absent
|
|
-> the type string in our JSON is not mapping to id 1 (FUN_18012fd40 maps the
|
|
atom for "players", 0x238, to 1). Schema bug in the `type` field.
|
|
a bucket exists but it is not 0
|
|
-> contextId/contextValue guard put us in the wrong bucket; the panel getter
|
|
only ever asks bucket 0.
|
|
outer map is EMPTY while parsed=1
|
|
-> the body parsed but every entry was dropped or the map was wiped after
|
|
parse (the factory wipe). Delivery problem.
|
|
outer map is EMPTY and parsed=0
|
|
-> our response never reached this deserializer at all.
|
|
mode tag != 1
|
|
-> corroborates the "case 1 is never selected" verdict, but ONLY if the store
|
|
does hold the values; on its own it proves nothing.
|
|
|
|
USAGE
|
|
-----
|
|
python3 tools/probe_club_stats.py # one snapshot, then exit
|
|
python3 tools/probe_club_stats.py --watch # poll until Ctrl-C, print on change
|
|
python3 tools/probe_club_stats.py --raw # + hexdump of store[0x00:0x90]
|
|
python3 tools/probe_club_stats.py --get 1 # emulate vt+0x800(typeId) exactly
|
|
|
|
FIFA does not have to be in MY CLUB when you start it; --watch is the intended way
|
|
to see the store fill as you navigate.
|
|
|
|
Needs read access to /proc/PID/mem (kernel.yama.ptrace_scope=0, or run as root).
|
|
"""
|
|
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
# ---------------------------------------------------------------- constants --
|
|
IMG_BASE = 0x180000000
|
|
DLL = "CardsDLL"
|
|
PROC_NAME = "FIFA17.exe"
|
|
|
|
G_CARDSDB = 0x1802E6398 # FUN_18011a830 returns this (VERIFIED this session)
|
|
|
|
STORE_OFF = 0x1F8B0 # vt+0x7f0: lea rax,[rcx+0x1f8b0] (VERIFIED)
|
|
|
|
# offsets RELATIVE TO THE STORE
|
|
ST_PARSED = 0x28 # set to 1 by the deserializer on END_OBJECT
|
|
ST_MAP_BASE = 0x30 # outer std::map base
|
|
ST_MAP_HEAD = 0x38 # == CardsDb+0x1f8e8, the getter's sentinel
|
|
ST_MAP_ANCHOR = 0x40
|
|
ST_MAP_ROOT = 0x48 # == CardsDb+0x1f8f8
|
|
ST_MAP_SIZE = 0x58 # base+0x28, same layout as the CardsDb item tree
|
|
ST_MODE = 0x78 # request+0xc4 (FUN_18012fbe0)
|
|
ST_MODE_ARG1 = 0x7C # request+0xc8
|
|
ST_MODE_ARG2 = 0x80 # request+0xcc
|
|
|
|
# node layout, shared by both levels
|
|
N_C0, N_C1, N_PARENT, N_KEY = 0x00, 0x08, 0x10, 0x20
|
|
IN_VALUE = 0x24 # inner node only
|
|
|
|
# inner map, relative to an OUTER node
|
|
ON_INNER_BASE = 0x28
|
|
ON_INNER_HEAD = 0x30
|
|
ON_INNER_ROOT = 0x40
|
|
ON_INNER_SIZE = 0x50
|
|
|
|
MAX_NODES = 20000 # a corrupt tree terminates instead of hanging us
|
|
PTR_LO, PTR_HI = 0x10000, 0x00007FFFFFFFFFFF # plausible user-space range
|
|
|
|
# URL builder FUN_18012f4f0
|
|
MODE_NAMES = {1: "club", 2: "year", 3: "country+id", 4: "league+id",
|
|
5: "newcards", 6: "consumables"}
|
|
|
|
# FUN_18012fd40: atom -> type id, cross-referenced against docs/fut_atoms.tsv
|
|
TYPE_NAMES = {
|
|
0x01: 'players', 0x02: 'playersBronze', 0x03: 'playersSilver',
|
|
0x04: 'playersGold', 0x05: 'rarePlayers', 0x0a: 'staff',
|
|
0x0b: 'staffManager', 0x0c: 'staffHeadCoach', 0x0d: 'staffGKCoach',
|
|
0x0e: 'staffPhysio', 0x0f: 'staffFitnessCoach', 0x14: 'stadia',
|
|
0x1e: 'balls', 0x28: 'kits', 0x29: 'kitsHome', 0x2a: 'kitsAway',
|
|
0x2d: 'badges', 0x2e: 'badgeDBid', 0x2f: 'leagueLogos', 0x32: 'trophies',
|
|
0x33: 'trophiesOffline', 0x34: 'trophiesOnline',
|
|
0x35: 'trophiesFeaturedOffline', 0x36: 'trophiesFeaturedOnline',
|
|
0x37: 'trophiesSeasonOffline', 0x38: 'trophiesSeasonOnline',
|
|
0x3c: 'consumables', 0x41: 'consumablesHealing',
|
|
0x42: 'consumablesContractPlayer', 0x43: 'consumablesTrainingPlayer',
|
|
0x44: 'consumablesFitnessPlayer', 0x45: 'consumablesPosition',
|
|
0x46: 'consumablesTrainingGk', 0x47: 'consumablesContractManager',
|
|
0x48: 'consumablesFormationManager', 0x49: 'consumablesTrainingManager',
|
|
0x4a: 'consumablesFitnessTeam',
|
|
0x4b: 'consumablesTrainingPlayerPlayStyle',
|
|
0x4c: 'consumablesTrainingGkPlayStyle',
|
|
0x4d: 'consumablesTrainingManagerLeagueModifier',
|
|
}
|
|
|
|
PLAYERS_TYPE_ID = 1
|
|
|
|
|
|
# ------------------------------------------------------------------ process --
|
|
def find_pid():
|
|
for d in glob.glob("/proc/[0-9]*"):
|
|
try:
|
|
with open(d + "/comm") as f:
|
|
if f.read().strip() == PROC_NAME:
|
|
return int(d.rsplit("/", 1)[-1])
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def module_base(pid, name=DLL):
|
|
"""Live load base of CardsDLL. It is NOT 0x180000000 in the Wine process."""
|
|
try:
|
|
with open("/proc/%d/maps" % pid) as f:
|
|
for line in f:
|
|
if name in line:
|
|
return int(line.split("-", 1)[0], 16)
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
class Mem(object):
|
|
"""Read-only /proc/PID/mem accessor. Failures return None, never raise."""
|
|
|
|
def __init__(self, pid):
|
|
self.pid = pid
|
|
self.fails = 0
|
|
self.f = open("/proc/%d/mem" % pid, "rb") # "rb": read-only, by design
|
|
self.assert_read_only()
|
|
|
|
def assert_read_only(self):
|
|
"""Abort rather than continue if this handle could ever write."""
|
|
mode = getattr(self.f, "mode", "")
|
|
if not self.f.readable() or self.f.writable() or "+" in mode or "w" in mode:
|
|
raise SystemExit("REFUSING TO RUN: /proc/%d/mem handle is not read-only "
|
|
"(mode=%r). This tool must never write to the game."
|
|
% (self.pid, mode))
|
|
|
|
def read(self, va, n):
|
|
if va is None or va < PTR_LO or va > PTR_HI:
|
|
self.fails += 1
|
|
return None
|
|
try:
|
|
self.f.seek(va)
|
|
b = self.f.read(n)
|
|
except Exception:
|
|
self.fails += 1
|
|
return None
|
|
if b is None or len(b) != n:
|
|
self.fails += 1
|
|
return None
|
|
return b
|
|
|
|
def q(self, va):
|
|
b = self.read(va, 8)
|
|
return struct.unpack("<Q", b)[0] if b else None
|
|
|
|
def u32(self, va):
|
|
b = self.read(va, 4)
|
|
return struct.unpack("<I", b)[0] if b else None
|
|
|
|
def i32(self, va):
|
|
b = self.read(va, 4)
|
|
return struct.unpack("<i", b)[0] if b else None
|
|
|
|
def u8(self, va):
|
|
b = self.read(va, 1)
|
|
return b[0] if b else None
|
|
|
|
def alive(self):
|
|
return os.path.exists("/proc/%d" % self.pid)
|
|
|
|
|
|
def plausible(p):
|
|
return p is not None and PTR_LO <= p <= PTR_HI and (p & 7) == 0
|
|
|
|
|
|
# ------------------------------------------------------------- tree walking --
|
|
def walk(mem, head, root, key_signed, want_value):
|
|
"""DFS a red-black tree. Returns (list_of_(key, value_or_None, node_va), note).
|
|
|
|
Both children are followed with a visited set, so the left/right convention
|
|
does not matter and a cycle or a garbage pointer stops the walk instead of
|
|
hanging it. `head` is the sentinel; it is never treated as a real node.
|
|
"""
|
|
if root is None:
|
|
return [], "root unreadable"
|
|
if root == 0 or root == head:
|
|
return [], "empty"
|
|
out, seen, stack, note = [], set(), [root], "ok"
|
|
while stack:
|
|
p = stack.pop()
|
|
if p == 0 or p == head or p in seen:
|
|
continue
|
|
if not plausible(p):
|
|
note = "hit an implausible pointer -- walk partial"
|
|
continue
|
|
if len(out) >= MAX_NODES:
|
|
note = "TRUNCATED at %d nodes" % MAX_NODES
|
|
break
|
|
seen.add(p)
|
|
k = mem.i32(p + N_KEY) if key_signed else mem.u32(p + N_KEY)
|
|
if k is None:
|
|
note = "node key unreadable -- walk partial"
|
|
continue
|
|
v = mem.i32(p + IN_VALUE) if want_value else None
|
|
out.append((k, v, p))
|
|
for slot in (N_C0, N_C1):
|
|
c = mem.q(p + slot)
|
|
if c is None:
|
|
note = "child pointer unreadable -- walk partial"
|
|
continue
|
|
if c and c != head and c not in seen:
|
|
stack.append(c)
|
|
return out, note
|
|
|
|
|
|
def read_store(mem, cdb):
|
|
"""Snapshot the whole two-level stat store. Never raises."""
|
|
st = cdb + STORE_OFF
|
|
s = {
|
|
"cardsdb": cdb,
|
|
"store": st,
|
|
"parsed": mem.u8(st + ST_PARSED),
|
|
"mode": mem.i32(st + ST_MODE),
|
|
"mode_arg1": mem.i32(st + ST_MODE_ARG1),
|
|
"mode_arg2": mem.i32(st + ST_MODE_ARG2),
|
|
"outer_size": mem.i32(st + ST_MAP_SIZE),
|
|
"buckets": None,
|
|
"note": "",
|
|
}
|
|
head = st + ST_MAP_HEAD
|
|
root = mem.q(st + ST_MAP_ROOT)
|
|
s["outer_root"] = root
|
|
outer, note = walk(mem, head, root, key_signed=False, want_value=False)
|
|
s["note"] = note
|
|
if note in ("root unreadable",):
|
|
return s
|
|
buckets = []
|
|
for ctx, _v, node in sorted(outer):
|
|
ihead = node + ON_INNER_HEAD
|
|
iroot = mem.q(node + ON_INNER_ROOT)
|
|
isize = mem.i32(node + ON_INNER_SIZE)
|
|
inner, inote = walk(mem, ihead, iroot, key_signed=True, want_value=True)
|
|
buckets.append({
|
|
"contextValue": ctx,
|
|
"node": node,
|
|
"size_field": isize,
|
|
"entries": sorted((k, v) for k, v, _ in inner),
|
|
"note": inote,
|
|
})
|
|
s["buckets"] = buckets
|
|
return s
|
|
|
|
|
|
def stat_get(store_snapshot, ctx, type_id):
|
|
"""Exactly what vt+0x7f8 returns: the value, or 0 when either key is absent.
|
|
|
|
Returns (value, found) so a stored 0 can be told apart from an absent key --
|
|
the game itself cannot make that distinction.
|
|
"""
|
|
for b in store_snapshot.get("buckets") or []:
|
|
if b["contextValue"] == ctx:
|
|
for k, v in b["entries"]:
|
|
if k == type_id:
|
|
return v, True
|
|
return 0, False
|
|
return 0, False
|
|
|
|
|
|
# ---------------------------------------------------------------- reporting --
|
|
def tname(t):
|
|
return TYPE_NAMES.get(t, "type_%#x" % t)
|
|
|
|
|
|
def fmt_ptr(v):
|
|
return "UNREADABLE" if v is None else "%#x" % v
|
|
|
|
|
|
def report(s, raw=None):
|
|
lines = []
|
|
a = lines.append
|
|
a("CardsDb %s" % fmt_ptr(s["cardsdb"]))
|
|
a("store (cdb+0x1f8b0) %s" % fmt_ptr(s["store"]))
|
|
p = s["parsed"]
|
|
a("store+0x28 parsed %s%s" % ("UNREADABLE" if p is None else p,
|
|
" <== a FutStickerBookStats2 body completed parsing"
|
|
if p == 1 else ""))
|
|
m = s["mode"]
|
|
a("store+0x78 mode %s (%s) +0x7c=%s +0x80=%s"
|
|
% ("UNREADABLE" if m is None else m,
|
|
MODE_NAMES.get(m, "unknown/never-set"),
|
|
s["mode_arg1"], s["mode_arg2"]))
|
|
a("outer map root=%s size_field=%s walk=%s"
|
|
% (fmt_ptr(s.get("outer_root")), s["outer_size"], s["note"]))
|
|
|
|
buckets = s["buckets"]
|
|
if buckets is None:
|
|
a(" (outer map unreadable)")
|
|
elif not buckets:
|
|
a(" NO BUCKETS -- the stat map is empty.")
|
|
else:
|
|
for b in buckets:
|
|
a(" bucket contextValue=%d node=%#x size_field=%s entries=%d (%s)"
|
|
% (b["contextValue"], b["node"], b["size_field"],
|
|
len(b["entries"]), b["note"]))
|
|
if b["size_field"] is not None and b["size_field"] != len(b["entries"]):
|
|
a(" WARNING: size field disagrees with the walk -- walk suspect")
|
|
for k, v in b["entries"]:
|
|
a(" %-3d %-42s = %s" % (k, tname(k), v))
|
|
|
|
a("")
|
|
v, found = stat_get(s, 0, PLAYERS_TYPE_ID)
|
|
a("vt+0x800(typeId=1 'players') -> %d [%s]"
|
|
% (v, "PRESENT in bucket 0" if found
|
|
else "ABSENT -- the getter returns 0 by fallthrough"))
|
|
if found and v:
|
|
a("VERDICT INPUT: the store HOLDS players=%d. Delivery and schema are FINE;"
|
|
% v)
|
|
a(" a panel reading 0 is then a SELECTION failure.")
|
|
elif s["parsed"] == 1 and not found:
|
|
a("VERDICT INPUT: a body parsed (parsed=1) but bucket 0 / type 1 is absent.")
|
|
a(" Either our contextValue is not 0 or our type string is not")
|
|
a(" mapping to id 1. That is a SCHEMA failure, not selection.")
|
|
elif not buckets:
|
|
a("VERDICT INPUT: the store is empty. Our /club/stats body is NOT landing.")
|
|
|
|
if m in (2, 3, 4) and buckets is not None:
|
|
keyed = [b["contextValue"] for b in buckets if b["contextValue"] != 0]
|
|
a("NOTE: mode %d reads PER-ROW buckets (%s), never bucket 0."
|
|
% (m, {2: "NATION_ID for team rows", 3: "LEAGUE_ID", 4: "TEAM_ID"}[m]))
|
|
a(" non-zero buckets present: %s"
|
|
% (keyed if keyed else "NONE -- every per-row lookup falls through to 0"))
|
|
if raw is not None:
|
|
a("")
|
|
a("raw store[0x00:0x90]:")
|
|
for off in range(0, 0x90, 16):
|
|
chunk = raw[off:off + 16]
|
|
a(" +%#04x %s" % (off, " ".join("%02x" % c for c in chunk)))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def signature(s):
|
|
"""Change key for --watch: everything a human would notice."""
|
|
return (s["parsed"], s["mode"], s["mode_arg1"], s["mode_arg2"],
|
|
tuple((b["contextValue"], tuple(b["entries"]))
|
|
for b in (s["buckets"] or [])))
|
|
|
|
|
|
# --------------------------------------------------------------------- main --
|
|
def main():
|
|
ap = argparse.ArgumentParser(
|
|
description="Read FIFA 17's live FUT club-stat store (READ-ONLY)")
|
|
ap.add_argument("--watch", action="store_true",
|
|
help="poll and print whenever the store changes (Ctrl-C to stop)")
|
|
ap.add_argument("--interval", type=float, default=0.5, help="poll seconds")
|
|
ap.add_argument("--raw", action="store_true",
|
|
help="also hexdump store[0x00:0x90]")
|
|
ap.add_argument("--get", type=lambda x: int(x, 0), default=None, metavar="TYPEID",
|
|
help="emulate vt+0x800(TYPEID) and print just that value")
|
|
ap.add_argument("--ctx", type=lambda x: int(x, 0), default=0,
|
|
help="contextValue bucket for --get (default 0, what the panel uses)")
|
|
args = ap.parse_args()
|
|
|
|
pid = find_pid()
|
|
if pid is None:
|
|
print("%s is not running. Start FIFA, reach the FUT hub, then run this." % PROC_NAME)
|
|
return 1
|
|
base = module_base(pid)
|
|
if base is None:
|
|
print("%s (pid %d) is running but %s is not mapped yet." % (PROC_NAME, pid, DLL))
|
|
print("Wait for the FUT layer to load (main menu / Ultimate Team) and re-run.")
|
|
return 1
|
|
try:
|
|
mem = Mem(pid)
|
|
except SystemExit:
|
|
raise
|
|
except Exception as e:
|
|
print("cannot open /proc/%d/mem: %s" % (pid, e))
|
|
print("Need: sudo sysctl -w kernel.yama.ptrace_scope=0")
|
|
return 1
|
|
|
|
gva = base + (G_CARDSDB - IMG_BASE)
|
|
print("pid=%d %s base=%#x (image base %#x)" % (pid, DLL, base, IMG_BASE))
|
|
print("CardsDb global @ %#x (static %#x)" % (gva, G_CARDSDB))
|
|
|
|
def snap():
|
|
cdb = mem.q(gva)
|
|
if cdb is None:
|
|
return None, "CardsDb global unreadable"
|
|
if cdb == 0:
|
|
return None, "CardsDb singleton is NULL -- the FUT layer is not constructed"
|
|
if not plausible(cdb):
|
|
return None, "CardsDb global holds an implausible pointer %#x" % cdb
|
|
st = cdb + STORE_OFF
|
|
if mem.read(st, 0x90) is None:
|
|
return None, "store window at %#x is not mapped" % st
|
|
return read_store(mem, cdb), None
|
|
|
|
if args.get is not None:
|
|
s, err = snap()
|
|
if err:
|
|
print(err)
|
|
return 1
|
|
v, found = stat_get(s, args.ctx, args.get)
|
|
print("stat_get(ctx=%d, type=%d %s) = %d [%s]"
|
|
% (args.ctx, args.get, tname(args.get), v,
|
|
"present" if found else "ABSENT (getter fallthrough 0)"))
|
|
return 0
|
|
|
|
def once():
|
|
s, err = snap()
|
|
if err:
|
|
print("[%s] %s" % (time.strftime("%H:%M:%S"), err))
|
|
return None
|
|
raw = mem.read(s["store"], 0x90) if args.raw else None
|
|
print(report(s, raw))
|
|
return s
|
|
|
|
if not args.watch:
|
|
print()
|
|
s = once()
|
|
print("\nfailed reads: %d" % mem.fails)
|
|
return 0 if s else 1
|
|
|
|
print("watching -- navigate FIFA into MY CLUB now. Ctrl-C to stop.\n")
|
|
last = None
|
|
try:
|
|
while True:
|
|
if not mem.alive():
|
|
print("[%s] %s exited." % (time.strftime("%H:%M:%S"), PROC_NAME))
|
|
break
|
|
s, err = snap()
|
|
if err:
|
|
if last != err:
|
|
print("[%s] %s" % (time.strftime("%H:%M:%S"), err))
|
|
last = err
|
|
else:
|
|
sig = signature(s)
|
|
if sig != last:
|
|
print("=" * 68)
|
|
print("[%s] STORE CHANGED" % time.strftime("%H:%M:%S"))
|
|
print(report(s, mem.read(s["store"], 0x90) if args.raw else None))
|
|
print()
|
|
last = sig
|
|
time.sleep(args.interval)
|
|
except KeyboardInterrupt:
|
|
print("\nstopped")
|
|
print("failed reads: %d" % mem.fails)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|