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>
This commit is contained in:
commit
57b5e43d0e
199 changed files with 1648333 additions and 0 deletions
144
tools/audit_position_hash.py
Normal file
144
tools/audit_position_hash.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""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])))
|
||||
Loading…
Add table
Add a link
Reference in a new issue