tools(re): interpret FIFA17 atom mappers and enumerate the resident item map
Two tools, both gated on positive controls because the previous pass produced a confidently wrong negative result. atom_mapper_emu.py interprets the atom -> field-id dispatch functions instead of pattern-scanning them. Its selftest encodes the two decoder traps that caused earlier mistakes - ModRM rm=5 with mod!=0 is [rbp+disp] rather than RIP-relative, and a constant may reach its use through a register - plus the live-verified controls that the item mapper maps atom 568 'players' to field id 1 and atom 11 'actives' to 0. The mandatory manager atom 424 control still fails as a coverage limit: only 2 of the 52 resolver callers are pure dispatch chains, so the tool refuses to support any absence claim about the squad mapper. The live probes enumerate the resident item map at owner+0x160c8, whose layout came from the lower_bound at 0x180119640: key = wire instance id at node+0x20, record at node+0x28, count at owner+0x160e8. probe_map2 reaches 22 nodes against a count field of 22, so the enumeration validates itself, and probe_hunt searches all writable memory with its own in-run positive control. Result: all four club staff are resident, both kit ids are absent everywhere, and a lookup miss returns the static sentinel 0x1802c2a28 whose +0x10 is NULL - which is exactly the KIT_SCAN symptom.
This commit is contained in:
Executable
+587
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interpret FIFA 17's atom -> field-id dispatch functions instead of pattern-scanning them.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
CardsDLL turns a JSON key into an "atom index" (a position in the string-pointer
|
||||
table at .data 0x1802d2760), then a per-response-family mapper converts that index
|
||||
into an internal field id with a chain of integer compares and jump tables.
|
||||
|
||||
A previous attempt to recover each mapper's accepted atoms by scanning for
|
||||
`sub ecx,K` / `cmp ecx,L` / `ja` patterns produced a confidently wrong answer: it
|
||||
reported that no mapper accepts atom 424 (`manager`), while a live client plainly
|
||||
holds a resident manager record. Pattern scanning cannot see control flow, so it
|
||||
cannot tell which compares are actually reachable.
|
||||
|
||||
This module executes the mappers instead. The modelled subset is exactly what these
|
||||
functions use: the resolver call, integer cmp/sub/add/dec, conditional and computed
|
||||
jumps, jump-table loads out of the image, lea, movsxd, and `mov eax,imm; ret`.
|
||||
Anything outside that subset raises Unsupported, so a wrong field id is never
|
||||
returned silently.
|
||||
|
||||
TWO DECODER TRAPS THIS MODULE IS REQUIRED TO HANDLE
|
||||
---------------------------------------------------
|
||||
1. ModRM rm==5 with mod!=0 is [rbp+disp], NOT RIP-relative. Only mod==0 with rm==5
|
||||
is RIP-relative. Treating all rm==5 as RIP-relative hides rbp-based DTO accesses.
|
||||
Covered by test_rbp_relative_is_not_rip_relative.
|
||||
2. A constant frequently arrives in a register (`mov r8d,0x4` ... later stored), so
|
||||
searching for an immediate-to-memory store misses it. The interpreter tracks
|
||||
register values, so propagated constants are followed.
|
||||
Covered by test_constant_propagated_through_register.
|
||||
|
||||
Run `--selftest` to execute the positive controls. Negative results from this tool
|
||||
are only admissible when the selftest passes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REGS = ("rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15")
|
||||
|
||||
ATOM_TABLE_BASE = 0x1802D2760 # validated against 6 known anchors, see anchors()
|
||||
ATOM_RESOLVER = 0x180180D00 # key string -> atom index, returns in eax
|
||||
ITEM_MAPPER = 0x18012FD40 # the DTO/item mapper: atom 568 'players' -> 1
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""The mapper used an instruction or address outside the modelled subset."""
|
||||
|
||||
|
||||
def s32(v: int) -> int:
|
||||
v &= 0xFFFFFFFF
|
||||
return v - 0x100000000 if v & 0x80000000 else v
|
||||
|
||||
|
||||
class Image:
|
||||
"""A parsed PE, with VA<->file mapping and .pdata function bounds."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.buf = path.read_bytes()
|
||||
b = self.buf
|
||||
pe = struct.unpack_from("<I", b, 0x3C)[0]
|
||||
if b[pe:pe + 4] != b"PE\0\0":
|
||||
raise ValueError(f"{path} is not a PE image")
|
||||
nsec = struct.unpack_from("<H", b, pe + 6)[0]
|
||||
optsz = struct.unpack_from("<H", b, pe + 20)[0]
|
||||
self.base = struct.unpack_from("<Q", b, pe + 24 + 24)[0]
|
||||
self.sections = []
|
||||
for i in range(nsec):
|
||||
o = pe + 24 + optsz + 40 * i
|
||||
name = b[o:o + 8].rstrip(b"\0").decode(errors="replace")
|
||||
vsz, va, rsz, raw = struct.unpack_from("<IIII", b, o + 8)
|
||||
self.sections.append((name, va, vsz, raw, rsz))
|
||||
self._funcs = None
|
||||
|
||||
def va2off(self, va: int):
|
||||
rva = va - self.base
|
||||
for _name, sva, vsz, raw, rsz in self.sections:
|
||||
if sva <= rva < sva + max(vsz, rsz):
|
||||
off = raw + (rva - sva)
|
||||
if off < len(self.buf):
|
||||
return off
|
||||
return None
|
||||
|
||||
def rd8(self, va: int) -> int:
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
raise Unsupported(f"unmapped byte read 0x{va:x}")
|
||||
return self.buf[o]
|
||||
|
||||
def rd32(self, va: int) -> int:
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
raise Unsupported(f"unmapped dword read 0x{va:x}")
|
||||
return struct.unpack_from("<I", self.buf, o)[0]
|
||||
|
||||
def cstr(self, va: int, maxlen: int = 96):
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
return None
|
||||
end = self.buf.find(b"\0", o, o + maxlen)
|
||||
if end < 0:
|
||||
return None
|
||||
try:
|
||||
return self.buf[o:end].decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
# ---- .pdata gives exact function bounds; never guess a prologue ----
|
||||
def functions(self):
|
||||
if self._funcs is None:
|
||||
sec = next(s for s in self.sections if s[0] == ".pdata")
|
||||
_n, _va, vsz, raw, _rsz = sec
|
||||
out = []
|
||||
for i in range(vsz // 12):
|
||||
beg, end, _unw = struct.unpack_from("<III", self.buf, raw + 12 * i)
|
||||
if beg or end:
|
||||
out.append((self.base + beg, self.base + end))
|
||||
out.sort()
|
||||
self._funcs = out
|
||||
return self._funcs
|
||||
|
||||
def function_of(self, va: int):
|
||||
fs = self.functions()
|
||||
starts = [f[0] for f in fs]
|
||||
i = bisect.bisect_right(starts, va) - 1
|
||||
if i >= 0 and fs[i][0] <= va < fs[i][1]:
|
||||
return fs[i]
|
||||
return None
|
||||
|
||||
def atom(self, index: int):
|
||||
ptr = struct.unpack_from("<Q", self.buf, self.va2off(ATOM_TABLE_BASE) + 8 * index)[0]
|
||||
return self.cstr(ptr)
|
||||
|
||||
def atom_index(self, name: str):
|
||||
off = self.va2off(ATOM_TABLE_BASE)
|
||||
for i in range(4096):
|
||||
ptr = struct.unpack_from("<Q", self.buf, off + 8 * i)[0]
|
||||
if self.cstr(ptr) == name:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
class Mapper:
|
||||
"""Executes one dispatch function for a given atom index."""
|
||||
|
||||
def __init__(self, image: Image, resolver: int = ATOM_RESOLVER):
|
||||
self.img = image
|
||||
self.resolver = resolver
|
||||
|
||||
def _ea(self, k: int, rex: int, r: dict):
|
||||
"""Decode ModRM[+SIB][+disp].
|
||||
|
||||
Returns (nbytes, dst_reg, addr, src_reg). addr is an int, or the marker
|
||||
("rip", disp) which the caller resolves once it knows the instruction
|
||||
length, or None for a register-form operand.
|
||||
|
||||
TRAP 1: rm==5 is RIP-relative ONLY when mod==0. With mod 1 or 2 it is
|
||||
[rbp+disp] and must be resolved from rbp.
|
||||
"""
|
||||
b = self.img.buf
|
||||
modrm = b[k]
|
||||
mod, rm = modrm >> 6, modrm & 7
|
||||
dst = REGS[(((modrm >> 3) & 7) | ((rex & 4) << 1)) & 15]
|
||||
n = 1
|
||||
if mod == 3:
|
||||
return n, dst, None, REGS[(rm | ((rex & 1) << 3)) & 15]
|
||||
base_v = idx_v = disp = 0
|
||||
if rm == 4:
|
||||
sib = b[k + 1]
|
||||
n += 1
|
||||
scale = 1 << (sib >> 6)
|
||||
ir = ((sib >> 3) & 7) | ((rex & 2) << 2)
|
||||
br = (sib & 7) | ((rex & 1) << 3)
|
||||
if (ir & 15) != 4:
|
||||
idx_v = r[REGS[ir & 15]] * scale
|
||||
if (sib & 7) == 5 and mod == 0:
|
||||
disp = struct.unpack_from("<i", b, k + n)[0]
|
||||
n += 4
|
||||
else:
|
||||
base_v = r[REGS[br & 15]]
|
||||
elif rm == 5 and mod == 0:
|
||||
disp = struct.unpack_from("<i", b, k + 1)[0]
|
||||
return n + 4, dst, ("rip", disp), None
|
||||
else:
|
||||
base_v = r[REGS[(rm | ((rex & 1) << 3)) & 15]]
|
||||
if mod == 1:
|
||||
disp = struct.unpack_from("<b", b, k + n)[0]
|
||||
n += 1
|
||||
elif mod == 2:
|
||||
disp = struct.unpack_from("<i", b, k + n)[0]
|
||||
n += 4
|
||||
return n, dst, (base_v + idx_v + disp) & 0xFFFFFFFFFFFFFFFF, None
|
||||
|
||||
@staticmethod
|
||||
def _cond(cc: int, last) -> bool:
|
||||
a, b = last
|
||||
sa, sb = s32(a), s32(b)
|
||||
ua, ub = a & 0xFFFFFFFF, b & 0xFFFFFFFF
|
||||
if cc == 0x4: return sa == sb
|
||||
if cc == 0x5: return sa != sb
|
||||
if cc == 0xF: return sa > sb
|
||||
if cc == 0xD: return sa >= sb
|
||||
if cc == 0xC: return sa < sb
|
||||
if cc == 0xE: return sa <= sb
|
||||
if cc == 0x7: return ua > ub
|
||||
if cc == 0x3: return ua >= ub
|
||||
if cc == 0x2: return ua < ub
|
||||
if cc == 0x6: return ua <= ub
|
||||
if cc == 0x8: return sa < sb
|
||||
if cc == 0x9: return sa >= sb
|
||||
raise Unsupported(f"condition code 0x{cc:x}")
|
||||
|
||||
def run(self, start: int, atom: int, limit: int = 5000) -> int:
|
||||
b = self.img.buf
|
||||
r = {k: 0 for k in REGS}
|
||||
last = (0, 0)
|
||||
va = start
|
||||
for _ in range(limit):
|
||||
i0 = self.img.va2off(va)
|
||||
if i0 is None:
|
||||
raise Unsupported(f"pc unmapped 0x{va:x}")
|
||||
j = i0
|
||||
while b[j] in (0x66, 0x67, 0xF2, 0xF3):
|
||||
j += 1
|
||||
rex = 0
|
||||
if 0x40 <= b[j] <= 0x4F:
|
||||
rex = b[j]
|
||||
j += 1
|
||||
op = b[j]
|
||||
pre = j - i0
|
||||
|
||||
if op == 0xC3:
|
||||
return r["rax"] & 0xFFFFFFFF
|
||||
if op == 0xCC:
|
||||
raise Unsupported(f"int3 at 0x{va:x}: ran off the end of the function")
|
||||
if op == 0xE8:
|
||||
tgt = va + pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
|
||||
if tgt != self.resolver:
|
||||
raise Unsupported(f"call to non-resolver 0x{tgt:x} at 0x{va:x}")
|
||||
r["rax"] = atom & 0xFFFFFFFF # resolver returns the atom index
|
||||
va += pre + 5
|
||||
continue
|
||||
if op == 0xE9:
|
||||
va += pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
|
||||
continue
|
||||
if op == 0xEB:
|
||||
va += pre + 2 + struct.unpack_from("<b", b, j + 1)[0]
|
||||
continue
|
||||
if 0x70 <= op <= 0x7F:
|
||||
nxt = va + pre + 2
|
||||
rel = struct.unpack_from("<b", b, j + 1)[0]
|
||||
va = nxt + rel if self._cond(op & 0xF, last) else nxt
|
||||
continue
|
||||
if op == 0x0F and 0x80 <= b[j + 1] <= 0x8F:
|
||||
nxt = va + pre + 6
|
||||
rel = struct.unpack_from("<i", b, j + 2)[0]
|
||||
va = nxt + rel if self._cond(b[j + 1] & 0xF, last) else nxt
|
||||
continue
|
||||
if 0xB8 <= op <= 0xBF:
|
||||
r[REGS[((op - 0xB8) | ((rex & 1) << 3)) & 15]] = struct.unpack_from("<I", b, j + 1)[0]
|
||||
va += pre + 5
|
||||
continue
|
||||
if op in (0x05, 0x2D, 0x3D):
|
||||
# accumulator short forms: add/sub/cmp eax, imm32
|
||||
imm = struct.unpack_from("<i", b, j + 1)[0]
|
||||
cur = r["rax"] & 0xFFFFFFFF
|
||||
if op == 0x3D:
|
||||
last = (cur, imm & 0xFFFFFFFF)
|
||||
elif op == 0x2D:
|
||||
r["rax"] = (cur - imm) & 0xFFFFFFFF
|
||||
last = (r["rax"], 0)
|
||||
else:
|
||||
r["rax"] = (cur + imm) & 0xFFFFFFFF
|
||||
last = (r["rax"], 0)
|
||||
va += pre + 5
|
||||
continue
|
||||
if op in (0x81, 0x83):
|
||||
w = 4 if op == 0x81 else 1
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
|
||||
reg = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
imm = struct.unpack_from("<i" if w == 4 else "<b", b, j + 2)[0]
|
||||
ext = (modrm >> 3) & 7
|
||||
cur = r[reg] & 0xFFFFFFFF
|
||||
if ext == 7:
|
||||
last = (cur, imm & 0xFFFFFFFF)
|
||||
elif ext == 5:
|
||||
r[reg] = (cur - imm) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
elif ext == 0:
|
||||
r[reg] = (cur + imm) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
else:
|
||||
raise Unsupported(f"{op:02x} /{ext} at 0x{va:x}")
|
||||
va += pre + 2 + w
|
||||
continue
|
||||
if op == 0xFF and b[j + 1] >> 6 == 3:
|
||||
ext = (b[j + 1] >> 3) & 7
|
||||
reg = REGS[((b[j + 1] & 7) | ((rex & 1) << 3)) & 15]
|
||||
if ext == 1:
|
||||
r[reg] = (r[reg] - 1) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
va += pre + 2
|
||||
continue
|
||||
if ext == 4:
|
||||
va = r[reg]
|
||||
continue
|
||||
raise Unsupported(f"ff /{ext} at 0x{va:x}")
|
||||
if op == 0x0F and b[j + 1] == 0xB6:
|
||||
n, dst, addr, src = self._ea(j + 2, rex, r)
|
||||
end = va + pre + 2 + n
|
||||
if isinstance(addr, tuple):
|
||||
addr = end + addr[1]
|
||||
r[dst] = self.img.rd8(addr) if addr is not None else r[src] & 0xFF
|
||||
va = end
|
||||
continue
|
||||
if op in (0x8B, 0x8D):
|
||||
n, dst, addr, src = self._ea(j + 1, rex, r)
|
||||
end = va + pre + 1 + n
|
||||
if isinstance(addr, tuple):
|
||||
addr = end + addr[1]
|
||||
if op == 0x8D:
|
||||
if addr is None:
|
||||
raise Unsupported(f"lea with register operand at 0x{va:x}")
|
||||
r[dst] = addr
|
||||
else:
|
||||
if addr is None:
|
||||
# register form: mov r32, r32 (e.g. 8b c8 = mov ecx,eax)
|
||||
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
|
||||
else:
|
||||
r[dst] = self.img.rd32(addr)
|
||||
va = end
|
||||
continue
|
||||
if op == 0x89:
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"89 memory store at 0x{va:x}")
|
||||
src = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
dst = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
|
||||
va += pre + 2
|
||||
continue
|
||||
if op == 0x63:
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"63 memory form at 0x{va:x}")
|
||||
src = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
dst = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
r[dst] = s32(r[src]) & 0xFFFFFFFFFFFFFFFF
|
||||
va += pre + 2
|
||||
continue
|
||||
if op in (0x01, 0x03, 0x29, 0x2B, 0x39, 0x3B,
|
||||
0x09, 0x0B, 0x21, 0x23, 0x31, 0x33, 0x85):
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
|
||||
a = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
c = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
m = 0xFFFFFFFFFFFFFFFF if rex & 8 else 0xFFFFFFFF
|
||||
if op == 0x01:
|
||||
r[a] = (r[a] + r[c]) & m
|
||||
elif op == 0x03:
|
||||
r[c] = (r[c] + r[a]) & m
|
||||
elif op == 0x29:
|
||||
r[a] = (r[a] - r[c]) & m
|
||||
last = (r[a] & 0xFFFFFFFF, 0)
|
||||
elif op == 0x2B:
|
||||
r[c] = (r[c] - r[a]) & m
|
||||
last = (r[c] & 0xFFFFFFFF, 0)
|
||||
elif op in (0x09, 0x0B, 0x21, 0x23, 0x31, 0x33):
|
||||
fn = {0x09: lambda x, y: x | y, 0x0B: lambda x, y: x | y,
|
||||
0x21: lambda x, y: x & y, 0x23: lambda x, y: x & y,
|
||||
0x31: lambda x, y: x ^ y, 0x33: lambda x, y: x ^ y}[op]
|
||||
if op in (0x09, 0x21, 0x31):
|
||||
r[a] = fn(r[a], r[c]) & m
|
||||
last = (r[a] & 0xFFFFFFFF, 0)
|
||||
else:
|
||||
r[c] = fn(r[c], r[a]) & m
|
||||
last = (r[c] & 0xFFFFFFFF, 0)
|
||||
elif op == 0x85:
|
||||
last = ((r[a] & r[c]) & 0xFFFFFFFF, 0)
|
||||
elif op == 0x39:
|
||||
last = (r[a] & 0xFFFFFFFF, r[c] & 0xFFFFFFFF)
|
||||
else:
|
||||
last = (r[c] & 0xFFFFFFFF, r[a] & 0xFFFFFFFF)
|
||||
va += pre + 2
|
||||
continue
|
||||
if op == 0x90:
|
||||
va += pre + 1
|
||||
continue
|
||||
if op == 0x0F and b[j + 1] == 0x1F:
|
||||
n, _d, _a, _s = self._ea(j + 2, rex, r)
|
||||
va += pre + 2 + n
|
||||
continue
|
||||
raise Unsupported(f"opcode {op:02x} at 0x{va:x}")
|
||||
raise Unsupported("instruction limit reached")
|
||||
|
||||
|
||||
def find_mappers(img: Image, resolver: int = ATOM_RESOLVER):
|
||||
"""Every function containing a direct call to the atom resolver."""
|
||||
sec = next(s for s in img.sections if s[0] == ".text")
|
||||
_n, tva, _vsz, traw, trsz = sec
|
||||
out = {}
|
||||
for i in range(traw, traw + trsz - 5):
|
||||
if img.buf[i] != 0xE8:
|
||||
continue
|
||||
va = img.base + tva + (i - traw)
|
||||
if va + 5 + struct.unpack_from("<i", img.buf, i + 1)[0] == resolver:
|
||||
f = img.function_of(va)
|
||||
if f:
|
||||
out.setdefault(f[0], []).append(va)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# selftest: the two decoder traps plus the live-verified positive controls
|
||||
# --------------------------------------------------------------------------
|
||||
def test_atom_anchors(img: Image) -> list:
|
||||
"""The atom table base must reproduce known anchors, or every index is wrong."""
|
||||
anchors = {11: "actives", 363: "itemData", 376: "kicktakers",
|
||||
424: "manager", 568: "players", 718: "squadActives"}
|
||||
fails = []
|
||||
for idx, want in anchors.items():
|
||||
got = img.atom(idx)
|
||||
if got != want:
|
||||
fails.append(f"atom[{idx}] = {got!r}, expected {want!r}")
|
||||
return fails
|
||||
|
||||
|
||||
def test_rbp_relative_is_not_rip_relative(img: Image) -> list:
|
||||
"""TRAP 1. mod!=0 with rm==5 must resolve as [rbp+disp], not RIP-relative.
|
||||
|
||||
Encoding under test: 8b 4d 20 == mov ecx,[rbp+0x20] (mod=01, rm=101).
|
||||
A decoder that treats rm==5 as RIP-relative computes a wildly different
|
||||
address and silently reads the wrong memory.
|
||||
"""
|
||||
m = Mapper(img)
|
||||
r = {k: 0 for k in REGS}
|
||||
r["rbp"] = 0x140000000
|
||||
saved = img.buf
|
||||
try:
|
||||
img.buf = bytes.fromhex("8b4d20")
|
||||
n, dst, addr, _src = m._ea(1, 0, r)
|
||||
finally:
|
||||
img.buf = saved
|
||||
fails = []
|
||||
if isinstance(addr, tuple):
|
||||
fails.append("mod=01 rm=101 decoded as RIP-relative; must be [rbp+disp]")
|
||||
elif addr != 0x140000020:
|
||||
fails.append(f"[rbp+0x20] resolved to 0x{addr:x}, expected 0x140000020")
|
||||
if dst != "rcx":
|
||||
fails.append(f"destination decoded as {dst}, expected rcx")
|
||||
if n != 2:
|
||||
fails.append(f"modrm+disp8 consumed {n} bytes, expected 2")
|
||||
return fails
|
||||
|
||||
|
||||
def test_constant_propagated_through_register(img: Image) -> list:
|
||||
"""TRAP 2. A constant reaching a use through a register must be followed.
|
||||
|
||||
Program: mov eax,0; mov r8d,4; mov eax,r8d; ret -> must yield 4, which is
|
||||
only observable if register values propagate. Scanning for an immediate
|
||||
store would see nothing.
|
||||
"""
|
||||
m = Mapper(img)
|
||||
saved = img.buf
|
||||
prog = bytes.fromhex("b800000000" "41b804000000" "4489c0" "c3")
|
||||
try:
|
||||
img.buf = prog
|
||||
img_va2off = img.va2off
|
||||
img.va2off = lambda va: va if 0 <= va < len(prog) else None
|
||||
got = m.run(0, 0)
|
||||
finally:
|
||||
img.buf = saved
|
||||
img.va2off = img_va2off
|
||||
return [] if got == 4 else [f"register-propagated constant yielded {got}, expected 4"]
|
||||
|
||||
|
||||
def test_item_mapper_controls(img: Image) -> list:
|
||||
"""Live/disassembly-verified behaviour of the item mapper."""
|
||||
m = Mapper(img)
|
||||
fails = []
|
||||
got = m.run(ITEM_MAPPER, 568)
|
||||
if got != 1:
|
||||
fails.append(f"item mapper atom 568 'players' -> {got}, expected 1")
|
||||
got = m.run(ITEM_MAPPER, 11)
|
||||
if got != 0:
|
||||
fails.append(f"item mapper atom 11 'actives' -> {got}, expected 0")
|
||||
return fails
|
||||
|
||||
|
||||
def test_manager_424_is_accepted_somewhere(img: Image) -> list:
|
||||
"""MANDATORY control. A live client holds a resident manager record, so some
|
||||
mapper must map atom 424 to a non-zero field id. The previous pattern-scan
|
||||
method failed exactly here, and any replacement must not."""
|
||||
m = Mapper(img)
|
||||
accepting = []
|
||||
for start in find_mappers(img):
|
||||
try:
|
||||
if m.run(start, 424):
|
||||
accepting.append(start)
|
||||
except Unsupported:
|
||||
continue
|
||||
if not accepting:
|
||||
return ["no mapper maps atom 424 'manager' to a non-zero field id, "
|
||||
"which contradicts the live resident manager record"]
|
||||
return []
|
||||
|
||||
|
||||
def selftest(img: Image) -> int:
|
||||
checks = [
|
||||
("atom table anchors", test_atom_anchors),
|
||||
("trap 1: rbp-relative modrm", test_rbp_relative_is_not_rip_relative),
|
||||
("trap 2: constant via register", test_constant_propagated_through_register),
|
||||
("item mapper positive controls", test_item_mapper_controls),
|
||||
("mandatory: manager atom 424 accepted", test_manager_424_is_accepted_somewhere),
|
||||
]
|
||||
bad = 0
|
||||
for name, fn in checks:
|
||||
try:
|
||||
fails = fn(img)
|
||||
except Exception as exc: # noqa: BLE001 - report, don't mask
|
||||
fails = [f"raised {type(exc).__name__}: {exc}"]
|
||||
if fails:
|
||||
bad += 1
|
||||
print(f" FAIL {name}")
|
||||
for f in fails:
|
||||
print(f" {f}")
|
||||
else:
|
||||
print(f" ok {name}")
|
||||
print("\n ALL PASS" if not bad else f"\n {bad} CHECK(S) FAILED - negative results are NOT admissible")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("image", type=Path, help="CardsDLL_Win64_retail.dll")
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
ap.add_argument("--atom", type=int, action="append", default=[],
|
||||
help="atom index to resolve through every mapper")
|
||||
ap.add_argument("--name", action="append", default=[],
|
||||
help="atom name to resolve through every mapper")
|
||||
args = ap.parse_args()
|
||||
img = Image(args.image)
|
||||
|
||||
if args.selftest:
|
||||
return selftest(img)
|
||||
|
||||
atoms = list(args.atom)
|
||||
for nm in args.name:
|
||||
idx = img.atom_index(nm)
|
||||
if idx is None:
|
||||
print(f" atom {nm!r} not found in the table")
|
||||
return 2
|
||||
atoms.append(idx)
|
||||
if not atoms:
|
||||
ap.error("give --atom/--name, or --selftest")
|
||||
|
||||
m = Mapper(img)
|
||||
mappers = find_mappers(img)
|
||||
print(f" {len(mappers)} mapper function(s) found\n")
|
||||
for a in atoms:
|
||||
print(f" === atom {a} ({img.atom(a)!r}) ===")
|
||||
rows, unsup = [], 0
|
||||
for start in sorted(mappers):
|
||||
try:
|
||||
fid = m.run(start, a)
|
||||
except Unsupported:
|
||||
unsup += 1
|
||||
continue
|
||||
if fid:
|
||||
rows.append((start, fid))
|
||||
for start, fid in rows:
|
||||
print(f" mapper 0x{start:x} -> field id {fid} (0x{fid:x})")
|
||||
print(f" {len(rows)} mapper(s) accept it; {unsup} not modelled\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hunt for specific wire instance ids anywhere in the client's writable memory.
|
||||
|
||||
Answers whether a served item was materialised into a record at all, versus
|
||||
materialised but not attached to a collection. A record is recognised by its
|
||||
established layout: id at +0x08, resourceId at +0x18, cardtype at +0x4c.
|
||||
|
||||
Read-only. Never writes.
|
||||
|
||||
usage: probe_hunt.py PID id [id ...]
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
IDS = [int(a) for a in sys.argv[2:]]
|
||||
if not IDS:
|
||||
sys.exit("give at least one wire id")
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
regions = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", ln)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4).strip()
|
||||
if "w" not in perms:
|
||||
continue
|
||||
if path.startswith("/") and not path.endswith(".dll") and not path.endswith(".exe"):
|
||||
continue
|
||||
regions.append((lo, hi, perms, path))
|
||||
total = sum(hi - lo for lo, hi, _, _ in regions)
|
||||
print(f" {len(regions)} writable regions, {total/2**20:.0f} MiB to scan")
|
||||
|
||||
needles = {struct.pack("<I", i): i for i in IDS}
|
||||
hits = {i: [] for i in IDS}
|
||||
CHUNK = 8 << 20
|
||||
scanned = 0
|
||||
for lo, hi, perms, path in regions:
|
||||
a = lo
|
||||
while a < hi:
|
||||
n = min(CHUNK, hi - a)
|
||||
try:
|
||||
mem.seek(a)
|
||||
data = mem.read(n)
|
||||
except OSError:
|
||||
a += n
|
||||
continue
|
||||
if not data:
|
||||
a += n
|
||||
continue
|
||||
scanned += len(data)
|
||||
for nd, wid in needles.items():
|
||||
start = 0
|
||||
while True:
|
||||
j = data.find(nd, start)
|
||||
if j < 0:
|
||||
break
|
||||
start = j + 1
|
||||
va = a + j
|
||||
# a record would place this id at +0x08
|
||||
rec = va - 0x08
|
||||
try:
|
||||
mem.seek(rec)
|
||||
r = mem.read(0x100)
|
||||
except OSError:
|
||||
continue
|
||||
if len(r) < 0x100:
|
||||
continue
|
||||
ct = struct.unpack_from("<i", r, 0x4c)[0]
|
||||
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||
sub = struct.unpack_from("<i", r, 0x50)[0]
|
||||
cat = struct.unpack_from("<i", r, 0x60)[0]
|
||||
looks = 0 <= ct <= 32 and res > 1000
|
||||
hits[wid].append((va, rec, ct, sub, cat, res, looks))
|
||||
a += n
|
||||
print(f" scanned {scanned/2**20:.0f} MiB\n")
|
||||
for wid in IDS:
|
||||
hs = hits[wid]
|
||||
recs = [h for h in hs if h[6]]
|
||||
print(f" id {wid}: {len(hs)} raw occurrence(s), {len(recs)} record-shaped")
|
||||
for va, rec, ct, sub, cat, res, _ in recs[:6]:
|
||||
print(f" record {rec:#x}: cardtype={ct} subtype={sub} category={cat} resourceId={res}")
|
||||
if not recs:
|
||||
print(" NOT MATERIALISED as a record anywhere in writable memory")
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Identify every resident record by its wire instance id.
|
||||
|
||||
Record layout established from known wire values:
|
||||
+0x08 id (wire instance) +0x18 resourceId +0x1c/+0x20 assetId
|
||||
+0x38 discardValue +0x4c cardtype +0x50 cardsubtypeid
|
||||
+0x5c itemState +0x60 category +0x94 teamid
|
||||
+0xb4 rating +0xba teamkittypetechid (u16)
|
||||
|
||||
Walks the contiguous 0x180-stride pool around the manager slot record so records
|
||||
that are resident but not in any collection are still seen. Read-only.
|
||||
|
||||
usage: probe_ids.py PID [expected_id ...]
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
WANT = {int(a) for a in sys.argv[2:]}
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
live = lambda s: base + (s - 0x180000000)
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
RECSZ = 0x180
|
||||
|
||||
def dec(rec):
|
||||
r = rd(rec, 0x180)
|
||||
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||
return dict(id=struct.unpack_from("<I", r, 0x8)[0], res=struct.unpack_from("<I", r, 0x18)[0],
|
||||
ct=g(0x4c), sub=g(0x50), st=g(0x5c), cat=g(0x60), team=g(0x94),
|
||||
rating=struct.unpack_from("<I", r, 0xb4)[0],
|
||||
kt=struct.unpack_from("<H", r, 0xba)[0])
|
||||
|
||||
mgr_rec = q(mgr + 0xc0 + 0x10)
|
||||
print(f" manager-slot record = {mgr_rec:#x}")
|
||||
anchor = mgr_rec if mgr_rec else q(q(mgr + 0xd8) + 0x10)
|
||||
|
||||
# walk backwards to the start of the contiguous run, then forwards
|
||||
lo = anchor
|
||||
for _ in range(64):
|
||||
prev = lo - RECSZ
|
||||
try:
|
||||
d = dec(prev)
|
||||
except OSError:
|
||||
break
|
||||
if not (0 < d["ct"] < 64) or d["id"] == 0:
|
||||
break
|
||||
lo = prev
|
||||
|
||||
print(f" pool run starts at {lo:#x}\n")
|
||||
print(f" {'idx':>3} {'addr':>12} {'id':>10} {'resource':>9} {'ct':>3} {'sub':>4} "
|
||||
f"{'st':>3} {'cat':>4} {'team':>5} {'rate':>5} {'kt':>6}")
|
||||
found = {}
|
||||
k = 0
|
||||
addr = lo
|
||||
while k < 48:
|
||||
try:
|
||||
d = dec(addr)
|
||||
except OSError:
|
||||
break
|
||||
if d["id"] == 0 and d["ct"] == 0:
|
||||
break
|
||||
tag = ""
|
||||
if d["ct"] == 7:
|
||||
tag = " <== CARDTYPE 7"
|
||||
if d["id"] in WANT:
|
||||
tag += " <== WANTED"
|
||||
found[d["id"]] = addr
|
||||
slot = " [manager slot]" if addr == mgr_rec else ""
|
||||
print(f" {k:>3} {addr:#12x} {d['id']:>10} {d['res']:>9} {d['ct']:>3} {d['sub']:>4} "
|
||||
f"{d['st']:>3} {d['cat']:>4} {d['team']:>5} {d['rating']:>5} {d['kt']:>6}{tag}{slot}")
|
||||
addr += RECSZ
|
||||
k += 1
|
||||
|
||||
if WANT:
|
||||
print(f"\n wanted ids: {sorted(WANT)}")
|
||||
for w in sorted(WANT):
|
||||
print(f" {w}: {'FOUND at ' + hex(found[w]) if w in found else 'NOT RESIDENT'}")
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Map FIFA 17 resident record offsets using UNIQUE wire values as ground truth.
|
||||
|
||||
v2: identifies each record by its wire instance id (large, unique) and only
|
||||
accepts a field mapping when the value is distinctive (>= 16) and the same
|
||||
offset holds the right value for EVERY identified record. This avoids the v1
|
||||
failure where cardsubtypeid == 0 matched every zeroed field in the struct.
|
||||
|
||||
Read-only. Never writes.
|
||||
|
||||
usage: probe_layout2.py PID squad_active.json
|
||||
"""
|
||||
import re, struct, sys, json, collections
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
SQUAD = json.load(open(sys.argv[2]))
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
live = lambda s: base + (s - 0x180000000)
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
RECSZ = 0x180
|
||||
|
||||
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
|
||||
recs = [r for r in (q(beg + k*24 + 0x10) for k in range((end - beg)//24)) if r]
|
||||
|
||||
wire = {}
|
||||
for p in SQUAD["players"]:
|
||||
it = p.get("itemData") or {}
|
||||
if it.get("id"):
|
||||
wire[it["id"]] = it
|
||||
|
||||
# --- identify each record by its wire instance id ---
|
||||
ident = {}
|
||||
for rec in recs:
|
||||
r = rd(rec, RECSZ)
|
||||
for off in range(0, RECSZ - 4, 4):
|
||||
v = struct.unpack_from("<I", r, off)[0]
|
||||
if v in wire:
|
||||
ident.setdefault(rec, (v, off))
|
||||
break
|
||||
print(f" resident player records: {len(recs)}, identified: {len(ident)}")
|
||||
id_offs = collections.Counter(o for _, o in ident.values())
|
||||
print(f" wire-id offset candidates: {[(hex(o), c) for o, c in id_offs.most_common()]}")
|
||||
|
||||
FIELDS = ("id", "resourceId", "assetId", "definitionId", "cardassetid", "rating",
|
||||
"teamid", "nation", "leagueId", "contract", "fitness", "playStyle",
|
||||
"discardValue", "cardsubtypeid", "owners", "rareflag")
|
||||
# --- for every offset, does it hold field F for every identified record? ---
|
||||
consistent = {}
|
||||
for off in range(0, RECSZ - 4, 4):
|
||||
for f in FIELDS:
|
||||
ok = 0; total = 0; distinct = set()
|
||||
for rec, (wid, _) in ident.items():
|
||||
it = wire[wid]
|
||||
v = it.get(f)
|
||||
if not isinstance(v, int) or v < 16: # require distinctive values
|
||||
continue
|
||||
total += 1
|
||||
got = struct.unpack_from("<I", rd(rec, RECSZ), off)[0]
|
||||
if got == v:
|
||||
ok += 1; distinct.add(v)
|
||||
if total >= 5 and ok == total and len(distinct) >= 2:
|
||||
consistent.setdefault(off, []).append((f, total, len(distinct)))
|
||||
|
||||
print(f"\n === offsets consistently holding a distinctive wire field ===")
|
||||
for off in sorted(consistent):
|
||||
for f, total, nd in consistent[off]:
|
||||
print(f" +0x{off:<4x} {f:14s} (matched {total}/{total} records, {nd} distinct values)")
|
||||
|
||||
# --- dump the manager and the three club staff for comparison ---
|
||||
print(f"\n === cardtype-2 slot (manager) ===")
|
||||
h = q(mgr + 0xc0 + 0x10)
|
||||
if h:
|
||||
r = rd(h, RECSZ)
|
||||
for off in sorted(consistent):
|
||||
f = consistent[off][0][0]
|
||||
print(f" +0x{off:<4x} {f:14s} = {struct.unpack_from('<I', r, off)[0]}")
|
||||
for name, off, sz in (("cardtype", 0x4c, 4), ("cardsubtypeid", 0x50, 4),
|
||||
("itemState", 0x5c, 4), ("category", 0x60, 4)):
|
||||
print(f" +0x{off:<4x} {name:14s} = {struct.unpack_from('<i', r, off)[0]}")
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enumerate FIFA 17's resident item map authoritatively.
|
||||
|
||||
Layout recovered from the lower_bound at 0x180119640:
|
||||
owner+0x160c8 sentinel / end marker
|
||||
owner+0x160d8 root
|
||||
owner+0x160e8 count
|
||||
node+0x00, node+0x08 children
|
||||
node+0x20 key = wire instance id (qword)
|
||||
node+0x28 the item record
|
||||
On miss the client returns the static sentinel 0x1802c2a28 whose +0x10 is NULL.
|
||||
|
||||
Read-only. usage: probe_map2.py PID [id ...]
|
||||
"""
|
||||
import re, struct, sys, collections
|
||||
|
||||
PID = int(sys.argv[1]); WANT = {int(a) for a in sys.argv[2:]}
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a): return struct.unpack("<Q", rd(a, 8))[0]
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m: named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
if rd(base + (0x180026fea - 0x180000000), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(base + (0x1802e6398 - 0x180000000))
|
||||
SENT, ROOT, COUNT = owner + 0x160c8, q(owner + 0x160d8), q(owner + 0x160e8) & 0xffffffff
|
||||
print(f" owner={owner:#x} sentinel={SENT:#x} root={ROOT:#x} count={COUNT}")
|
||||
|
||||
nodes, seen, stack = [], set(), [ROOT]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
if not n or n == SENT or n in seen or len(seen) > 5000:
|
||||
continue
|
||||
seen.add(n)
|
||||
try:
|
||||
h = rd(n, 0x30)
|
||||
except OSError:
|
||||
continue
|
||||
if len(h) < 0x30:
|
||||
continue
|
||||
nodes.append(n)
|
||||
stack.append(struct.unpack_from("<Q", h, 0)[0])
|
||||
stack.append(struct.unpack_from("<Q", h, 8)[0])
|
||||
print(f" nodes reached: {len(nodes)} (count field says {COUNT})\n")
|
||||
|
||||
print(f" {'key':>11} {'record':>12} {'id':>10} {'resource':>10} {'ct':>3} {'sub':>4} {'st':>4} {'cat':>4}")
|
||||
hist = collections.Counter(); found = {}
|
||||
rows = []
|
||||
for n in nodes:
|
||||
key = q(n + 0x20)
|
||||
rec = n + 0x28
|
||||
try: r = rd(rec, 0x180)
|
||||
except OSError: continue
|
||||
if len(r) < 0x180: continue
|
||||
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||
rid = struct.unpack_from("<I", r, 0x8)[0]
|
||||
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||
ct, sub, st, cat = g(0x4c), g(0x50), g(0x5c), g(0x60)
|
||||
hist[ct] += 1
|
||||
if rid in WANT: found[rid] = rec
|
||||
rows.append((key, rec, rid, res, ct, sub, st, cat))
|
||||
for key, rec, rid, res, ct, sub, st, cat in sorted(rows):
|
||||
tag = " <== CARDTYPE 7" if ct == 7 else (" <== WANTED" if rid in WANT else "")
|
||||
print(f" {key:>11} {rec:#12x} {rid:>10} {res:>10} {ct:>3} {sub:>4} {st:>4} {cat:>4}{tag}")
|
||||
print(f"\n cardtype histogram: {dict(sorted(hist.items()))} total={sum(hist.values())}")
|
||||
for w in sorted(WANT):
|
||||
print(f" id {w}: {'RESIDENT' if w in found else 'ABSENT'}")
|
||||
Reference in New Issue
Block a user