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

166 lines
5.8 KiB
Python

"""scan_gmui_subclasses.py <dump.dmp>
Scan for all aligned dwords equal to 0x007ccb60 (NoticeHandler vtable at
offset 0x5F8 of every gm*UI instance). For each hit at address H, read
the OUTER instance vtable at (H - 0x5F8) — that's the instance's primary
vtable at offset 0. Histogram by outer vtable.
Also dump the SECONDARY vtables at well-known offsets to help disambiguate
subclasses (since gm*UI uses multiple inheritance).
"""
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)
NOTICE_VT = 0x007ccb60
NOTICE_OFF = 0x5F8
def main():
md = MinidumpFile.parse(sys.argv[1])
reader = md.get_reader().get_buffered_reader()
# Module map
mods = []
for m in md.modules.modules:
mods.append((m.baseaddress, m.size, m.name))
def mod_of(addr):
for b, s, n in mods:
if b <= addr < b + s:
return n.split("\\")[-1]
return None
# Image-region ranges (for vtable validation)
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
# Scan all committed RW non-image regions for the NoticeHandler vtable value
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))
total_bytes = sum(s for _, s in scan_regions)
print(f"scanning {len(scan_regions)} writable non-image regions ({total_bytes/(1024*1024):.1f} MB)")
# All hit addresses (where the NoticeHandler vtable value appears)
hit_addrs = []
region_buffers = {} # base -> (size, bytes)
for base, size in scan_regions:
try:
reader.move(base)
buf = reader.read(size)
except Exception:
continue
if not buf: continue
region_buffers[base] = (size, buf)
end = (len(buf) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", buf, off)[0]
if v == NOTICE_VT:
hit_addrs.append(base + off)
print(f"raw NoticeHandler-vtable hits: {len(hit_addrs)}")
# For each hit, derive instance base = hit - 0x5F8 and try to read the
# outer vtable at instance_base + 0. Histogram outer vtable values.
def read_dword(va):
for base, (size, buf) in region_buffers.items():
if base <= va < base + size:
off = va - base
if off + 4 <= len(buf):
return struct.unpack_from("<I", buf, off)[0]
return None
return None
outer_vt_hist = Counter()
instances = [] # (instance_base, outer_vt)
not_in_rw = 0
not_image_vt = 0
for h in hit_addrs:
inst = h - NOTICE_OFF
outer = read_dword(inst)
if outer is None:
not_in_rw += 1
continue
if not is_image(outer):
not_image_vt += 1
continue
outer_vt_hist[outer] += 1
instances.append((inst, outer))
print(f"valid gm*UI instances (outer vtable is in image): {len(instances)}")
print(f" hits where instance base not in RW region: {not_in_rw}")
print(f" hits where outer vtable is non-image (false positive?): {not_image_vt}")
print()
print("=== Outer vtable histogram (top 15) ===")
for vt, cnt in outer_vt_hist.most_common(15):
owner = mod_of(vt) or "?"
print(f" 0x{vt:08x} count={cnt:<5} ({owner})")
# For each top outer vtable, sample a few instances and dump all
# image-pointer values within the instance (to find secondary vtables).
print()
print("=== Secondary vtable scan (top outer vtables) ===")
# Group instances by outer vtable
by_outer = defaultdict(list)
for inst, outer in instances:
by_outer[outer].append(inst)
SCAN_SIZE = 0x600 # scan 1.5KB of instance (covers up to NoticeHandler @ 0x5F8)
for outer, _ in outer_vt_hist.most_common(8):
insts = by_outer[outer]
print(f"\n-- outer vtable 0x{outer:08x} ({len(insts)} instances) --")
# For each offset in the instance, count how many instances have an
# image-pointer at that offset, and what the most common value is.
offset_value_hist = defaultdict(Counter)
sample_n = min(50, len(insts))
for inst in insts[:sample_n]:
for off in range(0, SCAN_SIZE, 4):
v = read_dword(inst + off)
if v is None: continue
if is_image(v):
offset_value_hist[off][v] += 1
# Show offsets where the value is consistent across most samples
consistent_offsets = []
for off, vh in offset_value_hist.items():
top_v, top_c = vh.most_common(1)[0]
if top_c >= sample_n * 0.7: # 70% agreement
consistent_offsets.append((off, top_v, top_c, len(vh)))
consistent_offsets.sort()
print(f" consistent image-pointers (>=70% of {sample_n} samples):")
for off, val, cnt, unique in consistent_offsets:
tag = ""
if off == 0: tag = " [PRIMARY VTABLE]"
elif off == NOTICE_OFF: tag = " [NoticeHandler]"
print(f" +0x{off:04x} val=0x{val:08x} ({cnt}/{sample_n}, {unique} unique){tag}")
if __name__ == "__main__":
main()