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>
169 lines
6.2 KiB
Python
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}")
|