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

231 lines
9 KiB
Python

"""patch_purge_v5_test.py <pid> [--revert]
EXPERIMENTAL: Override RenderSurface and RenderTexture's PurgeResource
vtable slot (slot 2 = +0x08) with thunks that call the existing
Destroy() method on each class, then return true so the purge loop
properly marks the resource as cleaned up.
Background:
GraphicsResource::PurgeResource is virtual (vtable slot 2). The
base implementation is the no-op stub at 0x004154a0 (mov al, 1; ret).
D3D-specialized subclasses (RenderTextureD3D, RenderSurfaceD3D,
CSurface, ImgTex) properly override slot 2 to free their GPU
resources. But the BASE RenderSurface and RenderTexture inherit
the no-op stub. GraphicsResource::PurgeOldResources iterates
resources, calls vtable[2], and if it returns true, marks them
as "purged" with a flag (+0x08 on the resource) so they're never
retried. The no-op returns true → mark as purged → permanent leak.
Verified by:
* comparative dump diff (Time/Nyckel low-leak vs Larsson/Jerry
high-leak): GraphicsResource vtable is the top-ranked residual
leak owner with 367 instances in heavy-looter Larsson vs 0 in
low-activity Time.
* subclass scan: RenderSurface (vtable 0x0079a67c) and
RenderTexture (vtable 0x0079c198) have slot 2 = 0x004154a0
(no-op); six other GraphicsResource subclasses override.
* decompile: PurgeOldResources at EoR 0x00446dc0 calls vtable[2]
and sets resource->+0x02 = 1 on success.
Fix:
Replace slot 2 in each leaking vtable with a thunk that calls
RenderSurface::Destroy (EoR 0x00444540) / RenderTexture::Destroy
(EoR 0x0044c4f0). These methods exist and do idempotent cleanup
(delete heavy heap allocations, null pointers, reset state).
They don't tear down the C++ object - perfect for PurgeResource.
Risks:
* If a leaked RenderSurface is in some half-destroyed state where
fields +0x64 / +0x114 are dangling (not NULL but point to freed
memory), Destroy will double-free. Believed unlikely since
Destroy is idempotent and checks for NULL before delete, and
the purge loop's eligibility check (+0x6 != currentFrame) only
runs purge on resources not in use this frame.
* Same shape as v4 (thunk injection + vtable rewrite). v4 has run
without crashes on 3 clients for hours.
Thunks (10 bytes each):
Thunk A — RenderSurface PurgeResource:
B8 40 45 44 00 mov eax, 0x00444540 ; RenderSurface::Destroy
FF D0 call eax ; thiscall - this in ecx
B0 01 mov al, 1 ; return true
C3 ret
Thunk B — RenderTexture PurgeResource:
B8 F0 C4 44 00 mov eax, 0x0044c4f0 ; RenderTexture::Destroy
FF D0 call eax
B0 01 mov al, 1
C3 ret
Vtable slots patched:
RenderSurface @ 0x0079a67c + 0x08 = 0x0079a684 → thunk A addr
RenderTexture @ 0x0079c198 + 0x08 = 0x0079c1a0 → thunk B addr
Both currently point to 0x004154a0 (no-op stub).
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
# Targets
NOOP_STUB_VA = 0x004154a0
RENDERSURFACE_DESTROY_VA = 0x00444540
RENDERTEXTURE_DESTROY_VA = 0x0044c4f0
RENDERSURFACE_VTABLE_VA = 0x0079a67c
RENDERTEXTURE_VTABLE_VA = 0x0079c198
SLOT_PURGERESOURCE = 0x08
RS_SLOT_VA = RENDERSURFACE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079a684
RT_SLOT_VA = RENDERTEXTURE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079c1a0
def make_thunk(target_va: int) -> bytes:
return bytes([
0xB8, target_va & 0xff, (target_va >> 8) & 0xff,
(target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va
0xFF, 0xD0, # call eax
0xB0, 0x01, # mov al, 1
0xC3, # ret
])
THUNK_RS = make_thunk(RENDERSURFACE_DESTROY_VA)
THUNK_RT = make_thunk(RENDERTEXTURE_DESTROY_VA)
assert len(THUNK_RS) == 10
assert len(THUNK_RT) == 10
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
PAGE_EXECUTE_READWRITE = 0x40
PAGE_READWRITE = 0x04
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]
CloseHandle.restype = wt.BOOL
VirtualAllocEx = k32.VirtualAllocEx
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
VirtualAllocEx.restype = wt.LPVOID
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_dword(h, addr):
out = ctypes.c_uint(0)
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, ctypes.byref(out), 4, ctypes.byref(sz)):
raise OSError(f"ReadProcessMemory 0x{addr:08x} failed err={ctypes.get_last_error()}")
return out.value
def write_dword(h, addr, value):
"""Write a DWORD into a (typically read-only) vtable region by
flipping protection RW, writing, then restoring."""
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx RW 0x{addr:08x} failed err={ctypes.get_last_error()}")
v = ctypes.c_uint(value)
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, ctypes.byref(v), 4, ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, 4, old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"WriteProcessMemory 0x{addr:08x} failed err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true",
help="restore the no-op stub at both vtable slots")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
sys.exit(2)
rs_cur = read_dword(h, RS_SLOT_VA)
rt_cur = read_dword(h, RT_SLOT_VA)
print(f"PID {args.pid}")
print(f" RenderSurface vtable slot @ 0x{RS_SLOT_VA:08x} = 0x{rs_cur:08x}")
print(f" RenderTexture vtable slot @ 0x{RT_SLOT_VA:08x} = 0x{rt_cur:08x}")
if args.revert:
if rs_cur == NOOP_STUB_VA and rt_cur == NOOP_STUB_VA:
print(" both already no-op — nothing to revert")
CloseHandle(h); return
if rs_cur != NOOP_STUB_VA:
write_dword(h, RS_SLOT_VA, NOOP_STUB_VA)
print(f" RenderSurface slot reverted to 0x{NOOP_STUB_VA:08x}")
if rt_cur != NOOP_STUB_VA:
write_dword(h, RT_SLOT_VA, NOOP_STUB_VA)
print(f" RenderTexture slot reverted to 0x{NOOP_STUB_VA:08x}")
CloseHandle(h); return
if rs_cur != NOOP_STUB_VA or rt_cur != NOOP_STUB_VA:
print(f" UNEXPECTED — one or both slots not the no-op stub "
f"(wanted 0x{NOOP_STUB_VA:08x}). Refusing to patch.")
CloseHandle(h); sys.exit(3)
page = VirtualAllocEx(h, None, 0x80, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not page:
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}")
CloseHandle(h); sys.exit(4)
print(f" thunk page allocated @ 0x{page:08x}")
thunk_rs_addr = page
thunk_rt_addr = page + 0x20 # padded for alignment + safety
sz = ctypes.c_size_t(0)
if not WriteProcessMemory(h, thunk_rs_addr, THUNK_RS, len(THUNK_RS), ctypes.byref(sz)):
print(f"write THUNK_RS failed err={ctypes.get_last_error()}"); sys.exit(5)
if not WriteProcessMemory(h, thunk_rt_addr, THUNK_RT, len(THUNK_RT), ctypes.byref(sz)):
print(f"write THUNK_RT failed err={ctypes.get_last_error()}"); sys.exit(6)
print(f" THUNK_RS @ 0x{thunk_rs_addr:08x}: {THUNK_RS.hex()}")
print(f" THUNK_RT @ 0x{thunk_rt_addr:08x}: {THUNK_RT.hex()}")
write_dword(h, RS_SLOT_VA, thunk_rs_addr)
write_dword(h, RT_SLOT_VA, thunk_rt_addr)
rs_after = read_dword(h, RS_SLOT_VA)
rt_after = read_dword(h, RT_SLOT_VA)
print(f" RenderSurface slot now 0x{rs_after:08x} {'OK' if rs_after == thunk_rs_addr else 'MISMATCH'}")
print(f" RenderTexture slot now 0x{rt_after:08x} {'OK' if rt_after == thunk_rt_addr else 'MISMATCH'}")
CloseHandle(h)
if __name__ == "__main__":
main()