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>
130 lines
4.5 KiB
Python
130 lines
4.5 KiB
Python
"""find_all_noop_slots.py <dump.dmp>
|
|
|
|
Scan the .rdata-like image regions for vtable-shaped structures and
|
|
report every slot that points to the no-op stub at 0x004154a0
|
|
(the `mov al,1; ret` stub used by GraphicsResource::PurgeResource,
|
|
DBObj::ReleaseSubObjects, and similar "should be overridden" virtuals).
|
|
|
|
Each finding is a vtable slot where the base implementation lies
|
|
("returns success without doing the work") and a subclass should
|
|
have overridden it. The leaks happen where subclasses didn't override.
|
|
|
|
Heuristic for "this is a vtable":
|
|
- A run of aligned DWORDs where each DWORD points to an address
|
|
inside an executable image region (.text).
|
|
- At least N consecutive such DWORDs (N=4) qualifies as a vtable.
|
|
|
|
For each vtable found, count how many slots == no-op-stub.
|
|
Output: vtables ranked by no-op-slot count, with slot indices.
|
|
"""
|
|
import struct, sys
|
|
from collections import defaultdict
|
|
from minidump.minidumpfile import MinidumpFile
|
|
|
|
|
|
NOOP_STUB = 0x004154a0
|
|
MIN_VTABLE_SLOTS = 4
|
|
|
|
|
|
def _ei(v):
|
|
if v is None: return 0
|
|
if hasattr(v, 'value'): return int(v.value)
|
|
return int(v)
|
|
|
|
|
|
def main():
|
|
md = MinidumpFile.parse(sys.argv[1])
|
|
reader = md.get_reader().get_buffered_reader()
|
|
|
|
mods = [(m.baseaddress, m.size, m.name) for m in md.modules.modules]
|
|
def mod_of(addr):
|
|
for b, s, n in mods:
|
|
if b <= addr < b + s:
|
|
return n.split("\\")[-1]
|
|
return None
|
|
|
|
# Image-region ranges
|
|
image_ranges = []
|
|
for r in md.memory_info.infos:
|
|
st, ty = _ei(r.State), _ei(r.Type)
|
|
if st == 0x1000 and ty == 0x1000000:
|
|
image_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize, _ei(r.Protect) & 0xff))
|
|
image_ranges.sort()
|
|
|
|
# exec image ranges (function pointers should point into these)
|
|
exec_ranges = [(lo, hi) for lo, hi, pr in image_ranges if pr in (0x20, 0x40)]
|
|
def is_exec(addr):
|
|
for lo, hi in exec_ranges:
|
|
if lo <= addr < hi: return True
|
|
if addr < lo: return False
|
|
return False
|
|
|
|
# Scan readable image ranges for vtables
|
|
scan_ranges = [(lo, hi) for lo, hi, pr in image_ranges if pr in (0x02, 0x04, 0x40)]
|
|
|
|
# vtable_addr -> list of (slot_idx, slot_val)
|
|
vtables = {} # only those with at least 1 no-op slot
|
|
|
|
for lo, hi in scan_ranges:
|
|
size = hi - lo
|
|
try:
|
|
reader.move(lo)
|
|
buf = reader.read(size)
|
|
except Exception:
|
|
continue
|
|
if not buf: continue
|
|
|
|
n = len(buf) // 4
|
|
# Sliding: find runs where DWORD[i] is exec-image pointer.
|
|
# Treat each such run >= MIN_VTABLE_SLOTS as a potential vtable.
|
|
i = 0
|
|
while i < n:
|
|
slot0 = struct.unpack_from("<I", buf, i*4)[0]
|
|
if not is_exec(slot0):
|
|
i += 1
|
|
continue
|
|
j = i
|
|
slots = []
|
|
while j < n:
|
|
v = struct.unpack_from("<I", buf, j*4)[0]
|
|
if not is_exec(v):
|
|
break
|
|
slots.append(v)
|
|
j += 1
|
|
if len(slots) >= MIN_VTABLE_SLOTS:
|
|
noop_slots = [(k, v) for k, v in enumerate(slots) if v == NOOP_STUB]
|
|
if noop_slots:
|
|
vt_addr = lo + i*4
|
|
vtables[vt_addr] = (slots, noop_slots)
|
|
i = j if len(slots) >= MIN_VTABLE_SLOTS else i + 1
|
|
|
|
print(f"vtables with at least one no-op-stub slot: {len(vtables)}")
|
|
|
|
# Rank by no-op slot count
|
|
ranked = sorted(vtables.items(), key=lambda x: len(x[1][1]), reverse=True)
|
|
|
|
print()
|
|
print("=== Top vtables by no-op-slot count ===")
|
|
print(f"{'vtable':<12} {'#slots':>6} {'#noop':>6} noop_slot_indices")
|
|
for vt, (slots, noops) in ranked[:50]:
|
|
idxs = ", ".join(str(k) for k, _ in noops)
|
|
print(f"0x{vt:08x} {len(slots):>6} {len(noops):>6} [{idxs}]")
|
|
|
|
print()
|
|
print(f"=== Total no-op-stub references across all vtables ===")
|
|
total_noop = sum(len(noops) for _, (_, noops) in ranked)
|
|
print(f"{total_noop} total no-op slot references across {len(vtables)} vtables")
|
|
|
|
# Also save full list
|
|
out = sys.argv[2] if len(sys.argv) > 2 else None
|
|
if out:
|
|
with open(out, "w", encoding="utf8") as f:
|
|
f.write(f"vtable\tn_slots\tn_noop\tnoop_indices\n")
|
|
for vt, (slots, noops) in ranked:
|
|
idxs = ",".join(str(k) for k, _ in noops)
|
|
f.write(f"0x{vt:08x}\t{len(slots)}\t{len(noops)}\t{idxs}\n")
|
|
print(f"full list written to {out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|