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>
117 lines
4.3 KiB
Python
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()
|