leakhunt/tools/patch_v6_test.py
acbot 57b5e43d0e 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>
2026-05-23 21:07:58 +02:00

143 lines
5.2 KiB
Python

"""patch_v6_test.py <pid> [--revert]
EXPERIMENTAL v6: NOP the IsVisible guard in
UIElement_ItemList::UpdateEmptySlots at EoR 0x004e4390.
Mechanism:
The function starts:
sub esp, 0x10
push esi
mov esi, ecx ; this = ecx
call IsVisible ; → al
test al, al
jz end_of_function ; <-- 6 bytes at 0x004e439d, NOP these
...
Replacing the 6-byte `0F 84 F3 01 00 00` with six NOPs makes the
function continue regardless of visibility. The trim loop inside
then runs and deletes WAITING-state items.
Why this fixes the leak:
Every container open/close triggers ItemList_Flush, which calls
Clear_UIItem + SetState(WAITING) on every cell, then calls
UpdateEmptySlots. UpdateEmptySlots currently bails when invisible
(which is exactly when Flush is called on close). NOPing the
visibility check lets the trim loop run, calling InternalDeleteItem
on each WAITING cell.
Risks:
After visibility-check NOP, the function still has a second guard
(GetAttribute_Int(0x10000015) == -1). If GetAttribute misbehaves
on invisible widgets, behavior could be unexpected. For inventory
lists this should be fine (the attribute is set at ctor).
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
PATCH_SITE_VA = 0x004e439d
ORIGINAL_BYTES = bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]) # jz +0x1f3
PATCHED_BYTES = bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90]) # 6x nop
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
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
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_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true",
help="restore original JZ instruction")
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 = read_bytes(h, PATCH_SITE_VA, 6)
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
if args.revert:
if cur == ORIGINAL_BYTES:
print(" already original — nothing to revert")
CloseHandle(h); return
if cur != PATCHED_BYTES:
print(f" UNEXPECTED — current bytes don't match either original or patched")
print(f" expected original {ORIGINAL_BYTES.hex()} or patched {PATCHED_BYTES.hex()}")
CloseHandle(h); sys.exit(3)
write_bytes(h, PATCH_SITE_VA, ORIGINAL_BYTES)
after = read_bytes(h, PATCH_SITE_VA, 6)
print(f" reverted; bytes now: {after.hex()}")
CloseHandle(h); return
if cur == PATCHED_BYTES:
print(" already patched — nothing to do")
CloseHandle(h); return
if cur != ORIGINAL_BYTES:
print(f" UNEXPECTED — current bytes {cur.hex()} don't match expected original {ORIGINAL_BYTES.hex()}")
CloseHandle(h); sys.exit(4)
write_bytes(h, PATCH_SITE_VA, PATCHED_BYTES)
after = read_bytes(h, PATCH_SITE_VA, 6)
print(f" patched; bytes now: {after.hex()}")
if after != PATCHED_BYTES:
print(" MISMATCH — write didn't take")
CloseHandle(h); sys.exit(5)
print(" OK")
CloseHandle(h)
if __name__ == "__main__":
main()