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
231
tools/patch_purge_v5_test.py
Normal file
231
tools/patch_purge_v5_test.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue