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

172 lines
7.3 KiB
Python

"""manual_purge.py <pid>
Force a UIItem pool drain by calling UpdateEmptySlots on every
UIElement_ItemList instance in the target process. Bypasses any
visibility-trigger questions — just calls the trim function directly
via CreateRemoteThread.
Requires v8-minimal applied (otherwise UpdateEmptySlots bails on the
internal IsVisible guard).
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
UPDATEEMPTYSLOTS_VA = 0x004e4390
# Heuristic: ItemList primary vtable — derive from xrefs.
# For our test, we scan heap for ItemList vtable matches.
# Actually we don't have the ItemList vtable VA — we need to find ItemLists
# differently. Best: scan for UIItem container pattern (this->+0x608 valid).
PROCESS_VM_READ = 0x10
PROCESS_VM_WRITE = 0x20
PROCESS_VM_OPERATION = 0x8
PROCESS_QUERY_INFORMATION = 0x400
PROCESS_CREATE_THREAD = 0x2
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
PAGE_EXECUTE_READWRITE = 0x40
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.WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.WriteProcessMemory.restype = wt.BOOL
k.VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
k.VirtualAllocEx.restype = wt.LPVOID
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
k.CreateRemoteThread.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t,
ctypes.c_void_p, ctypes.c_void_p, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
k.CreateRemoteThread.restype = wt.HANDLE
k.WaitForSingleObject = k.WaitForSingleObject
k.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
k.WaitForSingleObject.restype = wt.DWORD
def find_itemlist_instances(h):
"""Scan heap for objects whose first DWORD points to a code address
AND whose +0x610 looks like a small positive int (count) AND whose
+0x608 points to a valid memory address (the items array).
Returns list of candidate addresses."""
results = []
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):
try:
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])
# Walk DWORD-aligned, check pattern at each potential instance
end = len(data) - 0x614
for off in range(0, end, 4):
# vtable check: first DWORD points into image
vt = struct.unpack_from("<I", data, off)[0]
if vt < 0x00400000 or vt > 0x00800000:
continue
# +0x610 count check: small positive int
cnt = struct.unpack_from("<I", data, off + 0x610)[0]
if cnt == 0 or cnt > 2000:
continue
# +0x608 array pointer check: into private heap
arr = struct.unpack_from("<I", data, off + 0x608)[0]
if arr < 0x00400000 or arr > 0x80000000:
continue
results.append(mbi.BaseAddress + off)
except Exception:
pass
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
return results
def call_remote(h, target_va, ecx_arg):
"""Allocate a tiny stub that loads ECX and calls target_va, then RETs.
CreateRemoteThread runs the stub. ecx_arg = the this pointer."""
# Stub: mov ecx, ecx_arg; mov eax, target_va; call eax; xor eax, eax; ret 4
# Thread proc signature is DWORD WINAPI(LPVOID arg) — arg comes in as [esp+4]
# We'll use the arg passed by CreateRemoteThread as the this ptr.
stub = bytes([
0x8b, 0x4c, 0x24, 0x04, # mov ecx, [esp+4] ; this
0xb8,
target_va & 0xff, (target_va >> 8) & 0xff,
(target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va
0xff, 0xd0, # call eax
0x33, 0xc0, # xor eax, eax
0xc2, 0x04, 0x00, # ret 4
])
page = k.VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not page:
raise OSError(f"VirtualAllocEx failed err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
if not k.WriteProcessMemory(h, page, stub, len(stub), ctypes.byref(sz)):
raise OSError(f"WriteProcessMemory stub failed")
tid = wt.DWORD(0)
th = k.CreateRemoteThread(h, None, 0, page, ctypes.c_void_p(ecx_arg), 0, ctypes.byref(tid))
if not th:
raise OSError(f"CreateRemoteThread failed err={ctypes.get_last_error()}")
k.WaitForSingleObject(th, 5000) # 5 sec timeout
k.CloseHandle(th)
# Note: leaving stub page allocated (would need to free after)
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--scan-only", action="store_true",
help="just print candidate ItemList instances, don't call drain")
ap.add_argument("--limit", type=int, default=999,
help="max instances to drain (default 999)")
args = ap.parse_args()
h = k.OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION | PROCESS_CREATE_THREAD,
False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
print(f"Scanning PID {args.pid} for ItemList candidates...")
candidates = find_itemlist_instances(h)
print(f"Found {len(candidates)} candidates")
for c in candidates[:20]:
print(f" 0x{c:08x}")
if len(candidates) > 20:
print(f" ... +{len(candidates)-20} more")
if args.scan_only:
sys.exit(0)
if not candidates:
print("No ItemList candidates found")
sys.exit(0)
print(f"\nCalling UpdateEmptySlots on first {min(args.limit, len(candidates))} candidates...")
for i, c in enumerate(candidates[:args.limit]):
try:
call_remote(h, UPDATEEMPTYSLOTS_VA, c)
print(f" [{i+1}/{min(args.limit, len(candidates))}] called on 0x{c:08x}")
except Exception as e:
print(f" [{i+1}] FAILED on 0x{c:08x}: {e}")
break
print("\nDone. Recount with count_uiitem_live.py to see effect.")
k.CloseHandle(h)