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>
172 lines
7.3 KiB
Python
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)
|