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

121 lines
4.2 KiB
Python

"""
find_eor_rendersurface.py
Queries the EoR Ghidra MCP HTTP API to:
1. Enumerate all callers of operator_new (FUN_005df0f5)
2. Decompile each caller
3. Filter for ones whose body contains operator_new(0x120) — the unique
sizeof for RenderSurface. That's EoR's RenderSurface::Allocator.
4. For each match, capture the caller's address and report the body.
Also fetches xrefs to RenderSurface::Allocator → identifies the vtable
slot(s) where it appears (Allocate slot in each derived class's vtable).
"""
import json
import re
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
EOR_BASE = "http://192.168.1.98:8081"
OP_NEW_ADDR = "0x005df0f5"
TARGET_SIZE_HEX = "0x120"
TARGET_SIZE_DEC = 288
def http_get(path, **params):
qs = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{EOR_BASE}{path}?{qs}" if qs else f"{EOR_BASE}{path}"
with urllib.request.urlopen(url, timeout=60) as r:
return r.read().decode("utf-8", errors="replace")
def get_xrefs_to(addr, offset=0, limit=500):
raw = http_get("/xrefs_to", address=addr, offset=offset, limit=limit)
refs = []
for line in raw.splitlines():
# Format: "From 00536d73 in FUN_00536d70 [UNCONDITIONAL_CALL]"
m = re.match(r"From ([0-9a-f]+) in (\S+) \[([\w_]+)\]", line)
if m:
refs.append({
"site": int(m.group(1), 16),
"owner_name": m.group(2),
"kind": m.group(3),
})
return refs
def decompile(addr):
return http_get("/decompile_function_by_address", address=f"0x{addr:08x}")
def main():
# Collect all xrefs to operator_new (paginated)
all_refs = []
offset = 0
limit = 500
while True:
batch = get_xrefs_to(OP_NEW_ADDR, offset=offset, limit=limit)
if not batch:
break
all_refs.extend(batch)
if len(batch) < limit:
break
offset += limit
if offset > 20000:
break
print(f"total xrefs to operator_new: {len(all_refs)}")
# Owners (functions that call operator_new). Dedupe by owner_name.
owners = {}
for r in all_refs:
owners.setdefault(r["owner_name"], r["site"])
print(f"unique calling functions: {len(owners)}")
# Resolve each owner's function start address by decompiling and matching.
# Owner name is FUN_xxxxxxxx — the xxxx is the function start.
owner_addrs = []
for name, site in owners.items():
m = re.match(r"FUN_([0-9a-f]+)", name)
if m:
owner_addrs.append((int(m.group(1), 16), name))
print(f"resolvable owner-function start addresses: {len(owner_addrs)}")
# Decompile each, look for the 0x120 size signature
def check_one(addr_name):
addr, name = addr_name
try:
body = decompile(addr)
except Exception as e:
return (addr, name, None, f"error: {e}")
# Look for operator_new call with size 0x120 (288). Various forms.
if re.search(r"0x120\b", body) or re.search(r"\b288\b", body):
# Filter to actual op_new call sites
for line in body.splitlines():
if "0x120" in line or " 288" in line:
return (addr, name, "MATCH", line.strip())
return (addr, name, "MATCH", "(contains 0x120 somewhere)")
return (addr, name, None, None)
matches = []
print(f"decompiling {len(owner_addrs)} candidate Allocators...")
with ThreadPoolExecutor(max_workers=16) as ex:
futures = [ex.submit(check_one, an) for an in owner_addrs]
for i, fut in enumerate(as_completed(futures), 1):
addr, name, status, line = fut.result()
if status == "MATCH":
matches.append((addr, name, line))
print(f" [{i}/{len(owner_addrs)}] MATCH at 0x{addr:08x} ({name}): {line}")
print(f"\n=== {len(matches)} candidate RenderSurface::Allocator(s) ===")
for addr, name, line in matches:
print(f" 0x{addr:08x} {name}")
body = decompile(addr)
# print first 25 non-empty lines
for ln in body.splitlines()[:30]:
print(f" {ln}")
print()
if __name__ == "__main__":
main()