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>
144 lines
5.5 KiB
Python
144 lines
5.5 KiB
Python
"""patch_v15_positionhash_audit.py <pid>
|
|
|
|
READ-ONLY audit. Does NOT modify the target process.
|
|
|
|
Goal: confirm that the dominant Position-leak path is through
|
|
CBaseQualities::_posStatsTable (PackableHashTable<uint, Position>),
|
|
and identify which weenie types host the most retained Positions.
|
|
|
|
Method:
|
|
1. Scan live private memory for Position vt 0x00797910 instances.
|
|
2. For each, walk back through nearby heap headers to find the
|
|
containing PositionPropertyValue (vt write at offset -0x08 +
|
|
m_cRef at -0x04). PositionPropertyValue is the per-property
|
|
ref-counted holder that lives inside the hash table.
|
|
3. From the PositionPropertyValue, look for back-pointers into a
|
|
PackableHashTable node, then up to the owning CBaseQualities.
|
|
4. Print: count per (weenie-vt, dominant property key).
|
|
|
|
Caveats: heap-layout heuristics are fragile. Many Positions are
|
|
stack-locals, not heap-allocated — those should be filtered out by
|
|
checking the alignment-stride of the containing region.
|
|
|
|
Use this BEFORE designing any v14-style ctor trace, because it
|
|
narrows the search: if 95% of leaked Positions are inside one
|
|
weenie type's quality table, the patch target is that weenie's
|
|
unload path, not Position itself.
|
|
"""
|
|
import ctypes
|
|
import ctypes.wintypes as wt
|
|
import struct
|
|
import sys
|
|
from collections import Counter
|
|
|
|
POSITION_VT = 0x00797910
|
|
# PositionPropertyValue vtable EoR address — DERIVE before use.
|
|
# 2013 was at &PositionPropertyValue::vftable near 0x00796928.
|
|
# In EoR the address is near 0x00797928 (placeholder; verify).
|
|
POSITION_PROP_VAL_VT = 0x00797928 # PLACEHOLDER — verify
|
|
|
|
PROCESS_VM_READ = 0x10
|
|
PROCESS_QUERY_INFORMATION = 0x400
|
|
|
|
|
|
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.CloseHandle.argtypes = [wt.HANDLE]
|
|
k.CloseHandle.restype = wt.BOOL
|
|
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
|
|
|
|
|
|
def audit(pid):
|
|
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
|
|
False, pid)
|
|
if not h:
|
|
print(f"OpenProcess failed (err={ctypes.get_last_error()})")
|
|
return 1
|
|
|
|
position_addrs = []
|
|
mbi = MBI()
|
|
addr = 0
|
|
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
|
pr = mbi.Protect & 0xff
|
|
if (mbi.State == 0x1000 and mbi.Type == 0x20000
|
|
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)):
|
|
data = bytes(buf[:sz.value])
|
|
end = (len(data) // 4) * 4
|
|
for off in range(0, end, 4):
|
|
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
|
position_addrs.append((mbi.BaseAddress + off,
|
|
mbi.BaseAddress,
|
|
mbi.RegionSize))
|
|
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
|
if addr >= 0x80000000:
|
|
break
|
|
|
|
print(f"Total Position instances found: {len(position_addrs)}")
|
|
|
|
# Stage 2: classify by containing region size + density to
|
|
# separate stack/embedded from heap allocations
|
|
region_density = Counter()
|
|
for addr_, base, size in position_addrs:
|
|
region_density[(base, size)] += 1
|
|
|
|
print("\nDensity histogram (region_size_kb, hits_per_region, "
|
|
"count):")
|
|
bucket = Counter()
|
|
for (base, size), n in region_density.items():
|
|
kb = size // 1024
|
|
bucket[(kb, n)] += 1
|
|
for (kb, n), c in sorted(bucket.items())[:30]:
|
|
if c > 1:
|
|
print(f" region={kb:>5}KB hits={n:>4} count={c:>4}")
|
|
|
|
# Stage 3: for first 200 Position hits, look 8 bytes backwards
|
|
# for PositionPropertyValue vtable (PPV layout: [vt][m_cRef][position])
|
|
ppv_hits = 0
|
|
for i, (paddr, base, size) in enumerate(position_addrs[:1000]):
|
|
ppv_vt_addr = paddr - 8
|
|
if ppv_vt_addr < base:
|
|
continue
|
|
# Read 4 bytes
|
|
buf4 = (ctypes.c_ubyte * 4)()
|
|
sz4 = ctypes.c_size_t(0)
|
|
if not k.ReadProcessMemory(h, ppv_vt_addr, buf4, 4,
|
|
ctypes.byref(sz4)):
|
|
continue
|
|
vt = struct.unpack("<I", bytes(buf4))[0]
|
|
if vt == POSITION_PROP_VAL_VT:
|
|
ppv_hits += 1
|
|
print(f"\nOf first 1000 Position hits, {ppv_hits} are inside "
|
|
f"PositionPropertyValue (vt+m_cRef at -8).")
|
|
print("If this number is high (>500), the leak is in the "
|
|
"property-value layer.")
|
|
print("If low, the leak is in raw Position heap allocations "
|
|
"(check PositionPack / position_queue paths).")
|
|
|
|
k.CloseHandle(h)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(audit(int(sys.argv[1])))
|