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:
acbot 2026-05-23 21:05:17 +02:00
commit 57b5e43d0e
199 changed files with 1648333 additions and 0 deletions

View file

@ -0,0 +1,160 @@
"""patch_cgfxobj_v4_test.py <pid> [--revert]
EXPERIMENTAL: Override CGfxObj's vtable slot 11 (ReleaseSubObjects,
currently the DBObj no-op stub at 0x004154a0) with a tiny injected
thunk that calls D3DPolyRender::DestroyMesh on this->constructed_mesh
(field +0x6c).
If the orphan D3DXMesh leak comes from CGfxObj instances reaching
DBOCache::FreeObject without their mesh being released first, this
patch fixes that.
Risks:
- If any code reads CGfxObj->constructed_mesh after cache-add and
before re-Get-then-InitLoad, it will deref NULL and crash.
- This is the same risk shape as v1 Palette patch which DID crash
on copy-from-cached-source reads. For CGfxObj, the equivalent
"read from cached source" path may or may not exist. Test on a
single client first.
Mechanism:
- VirtualAllocEx in target process for a 64-byte RWX page
- Write the 18-byte thunk:
53 push ebx
8b d9 mov ebx, ecx (ecx = this)
83 c3 6c add ebx, 0x6c (ebx = &this->constructed_mesh)
53 push ebx
b8 e0 d1 59 00 mov eax, 0x0059d1e0 (D3DPolyRender::DestroyMesh)
ff d0 call eax
83 c4 04 add esp, 4
5b pop ebx
b0 01 mov al, 1 (return 1)
c3 ret
- VirtualProtectEx + WriteProcessMemory to flip the vtable slot at
0x007ca418 + 0x2c = 0x007ca444 from 0x004154a0 to the thunk addr.
Revert:
- Restore vtable slot to 0x004154a0
- The allocated page is leaked (intentional easier than tracking
its address)
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
VTABLE_CGFXOBJ = 0x007ca418
SLOT_RELEASE_SUBOBJ = 0x2c
SLOT_VA = VTABLE_CGFXOBJ + SLOT_RELEASE_SUBOBJ # 0x007ca444
NOOP_STUB_VA = 0x004154a0
DESTROY_MESH_VA = 0x0059d1e0
THUNK = bytes([
0x53, # push ebx
0x8b, 0xd9, # mov ebx, ecx
0x83, 0xc3, 0x6c, # add ebx, 0x6c
0x53, # push ebx
0xb8, 0xe0, 0xd1, 0x59, 0x00, # mov eax, 0x0059d1e0
0xff, 0xd0, # call eax
0x83, 0xc4, 0x04, # add esp, 4
0x5b, # pop ebx
0xb0, 0x01, # mov al, 1
0xc3, # ret
])
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 main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true",
help="restore the original no-op stub")
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)
cur = ctypes.c_uint(0)
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, SLOT_VA, ctypes.byref(cur), 4, ctypes.byref(sz)):
print(f"read SLOT_VA failed: err={ctypes.get_last_error()}"); sys.exit(3)
print(f"PID {args.pid} vtable slot @ 0x{SLOT_VA:08x} current: 0x{cur.value:08x}")
if args.revert:
if cur.value == NOOP_STUB_VA:
print(f" already pointing at no-op stub — nothing to revert")
CloseHandle(h); return
old_prot = wt.DWORD(0)
VirtualProtectEx(h, SLOT_VA, 4, PAGE_READWRITE, ctypes.byref(old_prot))
new = ctypes.c_uint(NOOP_STUB_VA)
WriteProcessMemory(h, SLOT_VA, ctypes.byref(new), 4, ctypes.byref(sz))
restored = wt.DWORD(0)
VirtualProtectEx(h, SLOT_VA, 4, old_prot.value, ctypes.byref(restored))
print(f" reverted to 0x{NOOP_STUB_VA:08x}")
CloseHandle(h); return
if cur.value != NOOP_STUB_VA:
print(f" UNEXPECTED current value (wanted 0x{NOOP_STUB_VA:08x}) — refusing to patch")
CloseHandle(h); sys.exit(4)
# Allocate RWX page for thunk
thunk_va = VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not thunk_va:
print(f"VirtualAllocEx failed: err={ctypes.get_last_error()}"); sys.exit(5)
print(f" thunk page allocated @ 0x{thunk_va:08x}")
# Write thunk
if not WriteProcessMemory(h, thunk_va, THUNK, len(THUNK), ctypes.byref(sz)):
print(f"write thunk failed: err={ctypes.get_last_error()}"); sys.exit(6)
# Flip vtable slot
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, SLOT_VA, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
print(f"VirtualProtectEx vtable failed: err={ctypes.get_last_error()}"); sys.exit(7)
new = ctypes.c_uint(thunk_va)
WriteProcessMemory(h, SLOT_VA, ctypes.byref(new), 4, ctypes.byref(sz))
restored = wt.DWORD(0)
VirtualProtectEx(h, SLOT_VA, 4, old_prot.value, ctypes.byref(restored))
# Verify
ReadProcessMemory(h, SLOT_VA, ctypes.byref(cur), 4, ctypes.byref(sz))
print(f" vtable slot now 0x{cur.value:08x} (patched OK)")
CloseHandle(h)
if __name__ == "__main__":
main()