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

119 lines
4.6 KiB
Python

"""Check an .exe's CodeView debug info to see what PDB GUID + age it
expects. Used to verify whether a candidate acclient.exe matches our
acclient.pdb without running the binary.
Usage:
py tools/pdb-extract/check_exe_pdb.py <path-to-exe>
"""
import struct
import sys
import uuid
def main():
if len(sys.argv) < 2:
print("usage: check_exe_pdb.py <path-to-exe>")
sys.exit(1)
with open(sys.argv[1], "rb") as f:
data = f.read()
# DOS header -> e_lfanew @ offset 0x3C points to PE header
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe_off:pe_off + 4] == b"PE\x00\x00", "not a PE file"
# COFF header
machine = struct.unpack_from("<H", data, pe_off + 4)[0]
n_sections = struct.unpack_from("<H", data, pe_off + 6)[0]
timestamp = struct.unpack_from("<I", data, pe_off + 8)[0]
opt_size = struct.unpack_from("<H", data, pe_off + 20)[0]
print(f"machine = 0x{machine:04x}")
print(f"timestamp = 0x{timestamp:08x} ({timestamp})")
import datetime
ts = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
print(f" -> linker UTC: {ts.isoformat()}")
# Optional header — magic indicates 32 vs 64 bit
opt_off = pe_off + 24
magic = struct.unpack_from("<H", data, opt_off)[0]
is_pe32_plus = (magic == 0x20B)
print(f"opt magic = 0x{magic:04x} ({'PE32+' if is_pe32_plus else 'PE32'})")
# Data directories: PE32 has them at opt_off + 96; PE32+ at opt_off + 112
dd_off = opt_off + (112 if is_pe32_plus else 96)
# Debug directory is data dir [6]
debug_va = struct.unpack_from("<I", data, dd_off + 6 * 8)[0]
debug_size = struct.unpack_from("<I", data, dd_off + 6 * 8 + 4)[0]
print(f"debug dir = VA=0x{debug_va:08x} size={debug_size}")
# We need to map the VA back to a file offset via section headers
sec_off = opt_off + opt_size
sections = []
for i in range(n_sections):
s = sec_off + i * 40
name = data[s:s + 8].rstrip(b"\x00").decode("ascii", errors="replace")
vsize = struct.unpack_from("<I", data, s + 8)[0]
vaddr = struct.unpack_from("<I", data, s + 12)[0]
rsize = struct.unpack_from("<I", data, s + 16)[0]
roff = struct.unpack_from("<I", data, s + 20)[0]
sections.append((name, vaddr, vsize, roff, rsize))
def va_to_file(va):
for (name, vaddr, vsize, roff, rsize) in sections:
if vaddr <= va < vaddr + vsize:
return roff + (va - vaddr)
return None
debug_off = va_to_file(debug_va)
if debug_off is None:
print("debug directory VA does not map into any section")
return
# Each debug directory entry is 28 bytes
n_entries = debug_size // 28
print(f"# debug entries = {n_entries}")
for i in range(n_entries):
e = debug_off + i * 28
characteristics = struct.unpack_from("<I", data, e)[0]
ts_e = struct.unpack_from("<I", data, e + 4)[0]
major = struct.unpack_from("<H", data, e + 8)[0]
minor = struct.unpack_from("<H", data, e + 10)[0]
type_e = struct.unpack_from("<I", data, e + 12)[0]
sz = struct.unpack_from("<I", data, e + 16)[0]
rva = struct.unpack_from("<I", data, e + 20)[0]
ptr = struct.unpack_from("<I", data, e + 24)[0]
type_name = {2: "CODEVIEW", 4: "MISC", 12: "VC_FEATURE", 13: "POGO", 16: "REPRO"}.get(type_e, f"type_{type_e}")
print(f" entry {i}: type={type_name} sz={sz} fileOff=0x{ptr:08x}")
if type_e == 2 and sz >= 24:
cv = data[ptr:ptr + sz]
sig = cv[:4]
print(f" cv signature = {sig!r}")
if sig == b"RSDS":
guid_bytes = cv[4:20]
age = struct.unpack_from("<I", cv, 20)[0]
pdb_name = cv[24:].rstrip(b"\x00").decode("utf-8", errors="replace")
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
print(f" GUID = {{{pdb_guid}}}")
print(f" age = {age}")
print(f" PDB filename = {pdb_name}")
expected_guid = uuid.UUID("9e847e2f-777c-4bd9-886c-22256bb87f32")
expected_age = 1
if pdb_guid == expected_guid and age == expected_age:
print()
print("=== MATCH: this exe pairs with our acclient.pdb ===")
else:
print()
print("=== MISMATCH ===")
print(f" expected GUID = {{{expected_guid}}}")
print(f" expected age = {expected_age}")
if __name__ == "__main__":
main()