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>
165 lines
6.4 KiB
Python
165 lines
6.4 KiB
Python
"""
|
|
owner_vtable_scan.py <dump.dmp>
|
|
|
|
Goal: identify which class owns the leaked 256-512KB buffers.
|
|
|
|
Method:
|
|
1. Enumerate leaked 256-512KB Private RW regions (the "leak set").
|
|
2. Build a set of candidate "pointer-to-buffer" values:
|
|
region_base + delta for delta in {0, 8, 0x10, 0x18, 0x20, 0x30, 0x40}
|
|
(covers different heap-header sizes incl. +ust, +hpa).
|
|
3. Scan ALL committed RW memory for any DWORD whose value is in that
|
|
candidate set. For each hit, the containing word at offset
|
|
(hit_addr - field_offset) might be a field inside some object.
|
|
4. For each hit, look BACKWARDS within the same heap entry for a vtable
|
|
(a DWORD pointing into image memory, typically rdata). The first
|
|
valid vtable found is the owner-object's vtable.
|
|
5. Histogram by (owner_vtable, field_offset). The top entries reveal
|
|
which class+field owns the leaked buffer.
|
|
|
|
Output: top vtable hits with their image-module attribution.
|
|
"""
|
|
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 main():
|
|
md = MinidumpFile.parse(sys.argv[1])
|
|
reader = md.get_reader().get_buffered_reader()
|
|
|
|
# Module map -> attribute vtable addresses
|
|
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
|
|
|
|
# Leaked 256-512KB regions
|
|
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))
|
|
print(f"leaked 256-512KB private RW regions: {len(leaked)}")
|
|
|
|
# Build candidate "pointer values" set
|
|
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
|
|
print(f"candidate pointer values: {len(cand_to_region)} (across {len(deltas)} deltas)")
|
|
|
|
# Scan all committed RW regions
|
|
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 # skip Image
|
|
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)")
|
|
|
|
# Build a per-region buffer cache so we can do "lookback within same region"
|
|
hits = [] # list of (hit_va, region_base_of_leaked_buf, value_pointed_at)
|
|
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))
|
|
print(f"raw pointer-into-leaked hits: {len(hits)}")
|
|
|
|
if not hits:
|
|
print("no hits — leaked buffers are orphaned (no live pointers to them).")
|
|
return
|
|
|
|
# For each hit, walk backwards within the same buffer up to N words looking
|
|
# for a DWORD that is in image memory and aligned (vtable candidate).
|
|
# Treat the hit as a "field at offset (off - vtbl_off) inside an object".
|
|
LOOKBACK_BYTES = 0x200 # 512 bytes back
|
|
|
|
vtable_hits = Counter() # (vtable, field_offset) -> count
|
|
vtable_only_hits = Counter() # vtable -> count
|
|
field_offsets_per_vtable = defaultdict(Counter)
|
|
examples = defaultdict(list)
|
|
no_vtable = 0
|
|
|
|
for hit_va, leaked_base, ptr_val, reg_base, off, buf in hits:
|
|
start = max(0, off - LOOKBACK_BYTES)
|
|
# Walk backwards in 4-byte steps from (off - 4) down to start
|
|
found = False
|
|
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 = v
|
|
field_off = off - back
|
|
vtable_hits[(vtable, field_off)] += 1
|
|
vtable_only_hits[vtable] += 1
|
|
field_offsets_per_vtable[vtable][field_off] += 1
|
|
if len(examples[(vtable, field_off)]) < 3:
|
|
examples[(vtable, field_off)].append((hit_va, leaked_base, ptr_val))
|
|
found = True
|
|
break
|
|
if not found:
|
|
no_vtable += 1
|
|
|
|
print(f"hits with no preceding vtable in 0x200 lookback: {no_vtable}")
|
|
print(f"unique (vtable, field_off) pairs: {len(vtable_hits)}")
|
|
print(f"unique vtables: {len(vtable_only_hits)}")
|
|
print()
|
|
|
|
print("=== Top vtables (regardless of field offset) ===")
|
|
for vtbl, cnt in vtable_only_hits.most_common(25):
|
|
owner = mod_of(vtbl) or "?"
|
|
# Show the top field offsets seen for this vtable
|
|
top_offs = field_offsets_per_vtable[vtbl].most_common(4)
|
|
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
|
|
print(f" 0x{vtbl:08x} count={cnt:<5} ({owner}) offsets: {offs_str}")
|
|
|
|
print()
|
|
print("=== Top (vtable, field_offset) pairs ===")
|
|
for (vtbl, off), cnt in vtable_hits.most_common(25):
|
|
owner = mod_of(vtbl) or "?"
|
|
ex = examples[(vtbl, off)][0]
|
|
print(f" 0x{vtbl:08x} +0x{off:03x} count={cnt:<5} ({owner}) e.g. hit@0x{ex[0]:08x} -> leaked@0x{ex[1]:08x} val=0x{ex[2]:08x}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|