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>
330 lines
14 KiB
Python
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()
|