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

103 lines
3.9 KiB
Python

"""build_patched_binary.py
Build acclient.eor.patched.exe by applying the v3b byte patches to a
backed-up copy of the original EoR acclient.exe.
Patches (file offsets = VA - 0x400 since base=0x400000 and .text RVA is +0x400):
- VA 0x0053effe ff 40 24 -> 90 90 90 ; NOP makeModifiedPalette() over-increment
- VA 0x0053f19c ff 46 24 -> 90 90 90 ; NOP makeModifiedPalette(id,sub) over-increment
For 32-bit PE with base=0x00400000 and .text starting at RVA 0x1000
mapped to file offset 0x400 (standard MSVC layout), file_offset =
VA - 0x00400000 - 0x1000 + 0x400 = VA - 0x00400C00.
"""
import hashlib, os, shutil, struct, sys
SRC = r"C:\Turbine\Asheron's Call\acclient.exe"
ORIG = r"C:\Turbine\Asheron's Call\acclient.eor.orig.exe"
PATCHED = r"C:\Turbine\Asheron's Call\acclient.eor.patched.exe"
# VA-based patches (we resolve file offsets via PE parsing)
PATCHES = [
(0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
(0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
]
def parse_pe_sections(data):
"""Return list of (rva_start, rva_size, file_offset) tuples."""
e_lfanew = struct.unpack_from("<I", data, 0x3c)[0]
sig = data[e_lfanew:e_lfanew+4]
assert sig == b"PE\0\0", f"bad PE sig: {sig}"
# COFF header at e_lfanew + 4
num_sections = struct.unpack_from("<H", data, e_lfanew + 6)[0]
size_opt_hdr = struct.unpack_from("<H", data, e_lfanew + 0x14)[0]
sections_off = e_lfanew + 0x18 + size_opt_hdr
image_base = struct.unpack_from("<I", data, e_lfanew + 0x34)[0] # PE32 ImageBase
out = []
for i in range(num_sections):
sh = sections_off + i * 0x28
virtual_size = struct.unpack_from("<I", data, sh + 0x08)[0]
virtual_addr = struct.unpack_from("<I", data, sh + 0x0c)[0]
raw_size = struct.unpack_from("<I", data, sh + 0x10)[0]
raw_off = struct.unpack_from("<I", data, sh + 0x14)[0]
name = data[sh:sh+8].rstrip(b"\0").decode("ascii", "replace")
out.append((name, image_base + virtual_addr, virtual_size, raw_off, raw_size))
return image_base, out
def va_to_file_offset(va, sections):
for name, va_start, vsize, raw_off, raw_size in sections:
if va_start <= va < va_start + max(vsize, raw_size):
return raw_off + (va - va_start)
raise ValueError(f"VA 0x{va:08x} not in any section")
def main():
if not os.path.exists(SRC):
print(f"src not found: {SRC}"); sys.exit(1)
# Backup
if not os.path.exists(ORIG):
shutil.copy2(SRC, ORIG)
print(f"backup written: {ORIG}")
else:
print(f"backup already exists: {ORIG}")
with open(ORIG, "rb") as f:
data = bytearray(f.read())
sha_orig = hashlib.sha256(data).hexdigest()
print(f"orig sha256: {sha_orig}")
print(f"orig size: {len(data)}")
image_base, sections = parse_pe_sections(data)
print(f"image base: 0x{image_base:08x}")
for s in sections[:3]:
print(f" section {s[0]:<8} va=0x{s[1]:08x} vsize=0x{s[2]:x} raw=0x{s[3]:08x} rsize=0x{s[4]:x}")
# Apply patches
for va, orig_bytes, patched_bytes in PATCHES:
off = va_to_file_offset(va, sections)
actual = bytes(data[off:off+len(orig_bytes)])
if actual == orig_bytes:
data[off:off+len(patched_bytes)] = patched_bytes
print(f" patched VA 0x{va:08x} (file off 0x{off:x}): "
f"{' '.join(f'{b:02x}' for b in orig_bytes)} -> "
f"{' '.join(f'{b:02x}' for b in patched_bytes)}")
elif actual == patched_bytes:
print(f" VA 0x{va:08x} already patched, skipping")
else:
print(f" VA 0x{va:08x} UNEXPECTED bytes: {' '.join(f'{b:02x}' for b in actual)}")
sys.exit(2)
with open(PATCHED, "wb") as f:
f.write(data)
sha_new = hashlib.sha256(bytes(data)).hexdigest()
print(f"\npatched sha256: {sha_new}")
print(f"written: {PATCHED}")
if __name__ == "__main__":
main()