"""patch_v14_cenvcell_clipplane.py [--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 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::~DArray` (3-byte thiscall wrapper; cplane_list inner DArray is at offset +4). Bytes at VA: 83 c1 04 e9 88 ff ff ff ~DArray = 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) 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(" (= 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 (0x0053C6F0). rel = struct.unpack(")" ) # operator delete: IAT thunk `ff 25 `. 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 `. 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("