"""D3 Q3: (a) where the lifecycle atoms are parsed AT ALL, (b) who reads the state slots on the item record, (c) who writes the pile slot +0x60. ESTABLISHED (q1/q2 + live, controls passed): record_off = 0x188 - N for the deser's stack struct; itemState -> +0x5c (free==1 live), owners -> +0x48 (1 live), !untradeable -> +0x49 (0 live), discardValue sent -> +0x38, computed -> +0x3c, vtable -> +0x70, pile -> +0x60 (1 club / 6 purchased, NOT from the wire). (a) THE ABSENCE QUESTION, done soundly. Every SAX deserializer in this DLL ends its key loop with the value-SKIP FUN_180135ff0 as the default arm. So the set of callers of FUN_180135ff0 IS the set of deserializers -- a bounded, enumerable population. We decompile each and look for the lifecycle atoms in ALL dispatch forms at once by matching the decompiler's own text: `case 0xNNN:`, `== 0xNNN`, `!= 0xNNN`, and `< 0xNNN` / `- 0xNNN` ladder steps. CONTROL: the same scan must find 0xd7 (discardValue) and 0x172 (itemState) inside FUN_18013fe00, both of which we have already read with our own eyes as `case` labels. If those two do not come back, the scan is broken and no absence below counts. (b) publisher hunt: group every function by the set of register+displacement memory operands it uses; a function that touches 0x38/0x3c/0x5c/0x49/0x60 together is reading the item record. CONTROL: the same grouping must rediscover FUN_18013fe00 itself as a heavy toucher. (c) pile writers: instructions storing an immediate 1 or 6 into [reg+0x60]. """ import traceback, os, re from collections import defaultdict OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" os.makedirs(OUT, exist_ok=True) LIFE = {0x172: "itemState", 0x226: "pile", 0x227: "pileSizeClientData", 0x228: "pileType", 0x207: "owners", 0x361: "untradeable", 0x362: "untradeableCount", 0x331: "tradeId", 0x332: "tradepile", 0x333: "tradePile", 0x19b: "loans", 0x16f: "itemLoans", 0xed: "duplicateItemLoans", 0x335: "tradeState", 0x1c0: "maximumTradePileSize", 0xd7: "discardValue(CONTROL)"} PAT = {a: re.compile(r"(case %s:|== %s\b|!= %s\b|< %s\b|- %s\b|\+ %s\b)" % tuple([hex(a)] * 6)) for a in LIFE} def dump(tag, va, echo=True): f = func(va) if f is None: print("%s %#x -> NO FUNCTION" % (tag, va)) return "" src = dec(va) print("=" * 78) print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src))) print("=" * 78) if echo: print(src) with open(OUT + "q3_%s_%x.c" % (tag, va), "w") as fh: fh.write(src) return src try: print("###### A. deserializer census (callers of the value-SKIP FUN_180135ff0)") ents = sorted({e for (_, _, _, e) in xrefs_to(0x180135ff0) if e}) print(" deserializer population: %d functions" % len(ents)) rows = [] for i, e in enumerate(ents): try: src = dec(e, 120) except Exception as ex: print(" dec failed %#x %s" % (e, ex)) continue hit = {} for a, nm in LIFE.items(): m = PAT[a].findall(src) if m: hit[a] = len(m) if hit: rows.append((e, fname(e), len(src), hit)) print(" functions mentioning at least one lifecycle atom: %d" % len(rows)) for e, nm, ln, hit in rows: print(" %#x %-18s len=%-7d %s" % (e, nm, ln, ", ".join("%s(0x%x)x%d" % (LIFE[a], a, n) for a, n in sorted(hit.items())))) print() print(" --- per-atom summary over the whole deserializer population") for a, nm in sorted(LIFE.items()): fs = [(e, n) for e, n, _, h in rows if a in h] print(" 0x%-4x %-24s parsed in %d deserializer(s): %s" % (a, nm, len(fs), ", ".join("%s(%#x)" % (n, e) for e, n in fs) or "NONE")) print() print("###### B. publisher hunt: displacement fingerprints over the whole .text") disp_of = defaultdict(set) count_of = defaultdict(lambda: defaultdict(int)) it = listing.getInstructions(True) tot = 0 while it.hasNext(): ins = it.next() tot += 1 f = None for i in range(ins.getNumOperands()): try: objs = ins.getOpObjects(i) except Exception: continue if len(objs) < 2: continue regs = [o for o in objs if hasattr(o, "getName")] if not regs: continue if any(str(r) in ("RSP", "RBP", "ESP", "EBP") for r in regs): continue for o in objs: try: v = int(o.getValue()) except Exception: continue if 0 <= v <= 0x200: if f is None: f = fm.getFunctionContaining(ins.getAddress()) if f is None: break f = int(f.getEntryPoint().getOffset()) disp_of[f].add(v) count_of[f][v] += 1 print(" scanned %d instructions, %d functions with reg+disp operands" % (tot, len(disp_of))) KEY = {0x38, 0x3c, 0x49, 0x5c, 0x60} cands = [(len(KEY & d), e, sorted(KEY & d)) for e, d in disp_of.items() if len(KEY & d) >= 3] cands.sort(reverse=True) print(" functions touching >=3 of {0x38,0x3c,0x49,0x5c,0x60}: %d" % len(cands)) for k, e, s in cands[:60]: print(" %#x %-18s %d/%d %s" % (e, fname(e), k, len(KEY), [hex(x) for x in s])) print(" CONTROL: is FUN_18013fe00 in the fingerprint map?", 0x18013fe00 in disp_of, sorted(hex(x) for x in (KEY & disp_of.get(0x18013fe00, set())))) print() print("###### C. every function whose displacement set contains 0x5c AND 0x49") for e, d in sorted(disp_of.items()): if 0x5c in d and 0x49 in d: print(" %#x %-18s" % (e, fname(e))) print() print("###### D. small getters: functions <= 0x20 bytes that read [reg+0x5c] or [reg+0x49] or [reg+0x60]") for e, d in sorted(disp_of.items()): f = func(e) if f is None: continue sz = int(f.getBody().getNumAddresses()) if sz <= 0x20 and d & {0x49, 0x5c, 0x60, 0x48}: print(" %#x %-18s size=%d disp=%s" % (e, fname(e), sz, sorted(hex(x) for x in d))) print() print("###### E. writers of the pile slot: STORE of imm into [reg+0x60]") it = listing.getInstructions(True) while it.hasNext(): ins = it.next() s = str(ins) if "+ 0x60]" in s and ins.getMnemonicString().upper() == "MOV" and s.rstrip().endswith((",0x1", ",0x6", ",0x2", ",0x3", ",0x4", ",0x5", ",0x0")): if "RSP" in s or "RBP" in s: continue f = fm.getFunctionContaining(ins.getAddress()) print(" %#x %-46s in %s" % (int(ins.getAddress().getOffset()), s, f.getName() if f else "?")) print() print("###### F. the two byte-0x49 comparators found in q2") for tag, va in (("cmp49_a", 0x1801a7260), ("cmp49_b", 0x1801a8940), ("wr49", 0x180130d10)): dump(tag, va) except Exception: traceback.print_exc()