leakhunt/tools/diff_owner_scans.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

162 lines
5.6 KiB
Python

"""diff_owner_scans.py <low.dmp> <high.dmp>
Run the owner-vtable scan internally on two dumps and emit a diff:
which vtables show up disproportionately in the high-leak dump
versus the low-leak baseline. The leaders are residual-leak suspects.
Output is ranked by ratio (high_count / low_count) with min thresholds
to filter noise.
"""
import struct, sys
from collections import Counter, defaultdict
from minidump.minidumpfile import MinidumpFile
def _ei(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def scan(path):
md = MinidumpFile.parse(path)
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_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))
image_ranges.sort()
def is_image(addr):
for lo, hi in image_ranges:
if lo <= addr < hi:
return True
if addr < lo:
return False
return False
leaked = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
and 256*1024 <= r.RegionSize < 512*1024:
leaked.append((r.BaseAddress, r.RegionSize))
deltas = [0, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x40, 0x50, 0x60]
cand_to_region = {}
for base, _sz in leaked:
for d in deltas:
cand_to_region[base + d] = base
scan_regions = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000: continue
if ty == 0x1000000: continue
if pr not in (0x04, 0x40): continue
scan_regions.append((r.BaseAddress, r.RegionSize))
hits = []
for base, size in scan_regions:
try:
reader.move(base)
buf = reader.read(size)
except Exception:
continue
if not buf: continue
end = (len(buf) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", buf, off)[0]
if v in cand_to_region:
hits.append((base + off, cand_to_region[v], v, base, off, buf))
LOOKBACK = 0x200
vtable_only = Counter()
field_per_vt = defaultdict(Counter)
for hit_va, _leak_base, _ptr_val, _reg_base, off, buf in hits:
start = max(0, off - LOOKBACK)
for back in range(off - 4, start - 4, -4):
if back < 0: break
v = struct.unpack_from("<I", buf, back)[0]
if v < 0x00400000 or v > 0x10000000:
continue
if is_image(v):
vtable_only[v] += 1
field_per_vt[v][off - back] += 1
break
return {
"leaked_regions": len(leaked),
"vtable_counts": vtable_only,
"field_per_vt": field_per_vt,
"mod_of": mod_of,
}
def main():
low_path, high_path = sys.argv[1], sys.argv[2]
print(f"Scanning low-leak control: {low_path}")
low = scan(low_path)
print(f" leaked regions: {low['leaked_regions']}")
print(f" unique vtables: {len(low['vtable_counts'])}")
print()
print(f"Scanning high-leak target: {high_path}")
high = scan(high_path)
print(f" leaked regions: {high['leaked_regions']}")
print(f" unique vtables: {len(high['vtable_counts'])}")
print()
# Baseline scale = ratio of leaked-region counts. Anything growing
# significantly faster than this is suspect.
scale = high['leaked_regions'] / max(low['leaked_regions'], 1)
print(f"Baseline scale (high_leaked / low_leaked): {scale:.1f}x")
print(f" vtables with ratio >> {scale:.0f}x are the residual-leak suspects")
print()
# Combine vtable counts
all_vts = set(low['vtable_counts']) | set(high['vtable_counts'])
# Rank by ratio with a minimum high-count floor so we ignore one-offs
MIN_HIGH = 20
rows = []
for vt in all_vts:
lc = low['vtable_counts'].get(vt, 0)
hc = high['vtable_counts'].get(vt, 0)
if hc < MIN_HIGH:
continue
# Avoid divide-by-zero; treat 0 baseline as huge ratio
ratio = hc / max(lc, 0.5)
delta = hc - lc
rows.append((vt, lc, hc, ratio, delta))
# Top by ratio
rows.sort(key=lambda r: r[3], reverse=True)
print(f"=== Top vtables by ratio (high/low), min high-count {MIN_HIGH} ===")
print(f"{'vtable':<12} {'low':>6} {'high':>6} {'ratio':>8} {'delta':>7} module fields")
for vt, lc, hc, ratio, delta in rows[:30]:
mod = high['mod_of'](vt) or "?"
top_offs = high['field_per_vt'][vt].most_common(3)
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
print(f"0x{vt:08x} {lc:>6} {hc:>6} {ratio:>8.1f} {delta:>7} {mod:<40} {offs_str}")
# Top by absolute delta (also informative)
print()
print(f"=== Top vtables by absolute count delta (high - low) ===")
rows.sort(key=lambda r: r[4], reverse=True)
print(f"{'vtable':<12} {'low':>6} {'high':>6} {'ratio':>8} {'delta':>7} module")
for vt, lc, hc, ratio, delta in rows[:20]:
mod = high['mod_of'](vt) or "?"
print(f"0x{vt:08x} {lc:>6} {hc:>6} {ratio:>8.1f} {delta:>7} {mod}")
if __name__ == "__main__":
main()