291 lines
14 KiB
Python
291 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""SBC menu render probe + minimal gate-arm poke (FIFA 17 CardsDLL).
|
|
|
|
WHAT THIS DOES
|
|
--------------
|
|
READ-ONLY BY DEFAULT. With no flags it opens /proc/<pid>/mem O_RDONLY, proves the
|
|
CardsDLL slide against the on-disk FNV prologue, and reports the exact live state of
|
|
the SBC data flow so the human can see whether a poke would render anything:
|
|
|
|
A = FUT root singleton = *(0x1802e6398) (vtable static 0x18021c2a0)
|
|
B = SBC request/TTL cache = A + 0x1f9d8 (vtable static 0x1801fae70)
|
|
B+0x08 collection ptr, B+0x20 QPC deadline, B+0x28 READY byte (the gate)
|
|
B offset 0x1f9d8 is DECODED live from A.vtable[+0x4e8] thunk
|
|
(48 8d 81 <disp32> = lea rax,[rcx+disp32]), not taken on faith.
|
|
M = SBC categories store = *(A + 0x20a68) (THE RENDER SOURCE)
|
|
lazy getter A.vtable[+0x9b0] = 0x18011b7d0; category count = WORD[M+0x50];
|
|
category vector M+0x58..M+0x60 (stride 0xf0). The SBC menu draws
|
|
WORD[M+0x50] + 2 tiles. M is NULL until the menu is opened (lazily built,
|
|
empty offline) or the sbs/sets response is parsed.
|
|
HUB = sibling cache = A + 0x1fd70
|
|
SBC req-mgr = A + 0x2a0
|
|
|
|
THE VERIFIED GATE (proven byte-exact against the shipped DLL, isValid 0x180065d40):
|
|
call 0x1801642c0 ; online sub-check -- STUBBED `mov al,1; ret`, not the wall
|
|
cmp BYTE[rbx+0x28],0 ; je fail ; <-- the READY gate
|
|
cmp QWORD[rbx+0x8],0 ; je RET_1 ; <-- SHORT-CIRCUIT: coll==0 => return 1
|
|
<QPC deadline compare> ; only reached when B+0x08 != 0
|
|
So isValid returns TRUE with B+0x28=1 AND B+0x08=0 (short-circuit). Writing B+0x08
|
|
forces the deadline branch; with a stale/past B+0x20 that returns 0 -> the error
|
|
modal. That is why this tool NEVER writes B+0x08 or B+0x20 -- doing so can DEFEAT
|
|
the fix and is a crash risk if the pointer is not a real EASTL collection.
|
|
|
|
THE INTERVENTION THIS TOOL CAN APPLY (--apply)
|
|
----------------------------------------------
|
|
The ONLY write blessed by adversarial verification as non-crashing from a bare
|
|
/proc/mem poke is:
|
|
|
|
BYTE[B+0x28] = 1 (arm the SBC ready gate; leave B+0x08 and B+0x20 alone)
|
|
|
|
This OPENS the SBC menu (isValid short-circuits to true) instead of the error modal.
|
|
It renders EMPTY (2 placeholder tiles) unless M is populated, because tiles come from
|
|
WORD[M+0x50], not from B. It is the proven-safe NEGATIVE CONTROL / gate-open step.
|
|
|
|
WHY A POPULATED MENU NEEDS THE INJECTED DLL, NOT THIS TOOL
|
|
----------------------------------------------------------
|
|
Populating M means running the client's OWN parser (deser 0x18017b2b0) over a real
|
|
sbs/sets response, so it clears+builds M with the correct 0xf0/0x3570 geometry and
|
|
rebuilds the indices. That requires executing code IN-PROCESS (the openfut-hook DLL)
|
|
or serving GET ut/game/fifa17/sbs/sets through the bridge so the native completion
|
|
path populates M and arms B for you. A /proc/mem byte poke cannot build M's nested
|
|
EASTL vectors safely (hand-building 0xf0/0x3570 records is the highest-crash option
|
|
all three verifiers rejected), and it cannot call the deser with a seated SAX cursor.
|
|
Cold-calling the deser with a null cursor WIPES M (clear runs before append) and
|
|
parses nothing. So: this tool arms the gate; the DLL (spec printed by --spec) does
|
|
the populate. See docs and the openfut-hook integration notes.
|
|
|
|
RISK / SAFETY
|
|
-------------
|
|
* Default run = READ ONLY. Nothing here writes unless you pass --apply.
|
|
* --apply WRITES LIVE GAME MEMORY (/proc/<pid>/mem O_WRONLY): one byte, B+0x28=1.
|
|
Do this only on a client sitting in the FUT hub, ideally with the SBC menu CLOSED
|
|
(never mutate while the menu is mid-iterate). Then re-open the SBC menu to render.
|
|
* --apply re-proves the slide AND re-verifies B.vtable == static 0x1801fae70 before
|
|
writing, and aborts on any mismatch. It refuses to write anything but B+0x28.
|
|
* If FIFA17.exe is not running or CardsDLL is not mapped, the tool says so and exits
|
|
0 -- static analysis is authoritative; live steps are best-effort.
|
|
|
|
USAGE
|
|
python3 sbc_hook_poke.py # read-only probe + dry-run plan (default)
|
|
python3 sbc_hook_poke.py --spec # also print the injected-DLL populate spec
|
|
python3 sbc_hook_poke.py --apply # WRITE BYTE[B+0x28]=1 (arm gate) -- HUMAN ONLY
|
|
"""
|
|
import os, struct, sys
|
|
|
|
# ---- static VAs (image base 0x180000000; add live slide) --------------------
|
|
A_SINGLETON = 0x1802e6398 # slot holding A = FUT root singleton ptr
|
|
CTRL_VA = 0x180180d00 # FNV atom-hash prologue used to prove the slide
|
|
A_VT_STATIC = 0x18021c2a0 # A.vtable (verify live == this + slide)
|
|
B_VT_STATIC = 0x1801fae70 # B.vtable (verify live == this + slide)
|
|
A_VT_BGETTER = 0x4e8 # A.vtable slot -> thunk lea rax,[rcx+0x1f9d8]
|
|
A_VT_MGETTER = 0x9b0 # A.vtable slot -> M lazy getter 0x18011b7d0
|
|
M_CACHE_OFF = 0x20a68 # M cache slot on A (decoded from getter cmp)
|
|
HUB_OFF = 0x1fd70
|
|
REQMGR_OFF = 0x2a0
|
|
ISVALID_VA = 0x180065d40
|
|
ONLINE_STUB = 0x1801642c0 # expect b0 01 c3 (mov al,1; ret)
|
|
B_READY_OFF = 0x28
|
|
|
|
PE_PATHS = ['/tmp/fut/cardsdll.dll', '/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll']
|
|
|
|
|
|
def find_pid():
|
|
for d in os.listdir('/proc'):
|
|
if d.isdigit():
|
|
try:
|
|
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
|
|
return int(d)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def load_pe():
|
|
for p in PE_PATHS:
|
|
try:
|
|
return open(p, 'rb').read()
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
|
|
def print_spec():
|
|
print("""
|
|
== INJECTED-DLL POPULATE SPEC (openfut-hook / version.dll) ===================
|
|
The poke tool arms the gate; the DLL must POPULATE M. Preferred, lowest-risk,
|
|
zero-forged-state path (run ON THE GAME MAIN/UI THREAD, SBC menu CLOSED):
|
|
|
|
Option 1 (best) -- serve the response, let the native chain do everything:
|
|
Route GET ut/game/fifa17/sbs/sets through the bridge/core with real JSON.
|
|
The client's own dispatcher builds the response-msg (ctor 0x18017b1c0,
|
|
deser slot +0x20 = 0x18017b2b0), seats a genuine SAX cursor, and its own
|
|
chain populates M and arms B via the completion callback 0x1800b8c30
|
|
(subscribed in svc ctor 0x1800b5765). No memory forging at all. NOTE: the
|
|
front-end refuses to ISSUE the fetch offline and the "ut/%s/sbs" template
|
|
(0x18021d908) has no native xref, so the DLL must inject the RESPONSE at the
|
|
message-receive layer (not rely on the client to send the GET).
|
|
|
|
Option 2 (fallback) -- drive the real parser from the hook:
|
|
1. reg = 0x1800d7170() ; -> ®istry 0x1802c2988
|
|
2. mgr = 0x180009c80(&out, reg) ; hashes 0xed84b11/0xed84b12
|
|
3. build a REAL seated SAX cursor over canned sbs/sets JSON:
|
|
ctx = 0x1801c63e0(...) + lexer 0x1801c8060 + an input-source object
|
|
whose vtable[+0x8] yields your JSON bytes. A null-source cursor parses
|
|
nothing AND the deser clears M first -> do not cold-call with null.
|
|
4. 0x18017b2b0(rcx=ignored, rdx=cursor) ; self-locates mgr, clears M,
|
|
per-cat ctor 0x180159da0 / cat-deser 0x18017ab80 / finalize 0x180160e50 /
|
|
append 0x18015a770, then store finalizers 0x180160e00 + 0x180160f30 +
|
|
0x180161020, then commit mgr.vtable[+0x8]. Sets WORD[M+0x50]=N.
|
|
5. arm gate: A.vtable[+0x4e8](A) -> B; set ONLY BYTE[B+0x28]=1.
|
|
Do NOT write B+0x08 or B+0x20 (short-circuit; see isValid proof).
|
|
6. trigger render: re-open the SBC menu, or fire refresh events 0x756c-0x7574
|
|
so the controller re-reads WORD[M+0x50] at 0x1800b5eda.
|
|
|
|
DO NOT: hand-build 0xf0 category / 0x3570 set records for a direct append
|
|
(deep-copy ctor 0x18015a2b0 derefs inner EASTL sub-vectors -> heap corruption);
|
|
skip the index-rebuild finalizers (by-index getter 0x180160a80 reads OOB);
|
|
mutate M while the menu iterates; or run any of this off the main thread.
|
|
=============================================================================
|
|
""")
|
|
|
|
|
|
def main():
|
|
apply = '--apply' in sys.argv
|
|
if '--spec' in sys.argv:
|
|
print_spec()
|
|
|
|
pid = find_pid()
|
|
if not pid:
|
|
print("FIFA17.exe not running -> skipping live steps. Static analysis is "
|
|
"authoritative; no write possible. (see --spec for the DLL plan)")
|
|
return 0
|
|
print("pid %d" % pid)
|
|
|
|
base = None
|
|
for ln in open('/proc/%d/maps' % pid):
|
|
if 'CardsDLL' in ln:
|
|
base = int(ln.split('-')[0], 16)
|
|
break
|
|
if not base:
|
|
print("CardsDLL not mapped (client not in Ultimate Team yet). Skip live step.")
|
|
return 0
|
|
slide = base - 0x180000000
|
|
print("base %#x slide %#x" % (base, slide))
|
|
|
|
fdr = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
|
|
rd = lambda va, n: os.pread(fdr, n, va)
|
|
q = lambda va: struct.unpack('<Q', rd(va, 8))[0]
|
|
w = lambda va: struct.unpack('<H', rd(va, 2))[0]
|
|
|
|
# ---- prove the slide against the on-disk FNV prologue --------------------
|
|
pe = load_pe()
|
|
if pe is None:
|
|
print("on-disk DLL not found (%s); cannot prove slide -> refuse." % PE_PATHS)
|
|
os.close(fdr); return 1
|
|
f = lambda va: va - 0x180000000 - 0x1000 + 0x400 # .text rva 0x1000 rawptr 0x400
|
|
ctl_ok = pe[f(CTRL_VA):f(CTRL_VA)+24] == rd(CTRL_VA + slide, 24)
|
|
print("CONTROL FNV %s" % ("MATCH" if ctl_ok else "MISMATCH -> ABORT"))
|
|
if not ctl_ok:
|
|
os.close(fdr); return 1
|
|
|
|
# ---- prove the two gate facts from on-disk bytes ------------------------
|
|
online_stub = pe[f(ONLINE_STUB):f(ONLINE_STUB)+3]
|
|
print("online sub-check 0x1801642c0 on-disk = %s %s"
|
|
% (online_stub.hex(), "(stubbed mov al,1;ret -- NOT the wall)"
|
|
if online_stub == b'\xb0\x01\xc3' else "(UNEXPECTED)"))
|
|
|
|
# ---- A root + vtable ----------------------------------------------------
|
|
A = q(A_SINGLETON + slide)
|
|
A_vt = q(A) - slide
|
|
print("A(FUT root) = %#x A.vtable %#x %s"
|
|
% (A, A_vt, "(match)" if A_vt == A_VT_STATIC else "(MISMATCH static %#x)" % A_VT_STATIC))
|
|
|
|
# ---- decode B offset live from A.vtable[+0x4e8] thunk -------------------
|
|
bthunk = q((A_vt + slide) + A_VT_BGETTER) # A.vtable slot -> thunk VA (live)
|
|
stub = rd(bthunk, 7)
|
|
b_off = None
|
|
if stub[:3] == b'\x48\x8d\x81': # lea rax,[rcx+disp32]
|
|
b_off = struct.unpack('<i', stub[3:7])[0]
|
|
print("A.vtable[+0x4e8] -> %#x stub=%s decoded B offset=%s"
|
|
% (bthunk - slide, stub.hex(),
|
|
hex(b_off) if b_off is not None else "?? (expected 0x1f9d8)"))
|
|
if b_off is None:
|
|
b_off = 0x1f9d8 # fall back to the model constant, but we warned above
|
|
B = A + b_off
|
|
|
|
# ---- B cache fields -----------------------------------------------------
|
|
def show_cache(name, C, expect_vt=None):
|
|
vt = q(C) - slide
|
|
coll = q(C + 0x08); dl = q(C + 0x20); ready = rd(C + B_READY_OFF, 1)[0]
|
|
tag = ""
|
|
if expect_vt is not None:
|
|
tag = "(match)" if vt == expect_vt else "(MISMATCH static %#x)" % expect_vt
|
|
print(" %-4s @%#x vt=%#x %s coll(+8)=%#x deadline(+0x20)=%#x ready(+0x28)=%d"
|
|
% (name, C, vt, tag, coll, dl, ready))
|
|
return vt, coll, dl, ready
|
|
|
|
print("live cache state:")
|
|
b_vt, b_coll, b_dl, b_ready = show_cache("SBC", B, B_VT_STATIC)
|
|
show_cache("HUB", A + HUB_OFF)
|
|
print(" reqmgr @%#x +0x08=%#x" % (A + REQMGR_OFF, q(A + REQMGR_OFF + 0x08)))
|
|
|
|
# ---- M = the render source ---------------------------------------------
|
|
M = q(A + M_CACHE_OFF)
|
|
if M == 0:
|
|
print(" M (render source, *(A+0x20a68)) = 0 -> NOT built yet "
|
|
"(SBC menu not opened this session). Empty offline.")
|
|
cat_count = 0
|
|
else:
|
|
cat_count = w(M + 0x50)
|
|
print(" M (render source) = %#x WORD[M+0x50] category count = %d "
|
|
"(menu would draw %d tiles)" % (M, cat_count, cat_count + 2))
|
|
|
|
# ---- the plan / dry-run -------------------------------------------------
|
|
print("\n-- INTERVENTION PLAN --")
|
|
print(" Verified-safe write (this tool, --apply): BYTE @ %#x (B+0x28) = 1"
|
|
% (B + B_READY_OFF))
|
|
print(" effect: isValid short-circuits TRUE -> SBC menu OPENS instead of modal.")
|
|
print(" render: EMPTY unless M is populated (tiles = WORD[M+0x50], not B).")
|
|
print(" REFUSED here (footgun): writing B+0x08 or B+0x20 -> deadline branch,")
|
|
print(" can return FALSE (modal) and/or crash on a bogus collection ptr.")
|
|
print(" Populated render: needs the injected DLL to fill M (run with --spec).")
|
|
|
|
if not apply:
|
|
cur = rd(B + B_READY_OFF, 1)[0]
|
|
print("\n[DRY-RUN] default mode -- no memory written. current BYTE[%#x]=%d, "
|
|
"would set =1. Pass --apply to write (HUMAN ONLY)."
|
|
% (B + B_READY_OFF, cur))
|
|
os.close(fdr)
|
|
return 0
|
|
|
|
# ---- --apply: the single blessed byte write -----------------------------
|
|
# re-verify EVERYTHING load-bearing before touching live memory.
|
|
if not ctl_ok or A_vt != A_VT_STATIC or b_vt != B_VT_STATIC:
|
|
print("\n[ABORT] slide/vtable sanity failed at write time -> refusing to write.")
|
|
os.close(fdr); return 1
|
|
if b_off != 0x1f9d8:
|
|
print("\n[ABORT] B offset decoded as %s (expected 0x1f9d8) -> refusing to write."
|
|
% hex(b_off))
|
|
os.close(fdr); return 1
|
|
|
|
target = B + B_READY_OFF
|
|
before = rd(target, 1)[0]
|
|
print("\n[APPLY] target BYTE @ %#x before=%d" % (target, before))
|
|
if before == 1:
|
|
print("[APPLY] already 1 -> nothing to do (idempotent).")
|
|
os.close(fdr); return 0
|
|
fdw = os.open('/proc/%d/mem' % pid, os.O_WRONLY)
|
|
n = os.pwrite(fdw, b'\x01', target)
|
|
os.close(fdw)
|
|
after = rd(target, 1)[0]
|
|
print("[APPLY] wrote %d byte(s); read-back BYTE @ %#x = %d %s"
|
|
% (n, target, after, "(OK)" if after == 1 else "(WRITE FAILED)"))
|
|
print("[APPLY] now RE-OPEN the SBC menu. Expect: menu opens (no modal); tiles will")
|
|
print(" be EMPTY/placeholder unless M was populated by the DLL first.")
|
|
os.close(fdr)
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|