leakhunt/tools/patch_v14_cenvcell_clipplane.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

330 lines
14 KiB
Python

"""patch_v14_cenvcell_clipplane.py <pid> [--revert] [--force]
v14: Plug the CEnvCell::Destroy clip_planes leak.
LEAK
CEnvCell::Destroy (EoR @ 0x0052e5f0) zeroes the inner ClipPlaneList's
cplane_num field but never frees:
* the inner ClipPlaneList (the *(*clip_planes) allocation), or
* the outer DArray<ClipPlaneList*> buffer (the clip_planes alloc).
Every CEnvCell that reaches Destroy() leaks both.
PATCH
Replace the 18-byte leak block at 0x0052e661..0x0052e672 with a
5-byte JMP to a VirtualAllocEx'd thunk that does the real cleanup,
zeroes the field, then resumes at 0x0052e673.
Layout of the leak block (verified against larsson_highleak.dmp):
0052e661 8b 86 dc 00 00 00 mov eax, [esi+0xDC] ; clip_planes
0052e667 3b c3 cmp eax, ebx ; if (clip_planes != 0)
0052e669 74 08 je 0052e673
0052e66b 8b 00 mov eax, [eax] ; inner = *clip_planes
0052e66d 3b c3 cmp eax, ebx ; if (inner != 0)
0052e66f 74 02 je 0052e673
0052e671 89 18 mov [eax], ebx ; inner->cplane_num = 0
THUNK pseudo-asm (pushad-bracketed; ESI is 'this', EBX is 0):
pushad
mov edi, [esi+0xDC]
test edi, edi
jz done
mov ecx, [edi] ; inner
test ecx, ecx
jz free_outer
push ecx
call ClipPlaneList::~ClipPlaneList ; thiscall: ecx=inner
pop ecx
push ecx
call operator delete ; cdecl
add esp, 4
free_outer:
push edi
call operator delete[] ; cdecl
add esp, 4
mov [esi+0xDC], ebx ; NULL the field
done:
popad
jmp resume ; 0x0052e673
SUPPORT ADDRESSES (EoR-verified 2026-05-19 via Ghidra MCP + live bytes)
ClipPlaneList::~ClipPlaneList = 0x0053C760
Layout: `add ecx, 4; jmp DArray<ClipPlane>::~DArray<ClipPlane>`
(3-byte thiscall wrapper; cplane_list inner DArray is at offset +4).
Bytes at VA: 83 c1 04 e9 88 ff ff ff
~DArray<ClipPlane> = 0x0053C6F0 (target of the above jmp)
operator delete = 0x005DF15E (IAT thunk: ff 25 7c 32 79 00)
operator delete[] = 0x005DF164 (IAT thunk: ff 25 34 32 79 00)
2013 PDB drift: PDB symbols at 0x0053ba00 / 0x005de02e / 0x005de034
resolve to completely different EoR code (~0x800 / ~0xF00 RVA drift).
DO NOT trust 2013-PDB addresses for EoR — use the constants above.
Default mode is --dry-run. Live application is intentionally a manual
flag so this can't be run by mistake.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
# --- patch site (EoR-verified against larsson_highleak.dmp) ----------
PATCH_SITE_VA = 0x0052E661
RESUME_VA = 0x0052E673
ORIG_BYTES = bytes([
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, # mov eax, [esi+0xDC]
0x3B, 0xC3, # cmp eax, ebx
0x74, 0x08, # je +8
0x8B, 0x00, # mov eax, [eax]
0x3B, 0xC3, # cmp eax, ebx
0x74, 0x02, # je +2
0x89, 0x18, # mov [eax], ebx
])
assert len(ORIG_BYTES) == 18
# --- support addresses (EoR-verified 2026-05-19) ---------------------
# Verified via Ghidra MCP function-name lookup + live byte inspection
# of PID 2324 (one of 15 running EoR clients). See module docstring
# for the byte-pattern evidence.
#
# NOTE: the 2013 PDB addresses (0x0053BA00 / 0x005DE02E / 0x005DE034)
# do NOT map to the same functions in EoR — the binary has drifted by
# roughly 0x800 / 0xF00 bytes at these RVAs. Do not regress to PDB
# symbols without re-verifying live.
CLIPPLANELIST_DTOR_VA = 0x0053C760 # ClipPlaneList::~ClipPlaneList
# (3-byte thiscall thunk:
# add ecx, 4; jmp ~DArray<ClipPlane>)
OPERATOR_DELETE_VA = 0x005DF15E # IAT thunk: ff 25 7c 32 79 00
OPERATOR_DELETE_ARR_VA = 0x005DF164 # IAT thunk: ff 25 34 32 79 00
# --- Win32 plumbing ---------------------------------------------------
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
PAGE_EXECUTE_READWRITE = 0x40
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 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 = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old)):
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.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def build_thunk(thunk_va):
"""Assemble the cleanup thunk. Returns raw bytes."""
out = bytearray()
def emit(b): out.extend(b)
def rel32_call(target):
rel = target - (thunk_va + len(out) + 5)
emit(bytes([0xE8]) + struct.pack("<i", rel))
def rel32_jmp(target):
rel = target - (thunk_va + len(out) + 5)
emit(bytes([0xE9]) + struct.pack("<i", rel))
emit(bytes([0x60])) # pushad
emit(bytes([0x8B, 0xBE, 0xDC, 0x00, 0x00, 0x00])) # mov edi, [esi+0xDC]
emit(bytes([0x85, 0xFF])) # test edi, edi
# je done — compute placeholder; patch after
je_done_at = len(out); emit(bytes([0x74, 0x00]))
emit(bytes([0x8B, 0x0F])) # mov ecx, [edi] ; inner
emit(bytes([0x85, 0xC9])) # test ecx, ecx
je_freeouter_at = len(out); emit(bytes([0x74, 0x00]))
emit(bytes([0x51])) # push ecx
rel32_call(CLIPPLANELIST_DTOR_VA) # ~ClipPlaneList (thiscall)
emit(bytes([0x59])) # pop ecx (restore inner for delete)
emit(bytes([0x51])) # push ecx
rel32_call(OPERATOR_DELETE_VA) # operator delete(inner)
emit(bytes([0x83, 0xC4, 0x04])) # add esp, 4
# patch je_freeouter to here
free_outer_off = len(out)
out[je_freeouter_at + 1] = (free_outer_off - (je_freeouter_at + 2)) & 0xFF
emit(bytes([0x57])) # push edi
rel32_call(OPERATOR_DELETE_ARR_VA) # operator delete[](clip_planes)
emit(bytes([0x83, 0xC4, 0x04])) # add esp, 4
emit(bytes([0x89, 0x9E, 0xDC, 0x00, 0x00, 0x00])) # mov [esi+0xDC], ebx (ebx==0)
# patch je_done to here
done_off = len(out)
out[je_done_at + 1] = (done_off - (je_done_at + 2)) & 0xFF
emit(bytes([0x61])) # popad
rel32_jmp(RESUME_VA) # back to 0x0052e673
return bytes(out)
def sanity_check_support(h):
"""Sanity-check that the support addresses LOOK like the EoR-verified
targets. Refuses to apply if any check fails (caller can pass --force)."""
problems = []
# ~ClipPlaneList: a 3-byte thiscall thunk in EoR:
# 83 c1 04 add ecx, 4
# e9 88 ff ff ff jmp ~DArray<ClipPlane> (= 0x0053C6F0 from 0x0053C763+5)
# We're tolerant about the rel32 (compilers can re-emit) but the
# `add ecx, 4; jmp rel32` shape is a hard signature.
dtor = read_bytes(h, CLIPPLANELIST_DTOR_VA, 8)
if dtor[:4] != bytes([0x83, 0xC1, 0x04, 0xE9]):
problems.append(
f"CLIPPLANELIST_DTOR_VA=0x{CLIPPLANELIST_DTOR_VA:08x} "
f"starts {dtor.hex()} — expected 83 c1 04 e9 (add ecx,4; jmp ...) "
f"i.e. the EoR ~ClipPlaneList thunk."
)
else:
# Verify the relative jump lands at ~DArray<ClipPlane> (0x0053C6F0).
rel = struct.unpack("<i", dtor[4:8])[0]
target = CLIPPLANELIST_DTOR_VA + 8 + rel
if target != 0x0053C6F0:
problems.append(
f"CLIPPLANELIST_DTOR_VA jmp target = 0x{target:08x}, "
f"expected 0x0053C6F0 (~DArray<ClipPlane>)"
)
# operator delete: IAT thunk `ff 25 <abs ptr>`.
od = read_bytes(h, OPERATOR_DELETE_VA, 6)
if od[:2] != bytes([0xFF, 0x25]):
problems.append(
f"OPERATOR_DELETE_VA=0x{OPERATOR_DELETE_VA:08x} starts {od.hex()} "
f"— expected ff 25 ... (IAT jump thunk)"
)
# operator delete[]: IAT thunk `ff 25 <abs ptr>`.
odarr = read_bytes(h, OPERATOR_DELETE_ARR_VA, 6)
if odarr[:2] != bytes([0xFF, 0x25]):
problems.append(
f"OPERATOR_DELETE_ARR_VA=0x{OPERATOR_DELETE_ARR_VA:08x} starts "
f"{odarr.hex()} — expected ff 25 ... (IAT jump thunk)"
)
return problems
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
ap.add_argument("--force", action="store_true",
help="Apply even if support-address sanity check fails")
ap.add_argument("--apply", action="store_true",
help="Actually write the patch. Without this flag the "
"script runs in dry-run mode (default) and never "
"modifies the target process.")
args = ap.parse_args()
# Default mode is dry-run; --apply must be explicit. --revert bypasses.
args.dry_run = not (args.apply or args.revert)
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, len(ORIG_BYTES))
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x} ({len(ORIG_BYTES)} B): {cur.hex()}")
print(f" expected original : {ORIG_BYTES.hex()}")
if args.revert:
if cur == ORIG_BYTES:
print(f" already original"); CloseHandle(h); return
# Restore the original 18 bytes
write_bytes(h, PATCH_SITE_VA, ORIG_BYTES)
after = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
print(f" reverted; bytes now: {after.hex()}")
if after != ORIG_BYTES:
print(f" REVERT MISMATCH"); CloseHandle(h); sys.exit(7)
CloseHandle(h); return
if cur != ORIG_BYTES:
if cur[0] == 0xE9:
print(f" looks already patched (starts E9 ...); use --revert");
CloseHandle(h); sys.exit(3)
print(f" UNEXPECTED original bytes"); CloseHandle(h); sys.exit(4)
problems = sanity_check_support(h)
if problems:
print(f" support-address sanity check FAILED:")
for p in problems:
print(f" - {p}")
if not args.force:
print(f" refusing to apply without --force")
CloseHandle(h); sys.exit(5)
print(f" --force given; proceeding anyway (RISKY)")
if args.dry_run:
# Render thunk against a notional thunk_va just to print the bytes
thunk_va = 0x10000000 # placeholder for length reporting
thunk = build_thunk(thunk_va)
print(f" dry-run: thunk would be {len(thunk)} bytes "
f"(rel32 targets vary with the allocated page).")
print(f" thunk (at notional 0x{thunk_va:08x}): {thunk.hex()}")
rel = thunk_va - (PATCH_SITE_VA + 5)
repl = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * (len(ORIG_BYTES) - 5))
print(f" patch-site replacement ({len(repl)} B): {repl.hex()}")
print(f" re-run with --apply to actually write.")
CloseHandle(h); return
thunk_page = VirtualAllocEx(h, None, 0x80, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not thunk_page:
print(f" VirtualAllocEx failed err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
print(f" thunk page @ 0x{thunk_page:08x}")
thunk = build_thunk(thunk_page)
print(f" thunk ({len(thunk)} B): {thunk.hex()}")
sz = ctypes.c_size_t(0)
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
print(f" write thunk failed err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
after_thunk = read_bytes(h, thunk_page, len(thunk))
if after_thunk != thunk:
print(f" thunk MISMATCH after write"); CloseHandle(h); sys.exit(7)
# Build replacement: 5-byte JMP + NOP pad to 18 bytes
rel = thunk_page - (PATCH_SITE_VA + 5)
repl = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * (len(ORIG_BYTES) - 5))
assert len(repl) == len(ORIG_BYTES)
print(f" writing patch ({len(repl)} B): {repl.hex()}")
write_bytes(h, PATCH_SITE_VA, repl)
after = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
if after != repl:
print(f" PATCH MISMATCH"); CloseHandle(h); sys.exit(8)
print(f" OK — clip_planes leak plugged at 0x{PATCH_SITE_VA:08x}")
CloseHandle(h)
if __name__ == "__main__":
main()