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

163 lines
5.9 KiB
Python

"""classify_0x0079385c_v2.py <pid>
V2 of the classifier. Two new approaches:
1. Test the "CObjCell shell at +0x30 and +0x54" hypothesis: for each hit,
check if there's ALSO a 0x0079385c marker exactly 0x24 (36) bytes away
(i.e. the OTHER offset of the same CObjCell). If yes → likely a real
CObjCell shell pair. Count those.
2. Check immediate-neighborhood context. A "real leaked object" looks like:
- Object head at some 8/16-byte-aligned address
- First DWORD is a vtable pointer in .rdata range
- Most of object is zeros or sensible field values
A "compiler-baked constant" looks like:
- Surrounded by code/anim data, not separable as an object
- May appear right after a function pointer (in a vtable construction)
or in a const-data array
Approach: for each hit, look at the 16 bytes BEFORE the hit. If the
preceding DWORDs contain ANY value in the executable-range
0x00400000-0x00700000 (which would be CODE pointer), this is likely an
embedded constant in compiled data, not a runtime object field.
Real heap object fields would not have code-range pointers RIGHT
before the marker.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
TARGET = 0x0079385c
# Code is in .text typically 0x00401000 - 0x006xxxxx
CODE_LO = 0x00401000
CODE_HI = 0x006d0000
# Read-only data (.rdata) typically follows .text
RDATA_LO = 0x006d0000
RDATA_HI = 0x008c0000
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)
regions = []
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
target_bytes = struct.pack("<I", TARGET)
# Stats
total_hits = 0
n_pair_at_24 = 0 # has a paired 0x0079385c at +/- 0x24 (CObjCell layout hint)
n_solo = 0 # alone
prev_dword_kind = Counter() # what's the DWORD immediately before the hit
prev16_has_code = 0 # has a code-range pointer in the prior 16 bytes (suggests baked-in)
prev16_all_zero = 0 # surrounded by zeros (suggests cleared-but-not-freed object field)
# Per-region density: hits-per-region
region_hit_counter = []
for base, data, rsize in regions:
hits_here = []
pos = 0
while True:
idx = data.find(target_bytes, pos)
if idx < 0: break
if idx % 4 != 0:
pos = idx + 1
continue
hits_here.append(idx)
pos = idx + 4
if not hits_here:
continue
region_hit_counter.append((base, rsize, len(hits_here)))
hits_set = set(hits_here)
for idx in hits_here:
total_hits += 1
# CObjCell pair check
if (idx + 0x24) in hits_set or (idx - 0x24) in hits_set:
n_pair_at_24 += 1
else:
n_solo += 1
# Immediate prior DWORD class
if idx >= 4:
prev = struct.unpack_from("<I", data, idx - 4)[0]
if prev == 0:
prev_dword_kind["zero"] += 1
elif CODE_LO <= prev < CODE_HI:
prev_dword_kind["code_ptr"] += 1
elif RDATA_LO <= prev < RDATA_HI:
prev_dword_kind["rdata_ptr"] += 1
elif 0x01000000 <= prev < 0x80000000:
prev_dword_kind["heap_ptr"] += 1
elif prev == TARGET:
prev_dword_kind["self_marker"] += 1
else:
prev_dword_kind["scalar/other"] += 1
# 16-byte window before
if idx >= 16:
window = data[idx-16:idx]
wd = struct.unpack("<IIII", window)
if all(d == 0 for d in wd):
prev16_all_zero += 1
if any(CODE_LO <= d < CODE_HI for d in wd):
prev16_has_code += 1
print(f"Total 0x{TARGET:08x} hits: {total_hits}")
print(f" Paired at +/-0x24 (CObjCell layout candidate): {n_pair_at_24}")
print(f" Solo: {n_solo}")
print()
print("Immediate prior DWORD classification:")
for k_, v in prev_dword_kind.most_common():
print(f" {k_:<15} {v:>7}")
print()
print(f"Prior 16 bytes all zero (likely object field): {prev16_all_zero}")
print(f"Prior 16 bytes has code-ptr (likely baked data): {prev16_has_code}")
print()
print("=== Top 15 regions by hit count ===")
region_hit_counter.sort(key=lambda x: -x[2])
for base, rsize, c in region_hit_counter[:15]:
print(f" base=0x{base:08x} size={rsize/1024:>8.1f}KB hits={c:>5} hits/KB={c*1024/rsize:.2f}")