"""patch_cgfxobj_v4_test.py [--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()