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

169 lines
6.2 KiB
Python

"""classify_0x0079385c_hits.py <pid>
For each occurrence of the DWORD 0x0079385c in private RW memory of <pid>:
- Record the absolute address of the hit
- Walk BACKWARDS 16-byte aligned, looking for a plausible vtable pointer
in the 0x00400000-0x00900000 (acclient .rdata) range with a small
nonzero offset distance (<= 0x400 bytes). That's the object's start
and offset-of-the-marker.
- Group hits by:
* offset-of-marker (e.g. +0x30, +0x54 confirms CObjCell)
* the head vtable found (what class the object actually is)
- Then independently bucket by REGION size of the containing
VirtualAlloc region (informational, not allocation size).
Output:
* Total hits
* Offset histogram (top 10)
* Head-vtable histogram (top 10) — includes vtable address + count
* Sample hex dumps (first 64 bytes) for top 3 head-vtable groups
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
from collections import Counter, defaultdict
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
TARGET = 0x0079385c
# acclient.exe is loaded around 0x00400000 with .rdata vtables typically
# in the 0x00700000-0x00880000 range. Accept slightly wider for safety.
VTABLE_LO = 0x00400000
VTABLE_HI = 0x00900000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
# Pass 1: enumerate all readable private RW regions; remember snapshots in memory
# so we can back-scan WITHOUT extra remote reads.
regions = [] # list of (base, data:bytes, region_size)
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
regions.append((int(mbi.BaseAddress), bytes(buf[:sz.value]), int(mbi.RegionSize)))
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
total_hits = 0
offset_hist = Counter() # offset from inferred object head
head_vt_hist = Counter() # vtable found at inferred object head
region_size_hist = Counter()
head_vt_samples = defaultdict(list) # vt -> list of (addr, first 64 bytes)
# Bucket region sizes for histogram
def bucket(sz):
if sz < 1024: return "<1KB"
if sz < 4*1024: return "1-4KB"
if sz < 64*1024: return "4-64KB"
if sz < 256*1024: return "64-256KB"
if sz < 512*1024: return "256-512KB"
if sz < 1024*1024: return "512KB-1MB"
return ">=1MB"
# For each hit, search backward for a vtable head.
# Walk back up to 0x400 bytes (CObjCell-class size guess), aligned to 4.
MAX_BACKSCAN = 0x400
for base, data, rsize in regions:
end = (len(data) // 4) * 4
# Find all DWORD positions equal to TARGET
# struct.iter_unpack is fast enough
pos = 0
target_bytes = struct.pack("<I", TARGET)
while True:
idx = data.find(target_bytes, pos)
if idx < 0: break
if idx % 4 != 0:
pos = idx + 1
continue
total_hits += 1
region_size_hist[bucket(rsize)] += 1
hit_addr = base + idx
# Back-scan: try each 4-byte aligned offset 0, -4, -8, ... up to MAX_BACKSCAN
found_vt = None
found_off = None
for back in range(0, MAX_BACKSCAN + 4, 4):
probe = idx - back
if probe < 0: break
v = struct.unpack_from("<I", data, probe)[0]
if VTABLE_LO <= v < VTABLE_HI and v != TARGET:
# Heuristic: this is likely the head vtable.
# The first plausible one we find (smallest back-distance) wins.
found_vt = v
found_off = back
break
if found_vt is not None:
offset_hist[found_off] += 1
head_vt_hist[found_vt] += 1
if len(head_vt_samples[found_vt]) < 3:
head_addr = hit_addr - found_off
head_idx = idx - found_off
snippet = data[head_idx:head_idx + 64]
head_vt_samples[found_vt].append((head_addr, snippet))
else:
offset_hist[-1] += 1 # marker for "no head found"
pos = idx + 4
print(f"PID={args.pid} scanned {len(regions)} private RW regions")
print(f"Total 0x{TARGET:08x} hits: {total_hits}\n")
print("=== Region-size histogram (where the hit lives) ===")
for b, c in region_size_hist.most_common():
print(f" {b:>10} {c:>7}")
print()
print("=== Offset of marker from inferred object head (top 15) ===")
for off, c in offset_hist.most_common(15):
if off == -1:
print(f" (no head found) {c:>7}")
else:
print(f" +0x{off:04x} {c:>7}")
print()
print("=== Head vtable histogram (top 15) ===")
for vt, c in head_vt_hist.most_common(15):
print(f" 0x{vt:08x} {c:>7}")
print()
print("=== Sample first-64-byte dumps for top 3 head vtables ===")
for vt, _c in head_vt_hist.most_common(3):
print(f"\n--- head vtable 0x{vt:08x} ---")
for ha, snip in head_vt_samples[vt]:
hexs = " ".join(f"{b:02x}" for b in snip)
print(f" at 0x{ha:08x}: {hexs}")