leakhunt/tools/find_all_noop_slots.py
acbot 57b5e43d0e Initial commit — leak-hunt project complete
Five bugs identified and patched in retail Asheron's Call client:
- v3b: palette refcount over-increment (3-byte NOP at two sites)
- v5: RenderSurface PurgeResource no-op stub (vtable slot 2 thunk)
- v11: two dangling-pointer crash guards (NULL-check + reorder)
- v14: CEnvCell::Destroy ClipPlaneList leak (18-byte JMP to cleanup thunk)
- v22: unpacker stale-pointer SEH guard (whole-function __try/__except)

All five ship in leakfix.dll (117 KB, SHA d282f23c…) which is loaded
by acclient.exe at process start via PE import table patching by
tools/install_leakfix.py.

Controlled 15-client fleet soak: unpatched control died at 26h with
palette exhaustion; all 14 patched clients survived past that point
and reached ≥5-day uptime.

Residual ~15 MB/h growth traced to d3d9.dll's internal slab allocator
(260KB surface backing buffers retained after Release). See REPORT.md
§10 for the full investigation; conclusion is that it's unfixable from
outside d3d9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:07:58 +02:00

130 lines
4.5 KiB
Python

"""find_all_noop_slots.py <dump.dmp>
Scan the .rdata-like image regions for vtable-shaped structures and
report every slot that points to the no-op stub at 0x004154a0
(the `mov al,1; ret` stub used by GraphicsResource::PurgeResource,
DBObj::ReleaseSubObjects, and similar "should be overridden" virtuals).
Each finding is a vtable slot where the base implementation lies
("returns success without doing the work") and a subclass should
have overridden it. The leaks happen where subclasses didn't override.
Heuristic for "this is a vtable":
- A run of aligned DWORDs where each DWORD points to an address
inside an executable image region (.text).
- At least N consecutive such DWORDs (N=4) qualifies as a vtable.
For each vtable found, count how many slots == no-op-stub.
Output: vtables ranked by no-op-slot count, with slot indices.
"""
import struct, sys
from collections import defaultdict
from minidump.minidumpfile import MinidumpFile
NOOP_STUB = 0x004154a0
MIN_VTABLE_SLOTS = 4
def _ei(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def main():
md = MinidumpFile.parse(sys.argv[1])
reader = md.get_reader().get_buffered_reader()
mods = [(m.baseaddress, m.size, m.name) for m in md.modules.modules]
def mod_of(addr):
for b, s, n in mods:
if b <= addr < b + s:
return n.split("\\")[-1]
return None
# Image-region ranges
image_ranges = []
for r in md.memory_info.infos:
st, ty = _ei(r.State), _ei(r.Type)
if st == 0x1000 and ty == 0x1000000:
image_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize, _ei(r.Protect) & 0xff))
image_ranges.sort()
# exec image ranges (function pointers should point into these)
exec_ranges = [(lo, hi) for lo, hi, pr in image_ranges if pr in (0x20, 0x40)]
def is_exec(addr):
for lo, hi in exec_ranges:
if lo <= addr < hi: return True
if addr < lo: return False
return False
# Scan readable image ranges for vtables
scan_ranges = [(lo, hi) for lo, hi, pr in image_ranges if pr in (0x02, 0x04, 0x40)]
# vtable_addr -> list of (slot_idx, slot_val)
vtables = {} # only those with at least 1 no-op slot
for lo, hi in scan_ranges:
size = hi - lo
try:
reader.move(lo)
buf = reader.read(size)
except Exception:
continue
if not buf: continue
n = len(buf) // 4
# Sliding: find runs where DWORD[i] is exec-image pointer.
# Treat each such run >= MIN_VTABLE_SLOTS as a potential vtable.
i = 0
while i < n:
slot0 = struct.unpack_from("<I", buf, i*4)[0]
if not is_exec(slot0):
i += 1
continue
j = i
slots = []
while j < n:
v = struct.unpack_from("<I", buf, j*4)[0]
if not is_exec(v):
break
slots.append(v)
j += 1
if len(slots) >= MIN_VTABLE_SLOTS:
noop_slots = [(k, v) for k, v in enumerate(slots) if v == NOOP_STUB]
if noop_slots:
vt_addr = lo + i*4
vtables[vt_addr] = (slots, noop_slots)
i = j if len(slots) >= MIN_VTABLE_SLOTS else i + 1
print(f"vtables with at least one no-op-stub slot: {len(vtables)}")
# Rank by no-op slot count
ranked = sorted(vtables.items(), key=lambda x: len(x[1][1]), reverse=True)
print()
print("=== Top vtables by no-op-slot count ===")
print(f"{'vtable':<12} {'#slots':>6} {'#noop':>6} noop_slot_indices")
for vt, (slots, noops) in ranked[:50]:
idxs = ", ".join(str(k) for k, _ in noops)
print(f"0x{vt:08x} {len(slots):>6} {len(noops):>6} [{idxs}]")
print()
print(f"=== Total no-op-stub references across all vtables ===")
total_noop = sum(len(noops) for _, (_, noops) in ranked)
print(f"{total_noop} total no-op slot references across {len(vtables)} vtables")
# Also save full list
out = sys.argv[2] if len(sys.argv) > 2 else None
if out:
with open(out, "w", encoding="utf8") as f:
f.write(f"vtable\tn_slots\tn_noop\tnoop_indices\n")
for vt, (slots, noops) in ranked:
idxs = ",".join(str(k) for k, _ in noops)
f.write(f"0x{vt:08x}\t{len(slots)}\t{len(noops)}\t{idxs}\n")
print(f"full list written to {out}")
if __name__ == "__main__":
main()