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

117 lines
4.3 KiB
Python

"""
scan_vtables.py <dump.dmp> [--size-min KB] [--size-max KB]
For every private RW region in [size-min, size-max] (default 256-512 KB),
read the first 0x100 bytes and scan for DWORDs that look like vtable
pointers (addresses pointing into acclient.exe's code/data range).
Outputs a histogram of (vtable RVA -> count of regions where it appeared
at any of the first 64 DWORD positions). The top entries are candidate
leaking classes.
Why this works without symbols:
* 32-bit C++ objects start with a vtable pointer at offset 0.
* The NT heap large-block path wraps a small header around the user
data, so the vtable shows up somewhere in the first 0x80 bytes.
* If thousands of leaked regions all carry the same vtable, it is
the same class — the RVA can be looked up in the EoR binary
(relative to acclient.exe base 0x00400000 typically) for an AOB
signature later.
"""
import argparse
import os
import sys
from collections import Counter
from minidump.minidumpfile import MinidumpFile
def _enum_int(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dump")
ap.add_argument("--size-min-kb", type=int, default=256)
ap.add_argument("--size-max-kb", type=int, default=512)
ap.add_argument("--scan-bytes", type=int, default=0x100,
help="bytes from region start to scan for vtables")
ap.add_argument("--top", type=int, default=20)
args = ap.parse_args()
md = MinidumpFile.parse(args.dump)
# Find acclient.exe base + size
acl = None
for m in md.modules.modules:
if os.path.basename(m.name).lower() == "acclient.exe":
acl = m
break
if acl is None:
print("acclient.exe not in module list", file=sys.stderr); sys.exit(1)
acl_lo = acl.baseaddress
acl_hi = acl.baseaddress + acl.size
print(f"acclient.exe: 0x{acl_lo:08x} - 0x{acl_hi:08x} ({acl.size/1024:.1f} KB)")
# Build a reader. minidump has a memory reader.
reader = md.get_reader().get_buffered_reader()
# Iterate candidate regions
lo_b = args.size_min_kb * 1024
hi_b = args.size_max_kb * 1024
cands = []
for r in md.memory_info.infos:
st = _enum_int(r.State)
ty = _enum_int(r.Type)
pr = _enum_int(r.Protect) & 0xFF
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
and lo_b <= r.RegionSize < hi_b:
cands.append((r.BaseAddress, r.RegionSize))
print(f"candidate regions ({args.size_min_kb}-{args.size_max_kb}KB private RW): {len(cands)}")
# For each region: read first scan_bytes, scan as DWORDs
hits = Counter() # vtable_rva -> region_count
region_hits = Counter() # how many regions had ANY hit
per_region_seen = []
total_dword_hits = 0
for base, size in cands:
try:
reader.move(base)
buf = reader.read(args.scan_bytes)
except Exception:
continue
if not buf:
continue
seen_here = set()
for off in range(0, min(len(buf), args.scan_bytes), 4):
dw = int.from_bytes(buf[off:off+4], "little", signed=False)
if acl_lo <= dw < acl_hi:
rva = dw - acl_lo
# An object's vtable typically sits in .rdata so RVAs are
# mid-image. We don't filter further here; let the histogram
# speak.
seen_here.add(rva)
total_dword_hits += 1
for rva in seen_here:
hits[rva] += 1
if seen_here:
region_hits[len(seen_here)] += 1
per_region_seen.append(len(seen_here))
print(f"regions with at least one acclient-pointer in first {args.scan_bytes:#x} bytes: {sum(region_hits.values())}")
print(f"distribution of pointer-counts per region:")
for n in sorted(region_hits):
print(f" {n} pointer(s) in region: {region_hits[n]} regions")
print()
print(f"top {args.top} vtable-candidate RVAs (regions sharing this acclient pointer in first {args.scan_bytes:#x} bytes):")
print(f" {'rva':>10} {'abs':>10} {'count':>6}")
for rva, count in hits.most_common(args.top):
print(f" 0x{rva:08x} 0x{acl_lo+rva:08x} {count:>6}")
if __name__ == "__main__":
main()