""" runtime_patch_v2.py [--dry-run] [--revert] Proper Phase 8 prototype: injects a small custom x86 thunk into the target process and rewrites the RenderSurface vtable `ReleaseSubObjects` slot to point at it. The thunk frees the two heap-allocated buffers (sourceData.sourceBits at offset 0x40 and m_pSurfaceBits at offset 0x58) using EoR's operator delete[] at 0x005df164. Differs from v1 in that v1 made the slot point at slot +0x3c's function, which turned out to be a partial helper (frees only one buffer). The custom thunk frees both, matching the body of 2013's RenderSurface::Destroy minus the Begin() reset. EoR addresses (from dump diagnostic + live disassembly): vtable A: 0x007caa08 vtable B: 0x007ca0d8 slot +0x2c (RSO): current value 0x004154a0 (no-op stub) operator delete[]: 0x005df164 offsets in struct: sourceData.sourceBits = +0x40, m_pSurfaceBits = +0x58 """ import argparse, ctypes, ctypes.wintypes as wt, json, os, struct, sys # Process access flags PROCESS_VM_READ = 0x0010 PROCESS_VM_WRITE = 0x0020 PROCESS_VM_OPERATION = 0x0008 PROCESS_QUERY_INFORMATION = 0x0400 # Memory protection MEM_COMMIT = 0x1000 MEM_RESERVE = 0x2000 MEM_RELEASE = 0x8000 PAGE_EXECUTE_READWRITE = 0x40 PAGE_READWRITE = 0x04 # === EoR offsets and addresses (from analysis above) === VTABLES = [ ("RenderSurface vtable A", 0x007caa08), ("RenderSurface vtable B", 0x007ca0d8), ] RSO_SLOT = 0x2c OFF_SOURCEBITS = 0x40 # sourceData.sourceBits within RenderSurface OFF_SURFACEBITS = 0x58 # m_pSurfaceBits within RenderSurface OP_DELETE_ARR_EOR = 0x005df164 # operator delete[] in EoR NO_OP_STUB = 0x004154a0 # the no-op DBObj::ReleaseSubObjects in EoR 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 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 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 VirtualAllocEx = k32.VirtualAllocEx VirtualAllocEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD, wt.DWORD] VirtualAllocEx.restype = wt.LPVOID VirtualFreeEx = k32.VirtualFreeEx VirtualFreeEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD] VirtualFreeEx.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_uint32(h, addr): b = ctypes.c_uint32(0); n = ctypes.c_size_t(0) if not ReadProcessMemory(h, addr, ctypes.byref(b), 4, ctypes.byref(n)) or n.value != 4: raise OSError(f"read 0x{addr:x} err={ctypes.get_last_error()}") return b.value def write_bytes(h, addr, data): n = ctypes.c_size_t(0) old = wt.DWORD(0) if not VirtualProtectEx(h, addr, len(data), PAGE_READWRITE, ctypes.byref(old)): raise OSError(f"protect 0x{addr:x} err={ctypes.get_last_error()}") try: buf = (ctypes.c_ubyte * len(data))(*data) if not WriteProcessMemory(h, addr, buf, len(data), ctypes.byref(n)) or n.value != len(data): raise OSError(f"write 0x{addr:x} err={ctypes.get_last_error()}") finally: VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(old)) def build_thunk(remote_addr): """ Emit x86 machine code for the RSO thunk. Calling convention __fastcall: ECX = this No stack args Returns uint8_t in AL. Equivalent C: uint8_t __fastcall RSO_thunk(void* this) { void** p1 = (void**)((char*)this + 0x40); void** p2 = (void**)((char*)this + 0x58); if (*p1) { operator_delete_arr(*p1); *p1 = 0; } if (*p2) { operator_delete_arr(*p2); *p2 = 0; } return 1; } Assembly: push esi mov esi, ecx ; this -> esi mov eax, [esi + 0x40] ; load sourceBits test eax, eax jz skip1 push eax call ; relative call add esp, 4 mov dword ptr [esi + 0x40], 0 skip1: mov eax, [esi + 0x58] ; load surfaceBits test eax, eax jz skip2 push eax call add esp, 4 mov dword ptr [esi + 0x58], 0 skip2: mov al, 1 pop esi ret """ # We emit pass-1, compute call targets relative to remote_addr where the # thunk will live. code = bytearray() # push esi code += b"\x56" # mov esi, ecx code += b"\x8b\xf1" # mov eax, [esi + 0x40] code += b"\x8b\x46\x40" # test eax, eax code += b"\x85\xc0" # jz skip1 (placeholder, 1-byte rel8) code += b"\x74\x00" jz1_idx = len(code) - 1 # push eax code += b"\x50" # call — 5 bytes, e8 + rel32 call_emit_idx = len(code) code += b"\xe8\x00\x00\x00\x00" # Compute and fill: relative = target - (next_instr_addr) next_after_call = remote_addr + call_emit_idx + 5 rel = (OP_DELETE_ARR_EOR - next_after_call) & 0xffffffff code[call_emit_idx+1:call_emit_idx+5] = struct.pack(" call_emit_idx2 = len(code) code += b"\xe8\x00\x00\x00\x00" next_after_call2 = remote_addr + call_emit_idx2 + 5 rel2 = (OP_DELETE_ARR_EOR - next_after_call2) & 0xffffffff code[call_emit_idx2+1:call_emit_idx2+5] = struct.pack(" pre 0x{entry['pre']:08x}") write_bytes(h, entry["vtable"] + RSO_SLOT, struct.pack(" {remote:#x}") applied.append(dict(name=name, vtable=vt, pre=cur, post=remote)) if applied: with open(backup_file, "w") as f: json.dump({"pid": pid, "thunk": remote, "slots": applied, "thunk_size": len(code)}, f, indent=2) print(f"backup saved to {backup_file}") else: print("NO slots patched") VirtualFreeEx(h, remote, 0, MEM_RELEASE) finally: CloseHandle(h) def main(): ap = argparse.ArgumentParser() ap.add_argument("pid", type=int) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--revert", action="store_true") args = ap.parse_args() patch_process(args.pid, dry_run=args.dry_run, revert=args.revert) if __name__ == "__main__": main()