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>
160 lines
6.4 KiB
Python
160 lines
6.4 KiB
Python
"""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()
|