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
160
tools/patch_cgfxobj_v4_test.py
Normal file
160
tools/patch_cgfxobj_v4_test.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue