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>
This commit is contained in:
acbot 2026-05-23 21:05:17 +02:00
commit 57b5e43d0e
199 changed files with 1648333 additions and 0 deletions

View file

@ -0,0 +1,237 @@
"""patch_v14_position_alloc_trace.py <pid> [--revert]
DIAGNOSTIC ONLY does not modify program behavior.
Goal: find the call sites that produce leaked Position instances.
Approach: Install a counter-increment thunk at the start of each
Position::Position ctor variant. The thunk:
- increments a per-ctor 32-bit counter in a runtime-allocated
counter block
- then falls through to the original prologue and the rest of
the ctor
Counter block is queried by `--read`. No game state is modified.
CTORS (EoR addresses, derived from 2013):
- Position::Position() 2013 0x00424ab0 EoR TBD
- Position::Position(uint, Frame*) 2013 0x00452780 EoR TBD
- Position::Position(Position*) 2013 0x004529a0 EoR TBD
The EoR offsets are NOT yet known precisely this patcher is a
SKELETON. Before running, the analyst must:
1. Find Position::Position in EoR via reference from
`vtable = 0x00797910` writes (cdb: `s -d acclient_base
L?<size> 00797910`)
2. Fill in CTOR_ADDRS below with the three EoR addresses.
3. Verify each address starts with `mov eax, [esp+4]` or similar
stack-arg setup; check that 5 bytes of prologue fits a relative
`call <thunk>` (e.g. `e8 XX XX XX XX`).
After verifying live counts are non-zero and growing, leave the
patcher in place for 1 hour and dump counters via `--read`. The
ratio of count_a / count_b / count_c identifies the dominant call
path.
SAFETY: the thunks preserve all registers (push eax/pushfd) before
incrementing, restore after. They do NOT change any game state.
Even if the analyst applies the wrong ctor address, the worst case
is a stale call (caller crashes immediately, easy to diagnose) no
delayed-AV class of failure.
DO NOT apply this patcher in production. It is for read-only
behavioral diagnosis only. Apply on ONE non-essential PID (e.g.
Time, the idle character) and observe for 30 minutes before
inferring rate.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import struct
import sys
# --- CONFIG (FILL IN BEFORE USE) -----
# These are placeholders — verify against EoR binary before applying.
# In 2013 these were 0x00424ab0, 0x00452780, 0x004529a0. The EoR
# offset relative to 2013 varies per class; do NOT assume +0x1000.
CTOR_ADDRS = {
"default": 0x00000000, # FILL IN — 2013 0x00424ab0
"uint_fp": 0x00000000, # FILL IN — 2013 0x00452780
"copy": 0x00000000, # FILL IN — 2013 0x004529a0
}
VERIFY_VT_ADDR = 0x00797910 # Position vtable; should match
# `mov dword ptr [ecx], 00797910h`
# in each ctor prologue.
# --- Win32 surface --------------------
PROCESS_ALL_ACCESS = 0x1F0FFF
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
PAGE_EXECUTE_READWRITE = 0x40
PAGE_READWRITE = 0x04
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
k.OpenProcess.restype = wt.HANDLE
k.CloseHandle.argtypes = [wt.HANDLE]
k.CloseHandle.restype = wt.BOOL
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID,
ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID,
ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.WriteProcessMemory.restype = wt.BOOL
k.VirtualAllocEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD, wt.DWORD]
k.VirtualAllocEx.restype = wt.LPVOID
k.VirtualFreeEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD]
k.VirtualFreeEx.restype = wt.BOOL
def rpm(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
return None
return bytes(buf[:sz.value])
def wpm(h, addr, data):
buf = (ctypes.c_ubyte * len(data))(*data)
sz = ctypes.c_size_t(0)
return bool(k.WriteProcessMemory(h, addr, buf, len(data),
ctypes.byref(sz)))
def verify_ctor_at(h, addr):
"""Confirm the ctor at `addr` is a Position ctor by looking for
a write of vt 0x00797910 in the first 32 bytes."""
code = rpm(h, addr, 32)
if not code:
return False
target = struct.pack("<I", VERIFY_VT_ADDR)
return target in code
def apply(pid):
if 0 in CTOR_ADDRS.values():
print("ERROR: CTOR_ADDRS not filled in. Edit the constants at "
"the top of this script first.")
return 1
h = k.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
if not h:
print(f"OpenProcess failed (err={ctypes.get_last_error()})")
return 1
try:
# Verify each ctor address writes the Position vt
for label, addr in CTOR_ADDRS.items():
if not verify_ctor_at(h, addr):
print(f"ERROR: addr 0x{addr:08x} ({label}) does not "
f"reference Position vt 0x{VERIFY_VT_ADDR:08x}. "
"Refusing to patch.")
return 2
# Allocate counter block + thunk region
block = k.VirtualAllocEx(h, None, 0x1000,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE)
if not block:
print("VirtualAllocEx failed")
return 3
block = int(block)
print(f"counter+thunk block @ 0x{block:08x}")
# Layout:
# block + 0x000 counter A (default ctor)
# block + 0x004 counter B (uint+frame ctor)
# block + 0x008 counter C (copy ctor)
# block + 0x100 thunk A
# block + 0x120 thunk B
# block + 0x140 thunk C
# Each thunk:
# pushfd; push eax
# mov eax, &counter
# lock inc [eax]
# pop eax; popfd
# <copy of overwritten prologue bytes>
# jmp <ctor + 5>
labels = list(CTOR_ADDRS.keys())
for i, label in enumerate(labels):
ctor_addr = CTOR_ADDRS[label]
counter_addr = block + i * 4
thunk_addr = block + 0x100 + i * 0x20
# Read 5 prologue bytes (the bytes we'll relocate)
prologue = rpm(h, ctor_addr, 5)
if not prologue or len(prologue) != 5:
print(f"failed to read prologue at 0x{ctor_addr:08x}")
return 4
# Build thunk
thunk = bytearray()
thunk += b"\x9c" # pushfd
thunk += b"\x50" # push eax
thunk += b"\xb8" + struct.pack("<I", counter_addr) # mov eax, &counter
thunk += b"\xf0\xff\x00" # lock inc dword [eax]
thunk += b"\x58" # pop eax
thunk += b"\x9d" # popfd
thunk += prologue # original 5 bytes
# Jump back to ctor+5
return_to = ctor_addr + 5
rel = return_to - (thunk_addr + len(thunk) + 5)
thunk += b"\xe9" + struct.pack("<i", rel) # jmp rel32
wpm(h, thunk_addr, bytes(thunk))
# Write `e9 rel32` (call would be cleaner; use jmp since
# we replicate prologue inside thunk)
patch_rel = thunk_addr - (ctor_addr + 5)
patch = b"\xe9" + struct.pack("<i", patch_rel)
wpm(h, ctor_addr, patch)
print(f" patched {label} at 0x{ctor_addr:08x}"
f"thunk 0x{thunk_addr:08x}, counter @ 0x{counter_addr:08x}")
print(f"\nCounters at 0x{block:08x}. Read with `--read 0x{block:08x}`.")
return 0
finally:
k.CloseHandle(h)
def read_counters(pid, block):
h = k.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
if not h:
print("OpenProcess failed")
return 1
try:
data = rpm(h, block, 16)
if not data:
print("read failed")
return 1
a, b, c, _ = struct.unpack("<IIII", data)
print(f" default ctor: {a:>10}")
print(f" uint+frame ctor: {b:>10}")
print(f" copy ctor: {c:>10}")
print(f" total: {a+b+c:>10}")
return 0
finally:
k.CloseHandle(h)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--read", type=lambda x: int(x, 0),
help="read counters at this address")
ap.add_argument("--revert", action="store_true",
help="NOT IMPLEMENTED - this is a one-shot "
"diagnostic; restart client to revert")
args = ap.parse_args()
if args.read:
return read_counters(args.pid, args.read)
if args.revert:
print("revert not implemented — kill client to undo")
return 1
return apply(args.pid)
if __name__ == "__main__":
sys.exit(main())