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:
commit
57b5e43d0e
199 changed files with 1648333 additions and 0 deletions
239
tools/analyze_dump.py
Normal file
239
tools/analyze_dump.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""
|
||||
analyze_dump.py <dump.dmp>
|
||||
|
||||
Parses a Windows minidump and computes VA-region stats with no PDB
|
||||
dependency:
|
||||
* total committed memory, broken down by Type (Private/Mapped/Image)
|
||||
* top-N largest committed regions with module/path attribution
|
||||
* size-bucket histogram of committed regions
|
||||
* module list with image base and size
|
||||
|
||||
Output: writes <dump.dmp>.stats.json next to the dump and prints a
|
||||
short human summary to stdout.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_RESERVE = 0x2000
|
||||
MEM_FREE = 0x10000
|
||||
|
||||
MEM_PRIVATE = 0x20000
|
||||
MEM_MAPPED = 0x40000
|
||||
MEM_IMAGE = 0x1000000
|
||||
|
||||
|
||||
def _enum_int(v):
|
||||
"""minidump library may return State/Type as Enum or int — normalize to int."""
|
||||
if v is None:
|
||||
return 0
|
||||
if hasattr(v, 'value'):
|
||||
return int(v.value)
|
||||
return int(v)
|
||||
|
||||
PROT_NAMES = {
|
||||
0x01: "NOACCESS", 0x02: "READONLY", 0x04: "READWRITE", 0x08: "WRITECOPY",
|
||||
0x10: "EXECUTE", 0x20: "EXECUTE_READ", 0x40: "EXECUTE_READWRITE",
|
||||
0x80: "EXECUTE_WRITECOPY",
|
||||
}
|
||||
def fmt_prot(p):
|
||||
base = p & 0xFF
|
||||
name = PROT_NAMES.get(base, f"0x{base:02x}")
|
||||
if p & 0x100: name += "|GUARD"
|
||||
if p & 0x200: name += "|NOCACHE"
|
||||
if p & 0x400: name += "|WRITECOMBINE"
|
||||
return name
|
||||
|
||||
def fmt_state(s):
|
||||
if s == MEM_COMMIT: return "COMMIT"
|
||||
if s == MEM_RESERVE: return "RESERVE"
|
||||
if s == MEM_FREE: return "FREE"
|
||||
return f"0x{s:x}"
|
||||
|
||||
def fmt_type(t):
|
||||
if t == MEM_PRIVATE: return "Private"
|
||||
if t == MEM_MAPPED: return "Mapped"
|
||||
if t == MEM_IMAGE: return "Image"
|
||||
return f"0x{t:x}"
|
||||
|
||||
def power_of_2_bucket(sz):
|
||||
"""Return string like '64KB-128KB'."""
|
||||
if sz <= 0: return "0"
|
||||
p = sz.bit_length() - 1
|
||||
lo = 1 << p
|
||||
hi = lo << 1
|
||||
def fmt(n):
|
||||
if n >= 1024*1024*1024: return f"{n//(1024*1024*1024)}GB"
|
||||
if n >= 1024*1024: return f"{n//(1024*1024)}MB"
|
||||
if n >= 1024: return f"{n//1024}KB"
|
||||
return f"{n}B"
|
||||
return f"{fmt(lo)}-{fmt(hi)}"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: analyze_dump.py <dump.dmp>", file=sys.stderr); sys.exit(1)
|
||||
path = sys.argv[1]
|
||||
if not os.path.exists(path):
|
||||
print(f"not found: {path}", file=sys.stderr); sys.exit(1)
|
||||
|
||||
md = MinidumpFile.parse(path)
|
||||
out = {
|
||||
"path": path,
|
||||
"file_size_mb": round(os.path.getsize(path)/(1024*1024), 1),
|
||||
}
|
||||
|
||||
# System info
|
||||
si = md.sysinfo
|
||||
if si is not None:
|
||||
out["sysinfo"] = {
|
||||
"ProcessorArchitecture": str(si.ProcessorArchitecture),
|
||||
"ProductType": str(si.ProductType),
|
||||
"MajorVersion": si.MajorVersion,
|
||||
"MinorVersion": si.MinorVersion,
|
||||
"BuildNumber": si.BuildNumber,
|
||||
}
|
||||
|
||||
# Modules
|
||||
mods = []
|
||||
if md.modules:
|
||||
for m in md.modules.modules:
|
||||
mods.append({
|
||||
"name": os.path.basename(m.name),
|
||||
"base": m.baseaddress,
|
||||
"size": m.size,
|
||||
"ts": m.timestamp,
|
||||
})
|
||||
out["modules"] = mods
|
||||
out["modules_count"] = len(mods)
|
||||
|
||||
# Build a "what module owns this address" lookup
|
||||
def mod_owning(addr):
|
||||
for m in mods:
|
||||
if m["base"] <= addr < m["base"] + m["size"]:
|
||||
return m["name"]
|
||||
return None
|
||||
|
||||
# Memory info — the VAD-like list (state/type/protection per region)
|
||||
regions = []
|
||||
by_state_type = Counter() # (state, type) -> bytes
|
||||
by_state_type_count = Counter() # (state, type) -> count
|
||||
bucket_committed = Counter()
|
||||
if md.memory_info and md.memory_info.infos:
|
||||
for r in md.memory_info.infos:
|
||||
base = r.BaseAddress
|
||||
sz = r.RegionSize
|
||||
st = _enum_int(r.State)
|
||||
ty = _enum_int(r.Type)
|
||||
pr = _enum_int(r.Protect)
|
||||
regions.append({
|
||||
"base": base,
|
||||
"size": sz,
|
||||
"state": st,
|
||||
"type": ty,
|
||||
"protect": pr,
|
||||
"owner": mod_owning(base),
|
||||
})
|
||||
by_state_type[(st, ty)] += sz
|
||||
by_state_type_count[(st, ty)] += 1
|
||||
if st == MEM_COMMIT:
|
||||
bucket_committed[power_of_2_bucket(sz)] += sz
|
||||
|
||||
# Largest committed regions
|
||||
committed = sorted([r for r in regions if r["state"] == MEM_COMMIT],
|
||||
key=lambda r: r["size"], reverse=True)
|
||||
out["top20_committed"] = [
|
||||
{
|
||||
"base": f"0x{r['base']:08x}",
|
||||
"size": r["size"],
|
||||
"size_h": _h(r["size"]),
|
||||
"type": fmt_type(r["type"]),
|
||||
"prot": fmt_prot(r["protect"]),
|
||||
"owner": r["owner"],
|
||||
}
|
||||
for r in committed[:20]
|
||||
]
|
||||
out["regions_count"] = len(regions)
|
||||
out["committed_total"] = sum(r["size"] for r in regions if r["state"] == MEM_COMMIT)
|
||||
out["committed_private_total"] = sum(r["size"] for r in regions
|
||||
if r["state"] == MEM_COMMIT and r["type"] == MEM_PRIVATE)
|
||||
out["committed_image_total"] = sum(r["size"] for r in regions
|
||||
if r["state"] == MEM_COMMIT and r["type"] == MEM_IMAGE)
|
||||
out["committed_mapped_total"] = sum(r["size"] for r in regions
|
||||
if r["state"] == MEM_COMMIT and r["type"] == MEM_MAPPED)
|
||||
|
||||
# Per-module image commit (sums all committed Image regions per owner module)
|
||||
by_module_image = defaultdict(int)
|
||||
for r in regions:
|
||||
if r["state"] == MEM_COMMIT and r["type"] == MEM_IMAGE and r["owner"]:
|
||||
by_module_image[r["owner"]] += r["size"]
|
||||
out["top_image_modules"] = sorted(
|
||||
[{"module": k, "image_bytes": v} for k, v in by_module_image.items()],
|
||||
key=lambda x: x["image_bytes"], reverse=True
|
||||
)[:15]
|
||||
|
||||
# Per-bucket committed (mostly interesting for private)
|
||||
out["committed_size_buckets"] = [
|
||||
{"bucket": k, "bytes": v, "count": sum(1 for r in regions if r["state"] == MEM_COMMIT and power_of_2_bucket(r["size"]) == k)}
|
||||
for k, v in sorted(bucket_committed.items(), key=lambda x: x[1], reverse=True)
|
||||
]
|
||||
|
||||
# Specifically: large private committed regions w/ exec/rw protect (heap suspects)
|
||||
heap_suspects = [r for r in regions
|
||||
if r["state"] == MEM_COMMIT
|
||||
and r["type"] == MEM_PRIVATE
|
||||
and (r["protect"] & 0xFF) in (0x04, 0x40) # RW / EXECUTE_READWRITE
|
||||
and r["size"] >= 64*1024] # at least 64 KB
|
||||
heap_suspects.sort(key=lambda r: r["size"], reverse=True)
|
||||
out["heap_suspect_regions"] = [
|
||||
{
|
||||
"base": f"0x{r['base']:08x}",
|
||||
"size": r["size"],
|
||||
"size_h": _h(r["size"]),
|
||||
"prot": fmt_prot(r["protect"]),
|
||||
}
|
||||
for r in heap_suspects[:50]
|
||||
]
|
||||
out["heap_suspect_total"] = sum(r["size"] for r in heap_suspects)
|
||||
out["heap_suspect_count"] = len(heap_suspects)
|
||||
|
||||
# Write JSON
|
||||
out_path = path + ".stats.json"
|
||||
with open(out_path, "w", encoding="utf8") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
|
||||
# Pretty summary to stdout
|
||||
print(f"=== {os.path.basename(path)} ===")
|
||||
print(f"file: {out['file_size_mb']} MB regions: {out['regions_count']} modules: {out['modules_count']}")
|
||||
print(f" committed_total {_h(out['committed_total'])}")
|
||||
print(f" private {_h(out['committed_private_total'])}")
|
||||
print(f" image {_h(out['committed_image_total'])}")
|
||||
print(f" mapped {_h(out['committed_mapped_total'])}")
|
||||
print(f" heap_suspect (private RW, >=64KB): {_h(out['heap_suspect_total'])} across {out['heap_suspect_count']} regions")
|
||||
print(f"")
|
||||
print(f" top 10 image modules by committed size:")
|
||||
for m in out["top_image_modules"][:10]:
|
||||
print(f" {_h(m['image_bytes']):>10} {m['module']}")
|
||||
print(f"")
|
||||
print(f" top 10 committed regions:")
|
||||
for r in out["top20_committed"][:10]:
|
||||
own = r["owner"] or ""
|
||||
print(f" {r['size_h']:>10} {r['base']} {r['type']:>8} {r['prot']:<28} {own}")
|
||||
print(f"")
|
||||
print(f" wrote {out_path}")
|
||||
|
||||
|
||||
def _h(n):
|
||||
if n >= 1024*1024*1024: return f"{n/(1024*1024*1024):.2f} GB"
|
||||
if n >= 1024*1024: return f"{n/(1024*1024):.2f} MB"
|
||||
if n >= 1024: return f"{n/1024:.1f} KB"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
144
tools/audit_position_hash.py
Normal file
144
tools/audit_position_hash.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""patch_v15_positionhash_audit.py <pid>
|
||||
|
||||
READ-ONLY audit. Does NOT modify the target process.
|
||||
|
||||
Goal: confirm that the dominant Position-leak path is through
|
||||
CBaseQualities::_posStatsTable (PackableHashTable<uint, Position>),
|
||||
and identify which weenie types host the most retained Positions.
|
||||
|
||||
Method:
|
||||
1. Scan live private memory for Position vt 0x00797910 instances.
|
||||
2. For each, walk back through nearby heap headers to find the
|
||||
containing PositionPropertyValue (vt write at offset -0x08 +
|
||||
m_cRef at -0x04). PositionPropertyValue is the per-property
|
||||
ref-counted holder that lives inside the hash table.
|
||||
3. From the PositionPropertyValue, look for back-pointers into a
|
||||
PackableHashTable node, then up to the owning CBaseQualities.
|
||||
4. Print: count per (weenie-vt, dominant property key).
|
||||
|
||||
Caveats: heap-layout heuristics are fragile. Many Positions are
|
||||
stack-locals, not heap-allocated — those should be filtered out by
|
||||
checking the alignment-stride of the containing region.
|
||||
|
||||
Use this BEFORE designing any v14-style ctor trace, because it
|
||||
narrows the search: if 95% of leaked Positions are inside one
|
||||
weenie type's quality table, the patch target is that weenie's
|
||||
unload path, not Position itself.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import struct
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
# PositionPropertyValue vtable EoR address — DERIVE before use.
|
||||
# 2013 was at &PositionPropertyValue::vftable near 0x00796928.
|
||||
# In EoR the address is near 0x00797928 (placeholder; verify).
|
||||
POSITION_PROP_VAL_VT = 0x00797928 # PLACEHOLDER — verify
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
|
||||
ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def audit(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
|
||||
False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess failed (err={ctypes.get_last_error()})")
|
||||
return 1
|
||||
|
||||
position_addrs = []
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf,
|
||||
mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
||||
position_addrs.append((mbi.BaseAddress + off,
|
||||
mbi.BaseAddress,
|
||||
mbi.RegionSize))
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
print(f"Total Position instances found: {len(position_addrs)}")
|
||||
|
||||
# Stage 2: classify by containing region size + density to
|
||||
# separate stack/embedded from heap allocations
|
||||
region_density = Counter()
|
||||
for addr_, base, size in position_addrs:
|
||||
region_density[(base, size)] += 1
|
||||
|
||||
print("\nDensity histogram (region_size_kb, hits_per_region, "
|
||||
"count):")
|
||||
bucket = Counter()
|
||||
for (base, size), n in region_density.items():
|
||||
kb = size // 1024
|
||||
bucket[(kb, n)] += 1
|
||||
for (kb, n), c in sorted(bucket.items())[:30]:
|
||||
if c > 1:
|
||||
print(f" region={kb:>5}KB hits={n:>4} count={c:>4}")
|
||||
|
||||
# Stage 3: for first 200 Position hits, look 8 bytes backwards
|
||||
# for PositionPropertyValue vtable (PPV layout: [vt][m_cRef][position])
|
||||
ppv_hits = 0
|
||||
for i, (paddr, base, size) in enumerate(position_addrs[:1000]):
|
||||
ppv_vt_addr = paddr - 8
|
||||
if ppv_vt_addr < base:
|
||||
continue
|
||||
# Read 4 bytes
|
||||
buf4 = (ctypes.c_ubyte * 4)()
|
||||
sz4 = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, ppv_vt_addr, buf4, 4,
|
||||
ctypes.byref(sz4)):
|
||||
continue
|
||||
vt = struct.unpack("<I", bytes(buf4))[0]
|
||||
if vt == POSITION_PROP_VAL_VT:
|
||||
ppv_hits += 1
|
||||
print(f"\nOf first 1000 Position hits, {ppv_hits} are inside "
|
||||
f"PositionPropertyValue (vt+m_cRef at -8).")
|
||||
print("If this number is high (>500), the leak is in the "
|
||||
"property-value layer.")
|
||||
print("If low, the leak is in raw Position heap allocations "
|
||||
"(check PositionPack / position_queue paths).")
|
||||
|
||||
k.CloseHandle(h)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(audit(int(sys.argv[1])))
|
||||
38
tools/auto_v5_watcher.sh
Normal file
38
tools/auto_v5_watcher.sh
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Persistent watcher: every 5 min, finds acclient PIDs that have v3b applied
|
||||
# but NOT v5, applies v5 to them. Skips any PID whose window title contains "Jerry".
|
||||
# Emits one line per applied PID (event-style for Monitor).
|
||||
set -u
|
||||
PY="C:/Users/acbot/AppData/Local/Programs/Python/Python312/python.exe"
|
||||
cd /c/Users/acbot/leakhunt
|
||||
|
||||
while true; do
|
||||
# Get all acclient PIDs + window titles via PowerShell
|
||||
pid_titles=$(powershell.exe -NoProfile -Command \
|
||||
"Get-Process acclient -EA SilentlyContinue | ForEach-Object { \"\$(\$_.Id)|\$(\$_.MainWindowTitle)\" }" \
|
||||
2>/dev/null | tr -d '\r')
|
||||
|
||||
while IFS='|' read -r pid title; do
|
||||
[ -z "$pid" ] && continue
|
||||
# Skip Jerry (control)
|
||||
if echo "$title" | grep -qi "Jerry"; then continue; fi
|
||||
|
||||
# Check patch state — only proceed if v3b is applied AND v5 is not
|
||||
state=$("$PY" tools/check_patch_state.py "$pid" 2>/dev/null \
|
||||
| awk -v p="$pid" '$1==p {print $2,$3,$5,$6}')
|
||||
[ -z "$state" ] && continue
|
||||
read v3b1 v3b2 v5rs v5rt <<<"$state"
|
||||
|
||||
# v3b must be P/P, v5-RS must be "." (no thunk yet)
|
||||
if [ "$v3b1" = "P" ] && [ "$v3b2" = "P" ] && [ "$v5rs" = "." ]; then
|
||||
result=$("$PY" tools/patch_purge_v5_test.py "$pid" 2>&1)
|
||||
if echo "$result" | grep -q "OK"; then
|
||||
echo "AUTO-V5 PID=$pid title=\"$title\" applied $(date +%H:%M:%S)"
|
||||
else
|
||||
echo "AUTO-V5-FAIL PID=$pid title=\"$title\" output: $(echo "$result" | tail -2 | tr '\n' ' ')"
|
||||
fi
|
||||
fi
|
||||
done <<< "$pid_titles"
|
||||
|
||||
sleep 300
|
||||
done
|
||||
96
tools/broader_vtable_sweep.py
Normal file
96
tools/broader_vtable_sweep.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""broader_vtable_sweep.py <larsson.dmp> <time.dmp>
|
||||
|
||||
Find vtables in the acclient image range that have very high count
|
||||
in larsson and low count in time, EXCLUDING the already-tracked ones.
|
||||
Aim: surface previously-untracked leak classes.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
# Known/already-investigated vtables
|
||||
KNOWN = {
|
||||
0x007c0498: "UIElement_UIItem",
|
||||
0x007caa08: "Palette",
|
||||
0x007c78ec: "CPhysicsObj",
|
||||
0x0079a67c: "RenderSurface",
|
||||
0x00801a94: "RenderSurfaceD3D",
|
||||
0x00801a18: "RenderTextureD3D",
|
||||
0x007ca4dc: "CSurface",
|
||||
0x007cab04: "ImgTex",
|
||||
0x007ca418: "CGfxObj",
|
||||
0x007ed3b0: "D3DXMesh",
|
||||
0x0079bf64: "GraphicsResource",
|
||||
0x007ccb60: "NoticeHandler_subvt",
|
||||
0x007c98e8: "CObjCell_primary",
|
||||
0x007c9a60: "CEnvCell_primary",
|
||||
0x0079385c: "CObjCell_subvt",
|
||||
0x00400c08: "CPhys_data_sentinel",
|
||||
0x007c9b58: "CPhys_inner", # speculative
|
||||
0x0079c198: "RenderTexture",
|
||||
}
|
||||
|
||||
# Acclient image VA range (32-bit)
|
||||
IMG_LO = 0x00400000
|
||||
IMG_HI = 0x00880000
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
def scan(dmp_path):
|
||||
md = MinidumpFile.parse(dmp_path)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
counts = Counter()
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40):
|
||||
continue
|
||||
try:
|
||||
reader.move(r.BaseAddress)
|
||||
buf = reader.read(r.RegionSize)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if IMG_LO <= v < IMG_HI:
|
||||
# filter UTF-16 noise: low byte 0x00 of every other byte
|
||||
# rough check: byte 1 is 0 and byte 3 is 0 with letters in bytes 0,2
|
||||
b0, b1, b2, b3 = v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff
|
||||
if b1 == 0 and b3 == 0 and 0x20 <= b0 <= 0x7e and 0x20 <= b2 <= 0x7e:
|
||||
continue # UTF-16 string fragment
|
||||
counts[v] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def main():
|
||||
larsson = sys.argv[1]
|
||||
time_path = sys.argv[2]
|
||||
print(f"scanning larsson: {larsson}")
|
||||
lc = scan(larsson)
|
||||
print(f" unique vtables: {len(lc)}")
|
||||
print(f"scanning time: {time_path}")
|
||||
tc = scan(time_path)
|
||||
print(f" unique vtables: {len(tc)}")
|
||||
# Compute diff
|
||||
rows = []
|
||||
for vt, c in lc.items():
|
||||
if vt in KNOWN: continue
|
||||
if c < 20: continue
|
||||
t = tc.get(vt, 0)
|
||||
ratio = c / max(t, 0.5)
|
||||
delta = c - t
|
||||
rows.append((vt, t, c, ratio, delta))
|
||||
rows.sort(key=lambda x: -x[4])
|
||||
print()
|
||||
print("UN-tracked acclient vtables, sorted by absolute delta (larsson - time):")
|
||||
print(f"{'vtable':12} {'time':>6} {'larsson':>8} {'ratio':>7} {'delta':>7}")
|
||||
for vt, t, c, ratio, delta in rows[:40]:
|
||||
print(f"0x{vt:08x} {t:>6} {c:>8} {ratio:>7.1f} {delta:>7}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
tools/build_patched_binary.py
Normal file
103
tools/build_patched_binary.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""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()
|
||||
179
tools/byte_accounting.py
Normal file
179
tools/byte_accounting.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""byte_accounting.py <pid>
|
||||
Walk all committed memory in a target process and categorize bytes by:
|
||||
- VAD region type (private vs mapped vs image)
|
||||
- Protection (RW vs RX vs RWX)
|
||||
- Size bucket
|
||||
- Known-class signature scan (vtable bytes within the region)
|
||||
Output: per-category totals so we can see where the working set lives."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD),
|
||||
("Protect", wt.DWORD),
|
||||
("Type", wt.DWORD),
|
||||
]
|
||||
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
MEM_MAPPED = 0x40000
|
||||
MEM_IMAGE = 0x1000000
|
||||
|
||||
# Known class vtables from instr.cpp
|
||||
KNOWN_VTABLES = {
|
||||
0x007C78EC: "CPhysicsObj",
|
||||
0x0079A67C: "RenderSurface",
|
||||
0x0079C198: "RenderTexture",
|
||||
0x00801A94: "RenderSurfaceD3D",
|
||||
0x00801A18: "RenderTextureD3D",
|
||||
0x007CA4DC: "CSurface(GR)",
|
||||
0x007CAB04: "ImgTex(GR)",
|
||||
0x007CA418: "CGfxObj",
|
||||
0x007ED3B0: "GXTri3Mesh",
|
||||
0x007E4F70: "ACCWeenieObject",
|
||||
0x007E4ED8: "CWeenieObject",
|
||||
}
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print(f"OpenProcess err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
# Totals by region type
|
||||
total_committed = 0
|
||||
by_type = {"private_rw": 0, "private_rx": 0, "private_rwx": 0,
|
||||
"mapped": 0, "image": 0, "other": 0}
|
||||
# Bytes attributed to each known class (rough estimate: vtable_count × likely class size)
|
||||
class_size_estimate = {
|
||||
"CPhysicsObj": 376, # +0x178 from constructor allocation
|
||||
"RenderSurface": 288,
|
||||
"RenderTexture": 152,
|
||||
"RenderSurfaceD3D": 304,
|
||||
"RenderTextureD3D": 176,
|
||||
"CSurface(GR)": 144,
|
||||
"ImgTex(GR)": 136,
|
||||
"CGfxObj": 200, # estimate
|
||||
"GXTri3Mesh": 1000, # estimate; large mesh class
|
||||
"ACCWeenieObject": 336,
|
||||
"CWeenieObject": 200, # estimate
|
||||
}
|
||||
class_instance_count = {name: 0 for name in KNOWN_VTABLES.values()}
|
||||
|
||||
# Histogram of private-RW region sizes
|
||||
size_buckets = [
|
||||
(0, 4096, "<4K"),
|
||||
(4096, 65536, "4K-64K"),
|
||||
(65536, 262144, "64K-256K"),
|
||||
(262144, 1048576, "256K-1M"),
|
||||
(1048576, 4194304, "1M-4M"),
|
||||
(4194304, 16777216, "4M-16M"),
|
||||
(16777216, 67108864, "16M-64M"),
|
||||
(67108864, 1<<31, "64M+"),
|
||||
]
|
||||
bucket_count = [0] * len(size_buckets)
|
||||
bucket_bytes = [0] * len(size_buckets)
|
||||
|
||||
def classify_size(n):
|
||||
for i, (lo, hi, _) in enumerate(size_buckets):
|
||||
if lo <= n < hi: return i
|
||||
return len(size_buckets) - 1
|
||||
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
region_base = mbi.BaseAddress or 0
|
||||
region_size = mbi.RegionSize
|
||||
if mbi.State == MEM_COMMIT:
|
||||
total_committed += region_size
|
||||
prot = mbi.Protect & 0xFF
|
||||
# Classify region type
|
||||
if mbi.Type == MEM_IMAGE:
|
||||
by_type["image"] += region_size
|
||||
elif mbi.Type == MEM_MAPPED:
|
||||
by_type["mapped"] += region_size
|
||||
elif mbi.Type == MEM_PRIVATE:
|
||||
if prot == 0x40: by_type["private_rwx"] += region_size
|
||||
elif prot == 0x20: by_type["private_rx"] += region_size
|
||||
elif prot == 0x04: by_type["private_rw"] += region_size
|
||||
else: by_type["other"] += region_size
|
||||
else:
|
||||
by_type["other"] += region_size
|
||||
|
||||
# For private RW/RWX: bucket by size + scan for known vtables
|
||||
if mbi.Type == MEM_PRIVATE and prot in (0x04, 0x40):
|
||||
bi = classify_size(region_size)
|
||||
bucket_count[bi] += 1
|
||||
bucket_bytes[bi] += region_size
|
||||
# Scan for known vtable bytes (skip huge regions to bound time)
|
||||
if region_size <= 64 * 1024 * 1024:
|
||||
try:
|
||||
data = rd(h, region_base, region_size)
|
||||
if data:
|
||||
for vt, name in KNOWN_VTABLES.items():
|
||||
vt_bytes = struct.pack('<I', vt)
|
||||
count = 0
|
||||
off = 0
|
||||
while True:
|
||||
off = data.find(vt_bytes, off)
|
||||
if off < 0: break
|
||||
if (off & 3) == 0: count += 1
|
||||
off += 4
|
||||
class_instance_count[name] += count
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
next_addr = region_base + region_size
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
k.CloseHandle(h)
|
||||
|
||||
def mb(n): return f"{n/(1024*1024):,.1f}"
|
||||
|
||||
print(f"=== pid {pid} byte accounting ===")
|
||||
print(f"Total committed: {mb(total_committed)} MB")
|
||||
print()
|
||||
print("By region type:")
|
||||
for label, n in by_type.items():
|
||||
pct = (n*100/total_committed) if total_committed else 0
|
||||
print(f" {label:14s} {mb(n):>9} MB ({pct:5.1f}%)")
|
||||
print()
|
||||
print("Private RW/RWX region size distribution:")
|
||||
print(f" {'bucket':<12} {'count':>6} {'total MB':>10}")
|
||||
for i, (lo, hi, label) in enumerate(size_buckets):
|
||||
if bucket_count[i] == 0: continue
|
||||
print(f" {label:<12} {bucket_count[i]:>6} {mb(bucket_bytes[i]):>10}")
|
||||
print()
|
||||
print("Known-class vtable counts (and estimated bytes):")
|
||||
print(f" {'class':<22} {'count':>6} {'est bytes':>12} {'est MB':>8}")
|
||||
total_class_bytes = 0
|
||||
for name in sorted(class_instance_count, key=lambda x: -class_instance_count[x]):
|
||||
n = class_instance_count[name]
|
||||
if n == 0: continue
|
||||
sz = class_size_estimate.get(name, 200)
|
||||
bytes_total = n * sz
|
||||
total_class_bytes += bytes_total
|
||||
print(f" {name:<22} {n:>6} {bytes_total:>12,} {mb(bytes_total):>8}")
|
||||
print()
|
||||
print(f" Identified-class total: ~{mb(total_class_bytes)} MB")
|
||||
print(f" Of private RW: ~{mb(by_type['private_rw'])} MB")
|
||||
unidentified = by_type['private_rw'] - total_class_bytes
|
||||
print(f" UNIDENTIFIED in priv RW: ~{mb(unidentified)} MB ← what we don't account for")
|
||||
2
tools/cdb_dump.txt
Normal file
2
tools/cdb_dump.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\target.dmp
|
||||
qd
|
||||
2
tools/cdb_dump_jerry_highleak.txt
Normal file
2
tools/cdb_dump_jerry_highleak.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
|
||||
qd
|
||||
2
tools/cdb_dump_larsson_highleak.txt
Normal file
2
tools/cdb_dump_larsson_highleak.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
|
||||
qd
|
||||
2
tools/cdb_dump_nyckel_lowleak.txt
Normal file
2
tools/cdb_dump_nyckel_lowleak.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
|
||||
qd
|
||||
2
tools/cdb_dump_nyckel_lowleak2.txt
Normal file
2
tools/cdb_dump_nyckel_lowleak2.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\nyckel_lowleak2.dmp
|
||||
qd
|
||||
2
tools/cdb_dump_time_lowleak.txt
Normal file
2
tools/cdb_dump_time_lowleak.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\time_lowleak.dmp
|
||||
qd
|
||||
65
tools/check_acclient_imports.py
Normal file
65
tools/check_acclient_imports.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""check_acclient_imports.py
|
||||
Read acclient.exe imports and report whether leakfix.dll is already loaded."""
|
||||
import struct, sys, os
|
||||
|
||||
EXE = r"C:\Turbine\Asheron's Call\acclient.exe"
|
||||
if len(sys.argv) > 1: EXE = sys.argv[1]
|
||||
|
||||
with open(EXE, 'rb') as f:
|
||||
data = f.read()
|
||||
print(f"Loaded {len(data):,} bytes from {EXE}")
|
||||
|
||||
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
|
||||
print(f"PE header at 0x{pe_off:x}, signature: {data[pe_off:pe_off+4]}")
|
||||
|
||||
num_sections = struct.unpack_from('<H', data, pe_off + 4 + 2)[0]
|
||||
opt_header_size = struct.unpack_from('<H', data, pe_off + 4 + 16)[0]
|
||||
opt_off = pe_off + 4 + 20
|
||||
magic = struct.unpack_from('<H', data, opt_off)[0]
|
||||
print(f"sections={num_sections}, opt_hdr={opt_header_size}, magic=0x{magic:x}")
|
||||
|
||||
# Read sections to enable RVA→file translation
|
||||
sect_off = opt_off + opt_header_size
|
||||
sections = []
|
||||
for i in range(num_sections):
|
||||
so = sect_off + i*40
|
||||
name = data[so:so+8].rstrip(b'\0').decode(errors='replace')
|
||||
vsize = struct.unpack_from('<I', data, so+8)[0]
|
||||
vaddr = struct.unpack_from('<I', data, so+12)[0]
|
||||
rsize = struct.unpack_from('<I', data, so+16)[0]
|
||||
rawoff = struct.unpack_from('<I', data, so+20)[0]
|
||||
chars = struct.unpack_from('<I', data, so+36)[0]
|
||||
sections.append((name, vaddr, vsize, rawoff, rsize, chars))
|
||||
print(f" [{i}] {name:8s} vaddr=0x{vaddr:08x} vsize=0x{vsize:08x} raw=0x{rawoff:08x} rsize=0x{rsize:08x} chars=0x{chars:08x}")
|
||||
|
||||
def rva_to_off(rva):
|
||||
for name, vaddr, vsize, rawoff, rsize, chars in sections:
|
||||
if vaddr <= rva < vaddr + vsize:
|
||||
return rawoff + (rva - vaddr)
|
||||
return None
|
||||
|
||||
# DataDirectory[1] = Import Table
|
||||
dd_off = opt_off + 96
|
||||
import_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
|
||||
import_sz = struct.unpack_from('<I', data, dd_off + 12)[0]
|
||||
print(f"\nImport directory: RVA=0x{import_rva:x} size={import_sz}")
|
||||
import_foff = rva_to_off(import_rva)
|
||||
print(f"Import directory file offset: 0x{import_foff:x}")
|
||||
|
||||
print("\nImports:")
|
||||
off = import_foff
|
||||
dlls = []
|
||||
while True:
|
||||
name_rva = struct.unpack_from('<I', data, off + 12)[0]
|
||||
if name_rva == 0: break
|
||||
name_foff = rva_to_off(name_rva)
|
||||
name_end = data.index(b'\0', name_foff)
|
||||
dll_name = data[name_foff:name_end].decode(errors='replace')
|
||||
dlls.append((dll_name, off))
|
||||
off += 20
|
||||
|
||||
for name, descriptor_off in dlls:
|
||||
print(f" {name} (descriptor @ file 0x{descriptor_off:x})")
|
||||
|
||||
print(f"\nTotal: {len(dlls)} imports")
|
||||
print(f"leakfix.dll already in imports? {any('leakfix' in n.lower() for n, _ in dlls)}")
|
||||
119
tools/check_exe_pdb.py
Normal file
119
tools/check_exe_pdb.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""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()
|
||||
104
tools/check_orphan_refcounts.py
Normal file
104
tools/check_orphan_refcounts.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""check_orphan_refcounts.py <pid>
|
||||
|
||||
For D3DXMesh instances with NO pointers in heap memory, read their
|
||||
internal refcount (COM-style at +0x?? — let's check several offsets).
|
||||
If refcount > 0, something outside heap (stack/static globals)
|
||||
references them. If refcount == 0, they're truly leaked.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
VTABLE = 0x007ed3b0
|
||||
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
|
||||
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress',ctypes.c_void_p),('AllocationBase',ctypes.c_void_p),
|
||||
('AllocationProtect',wt.DWORD),('PartitionId',wt.WORD),('RegionSize',ctypes.c_size_t),
|
||||
('State',wt.DWORD),('Protect',wt.DWORD),('Type',wt.DWORD)]
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes=[wt.DWORD,wt.BOOL,wt.DWORD]; k.OpenProcess.restype=wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes=[wt.HANDLE,ctypes.c_void_p,ctypes.POINTER(MBI),ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype=ctypes.c_size_t
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
|
||||
|
||||
# Pass 1: enumerate all RW regions and find mesh addrs + collect data
|
||||
rw_regions = []
|
||||
mbi=MBI(); addr=0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
if mbi.State==MEM_COMMIT and mbi.Type==MEM_PRIVATE and (mbi.Protect&0xff) in (0x04, 0x40):
|
||||
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
|
||||
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
|
||||
if addr>=0x80000000: break
|
||||
|
||||
# Find mesh addresses + their data
|
||||
mesh_data = {} # addr -> first 64 bytes
|
||||
for base, data in rw_regions:
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-0x40, 4):
|
||||
if struct.unpack_from('<I', data, off)[0] == VTABLE:
|
||||
mesh_data[base + off] = data[off:off+0x40]
|
||||
|
||||
print(f'D3DXMesh instances: {len(mesh_data)}')
|
||||
|
||||
# Pass 2: for each mesh address, count pointers in heap memory
|
||||
mesh_addr_set = set(mesh_data.keys())
|
||||
ref_counts = {a: 0 for a in mesh_data}
|
||||
for base, data in rw_regions:
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-4, 4):
|
||||
v = struct.unpack_from('<I', data, off)[0]
|
||||
if v in mesh_addr_set:
|
||||
ref_addr = base + off
|
||||
if ref_addr in mesh_addr_set: continue # self
|
||||
ref_counts[v] += 1
|
||||
|
||||
orphans = [a for a in mesh_data if ref_counts[a] == 0]
|
||||
held = [a for a in mesh_data if ref_counts[a] > 0]
|
||||
print(f'orphans (0 heap refs): {len(orphans)}')
|
||||
print(f'held: {len(held)}')
|
||||
|
||||
# For each orphan, dump the first 0x40 bytes and try to find a refcount-looking field
|
||||
# COM objects typically have a refcount at +0x04 or +0x08
|
||||
print()
|
||||
print('=== Orphan mesh refcount candidates (DWORDs at +0x04, +0x08, +0x0c, +0x10, +0x14) ===')
|
||||
hist_off04 = Counter()
|
||||
hist_off08 = Counter()
|
||||
hist_off0c = Counter()
|
||||
hist_off10 = Counter()
|
||||
hist_off14 = Counter()
|
||||
for a in orphans:
|
||||
d = mesh_data[a]
|
||||
v04 = struct.unpack_from('<I', d, 0x04)[0]
|
||||
v08 = struct.unpack_from('<I', d, 0x08)[0]
|
||||
v0c = struct.unpack_from('<I', d, 0x0c)[0]
|
||||
v10 = struct.unpack_from('<I', d, 0x10)[0]
|
||||
v14 = struct.unpack_from('<I', d, 0x14)[0]
|
||||
hist_off04[min(v04, 100)] += 1
|
||||
hist_off08[min(v08, 100)] += 1
|
||||
hist_off0c[min(v0c, 100)] += 1
|
||||
hist_off10[min(v10, 100)] += 1
|
||||
hist_off14[min(v14, 100)] += 1
|
||||
|
||||
print(f'+0x04 distribution (top 5): {hist_off04.most_common(5)}')
|
||||
print(f'+0x08 distribution (top 5): {hist_off08.most_common(5)}')
|
||||
print(f'+0x0c distribution (top 5): {hist_off0c.most_common(5)}')
|
||||
print(f'+0x10 distribution (top 5): {hist_off10.most_common(5)}')
|
||||
print(f'+0x14 distribution (top 5): {hist_off14.most_common(5)}')
|
||||
|
||||
# Sample 5 orphans — dump full 0x40 bytes
|
||||
print()
|
||||
print('=== Sample 5 orphan dumps ===')
|
||||
for a in orphans[:5]:
|
||||
print(f' mesh @ 0x{a:08x}:')
|
||||
d = mesh_data[a]
|
||||
for i in range(0, 0x40, 16):
|
||||
row = d[i:i+16]
|
||||
hex_str = ' '.join(f'{b:02x}' for b in row)
|
||||
print(f' +0x{i:02x}: {hex_str}')
|
||||
102
tools/check_patch_state.py
Normal file
102
tools/check_patch_state.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""check_patch_state.py [pid1 pid2 ...]
|
||||
|
||||
Check which patches are applied to each AC client. If no PIDs given,
|
||||
scans all acclient processes.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, subprocess, sys
|
||||
|
||||
|
||||
SITES = {
|
||||
"v3b-1": (0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
|
||||
"v3b-2": (0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
|
||||
"v4": (0x007ca444, bytes([0xa0, 0x54, 0x41, 0x00]), None), # CGfxObj vtable slot 11
|
||||
"v5-RS": (0x0079a684, bytes([0xa0, 0x54, 0x41, 0x00]), None), # RenderSurface vtable slot 2
|
||||
"v5-RT": (0x0079c1a0, bytes([0xa0, 0x54, 0x41, 0x00]), None), # RenderTexture vtable slot 2
|
||||
"v8-1": (0x004e439d, bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]), bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
|
||||
"v8-2": (0x004e43c0, bytes([0x0f, 0x85, 0xcd, 0x01, 0x00, 0x00]), bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
|
||||
"v8-3": (0x004e4496, bytes([0x75, 0x0d]), bytes([0x75, 0x08])),
|
||||
}
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def read_bytes(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 check_pid(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
return None
|
||||
result = {}
|
||||
for name, (addr, orig, patched) in SITES.items():
|
||||
cur = read_bytes(h, addr, len(orig))
|
||||
if cur is None:
|
||||
result[name] = "?"
|
||||
elif cur == orig:
|
||||
result[name] = "." # unpatched / original
|
||||
elif patched is not None and cur == patched:
|
||||
result[name] = "P"
|
||||
else:
|
||||
result[name] = "X" # different (could be runtime-allocated thunk addr for v4/v5)
|
||||
k.CloseHandle(h)
|
||||
return result
|
||||
|
||||
|
||||
def get_window_title(pid):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["powershell.exe", "-NoProfile", "-Command",
|
||||
f"(Get-Process -Id {pid} -ErrorAction SilentlyContinue).MainWindowTitle"],
|
||||
text=True, stderr=subprocess.DEVNULL).strip()
|
||||
return out.split("-")[-1].strip() if out else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def list_acclient_pids():
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["powershell.exe", "-NoProfile", "-Command",
|
||||
"(Get-Process acclient -ErrorAction SilentlyContinue).Id"],
|
||||
text=True, stderr=subprocess.DEVNULL).strip()
|
||||
return sorted(int(line) for line in out.splitlines() if line.strip())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pids", type=int, nargs="*")
|
||||
args = ap.parse_args()
|
||||
|
||||
pids = args.pids if args.pids else list_acclient_pids()
|
||||
|
||||
cols = list(SITES.keys())
|
||||
print(f"{'pid':>6} {' '.join(c[:6] for c in cols)} character")
|
||||
print(f"{'---':>6} {' '.join('------' for c in cols)} -----------")
|
||||
for pid in pids:
|
||||
r = check_pid(pid)
|
||||
if r is None:
|
||||
print(f"{pid:>6} <dead>")
|
||||
continue
|
||||
name = get_window_title(pid)
|
||||
row = " ".join(f"{r[c]:>6}" for c in cols)
|
||||
print(f"{pid:>6} {row} {name}")
|
||||
|
||||
print()
|
||||
print("Legend: . = unpatched/original P = patched (NOP/byte change)")
|
||||
print(" X = different (runtime-allocated thunk, e.g. v4/v5 -> custom code page)")
|
||||
169
tools/classify_0x0079385c_hits.py
Normal file
169
tools/classify_0x0079385c_hits.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""classify_0x0079385c_hits.py <pid>
|
||||
|
||||
For each occurrence of the DWORD 0x0079385c in private RW memory of <pid>:
|
||||
- Record the absolute address of the hit
|
||||
- Walk BACKWARDS 16-byte aligned, looking for a plausible vtable pointer
|
||||
in the 0x00400000-0x00900000 (acclient .rdata) range with a small
|
||||
nonzero offset distance (<= 0x400 bytes). That's the object's start
|
||||
and offset-of-the-marker.
|
||||
- Group hits by:
|
||||
* offset-of-marker (e.g. +0x30, +0x54 confirms CObjCell)
|
||||
* the head vtable found (what class the object actually is)
|
||||
- Then independently bucket by REGION size of the containing
|
||||
VirtualAlloc region (informational, not allocation size).
|
||||
|
||||
Output:
|
||||
* Total hits
|
||||
* Offset histogram (top 10)
|
||||
* Head-vtable histogram (top 10) — includes vtable address + count
|
||||
* Sample hex dumps (first 64 bytes) for top 3 head-vtable groups
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
TARGET = 0x0079385c
|
||||
|
||||
# acclient.exe is loaded around 0x00400000 with .rdata vtables typically
|
||||
# in the 0x00700000-0x00880000 range. Accept slightly wider for safety.
|
||||
VTABLE_LO = 0x00400000
|
||||
VTABLE_HI = 0x00900000
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
args = ap.parse_args()
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
# Pass 1: enumerate all readable private RW regions; remember snapshots in memory
|
||||
# so we can back-scan WITHOUT extra remote reads.
|
||||
regions = [] # list of (base, data:bytes, region_size)
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
regions.append((int(mbi.BaseAddress), bytes(buf[:sz.value]), int(mbi.RegionSize)))
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
total_hits = 0
|
||||
offset_hist = Counter() # offset from inferred object head
|
||||
head_vt_hist = Counter() # vtable found at inferred object head
|
||||
region_size_hist = Counter()
|
||||
head_vt_samples = defaultdict(list) # vt -> list of (addr, first 64 bytes)
|
||||
|
||||
# Bucket region sizes for histogram
|
||||
def bucket(sz):
|
||||
if sz < 1024: return "<1KB"
|
||||
if sz < 4*1024: return "1-4KB"
|
||||
if sz < 64*1024: return "4-64KB"
|
||||
if sz < 256*1024: return "64-256KB"
|
||||
if sz < 512*1024: return "256-512KB"
|
||||
if sz < 1024*1024: return "512KB-1MB"
|
||||
return ">=1MB"
|
||||
|
||||
# For each hit, search backward for a vtable head.
|
||||
# Walk back up to 0x400 bytes (CObjCell-class size guess), aligned to 4.
|
||||
MAX_BACKSCAN = 0x400
|
||||
|
||||
for base, data, rsize in regions:
|
||||
end = (len(data) // 4) * 4
|
||||
# Find all DWORD positions equal to TARGET
|
||||
# struct.iter_unpack is fast enough
|
||||
pos = 0
|
||||
target_bytes = struct.pack("<I", TARGET)
|
||||
while True:
|
||||
idx = data.find(target_bytes, pos)
|
||||
if idx < 0: break
|
||||
if idx % 4 != 0:
|
||||
pos = idx + 1
|
||||
continue
|
||||
total_hits += 1
|
||||
region_size_hist[bucket(rsize)] += 1
|
||||
hit_addr = base + idx
|
||||
|
||||
# Back-scan: try each 4-byte aligned offset 0, -4, -8, ... up to MAX_BACKSCAN
|
||||
found_vt = None
|
||||
found_off = None
|
||||
for back in range(0, MAX_BACKSCAN + 4, 4):
|
||||
probe = idx - back
|
||||
if probe < 0: break
|
||||
v = struct.unpack_from("<I", data, probe)[0]
|
||||
if VTABLE_LO <= v < VTABLE_HI and v != TARGET:
|
||||
# Heuristic: this is likely the head vtable.
|
||||
# The first plausible one we find (smallest back-distance) wins.
|
||||
found_vt = v
|
||||
found_off = back
|
||||
break
|
||||
if found_vt is not None:
|
||||
offset_hist[found_off] += 1
|
||||
head_vt_hist[found_vt] += 1
|
||||
if len(head_vt_samples[found_vt]) < 3:
|
||||
head_addr = hit_addr - found_off
|
||||
head_idx = idx - found_off
|
||||
snippet = data[head_idx:head_idx + 64]
|
||||
head_vt_samples[found_vt].append((head_addr, snippet))
|
||||
else:
|
||||
offset_hist[-1] += 1 # marker for "no head found"
|
||||
pos = idx + 4
|
||||
|
||||
print(f"PID={args.pid} scanned {len(regions)} private RW regions")
|
||||
print(f"Total 0x{TARGET:08x} hits: {total_hits}\n")
|
||||
|
||||
print("=== Region-size histogram (where the hit lives) ===")
|
||||
for b, c in region_size_hist.most_common():
|
||||
print(f" {b:>10} {c:>7}")
|
||||
print()
|
||||
|
||||
print("=== Offset of marker from inferred object head (top 15) ===")
|
||||
for off, c in offset_hist.most_common(15):
|
||||
if off == -1:
|
||||
print(f" (no head found) {c:>7}")
|
||||
else:
|
||||
print(f" +0x{off:04x} {c:>7}")
|
||||
print()
|
||||
|
||||
print("=== Head vtable histogram (top 15) ===")
|
||||
for vt, c in head_vt_hist.most_common(15):
|
||||
print(f" 0x{vt:08x} {c:>7}")
|
||||
print()
|
||||
|
||||
print("=== Sample first-64-byte dumps for top 3 head vtables ===")
|
||||
for vt, _c in head_vt_hist.most_common(3):
|
||||
print(f"\n--- head vtable 0x{vt:08x} ---")
|
||||
for ha, snip in head_vt_samples[vt]:
|
||||
hexs = " ".join(f"{b:02x}" for b in snip)
|
||||
print(f" at 0x{ha:08x}: {hexs}")
|
||||
163
tools/classify_0x0079385c_v2.py
Normal file
163
tools/classify_0x0079385c_v2.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""classify_0x0079385c_v2.py <pid>
|
||||
|
||||
V2 of the classifier. Two new approaches:
|
||||
|
||||
1. Test the "CObjCell shell at +0x30 and +0x54" hypothesis: for each hit,
|
||||
check if there's ALSO a 0x0079385c marker exactly 0x24 (36) bytes away
|
||||
(i.e. the OTHER offset of the same CObjCell). If yes → likely a real
|
||||
CObjCell shell pair. Count those.
|
||||
|
||||
2. Check immediate-neighborhood context. A "real leaked object" looks like:
|
||||
- Object head at some 8/16-byte-aligned address
|
||||
- First DWORD is a vtable pointer in .rdata range
|
||||
- Most of object is zeros or sensible field values
|
||||
A "compiler-baked constant" looks like:
|
||||
- Surrounded by code/anim data, not separable as an object
|
||||
- May appear right after a function pointer (in a vtable construction)
|
||||
or in a const-data array
|
||||
|
||||
Approach: for each hit, look at the 16 bytes BEFORE the hit. If the
|
||||
preceding DWORDs contain ANY value in the executable-range
|
||||
0x00400000-0x00700000 (which would be CODE pointer), this is likely an
|
||||
embedded constant in compiled data, not a runtime object field.
|
||||
Real heap object fields would not have code-range pointers RIGHT
|
||||
before the marker.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
TARGET = 0x0079385c
|
||||
|
||||
# Code is in .text typically 0x00401000 - 0x006xxxxx
|
||||
CODE_LO = 0x00401000
|
||||
CODE_HI = 0x006d0000
|
||||
# Read-only data (.rdata) typically follows .text
|
||||
RDATA_LO = 0x006d0000
|
||||
RDATA_HI = 0x008c0000
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
args = ap.parse_args()
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
regions = []
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
regions.append((int(mbi.BaseAddress), bytes(buf[:sz.value]), int(mbi.RegionSize)))
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
target_bytes = struct.pack("<I", TARGET)
|
||||
|
||||
# Stats
|
||||
total_hits = 0
|
||||
n_pair_at_24 = 0 # has a paired 0x0079385c at +/- 0x24 (CObjCell layout hint)
|
||||
n_solo = 0 # alone
|
||||
prev_dword_kind = Counter() # what's the DWORD immediately before the hit
|
||||
prev16_has_code = 0 # has a code-range pointer in the prior 16 bytes (suggests baked-in)
|
||||
prev16_all_zero = 0 # surrounded by zeros (suggests cleared-but-not-freed object field)
|
||||
|
||||
# Per-region density: hits-per-region
|
||||
region_hit_counter = []
|
||||
|
||||
for base, data, rsize in regions:
|
||||
hits_here = []
|
||||
pos = 0
|
||||
while True:
|
||||
idx = data.find(target_bytes, pos)
|
||||
if idx < 0: break
|
||||
if idx % 4 != 0:
|
||||
pos = idx + 1
|
||||
continue
|
||||
hits_here.append(idx)
|
||||
pos = idx + 4
|
||||
if not hits_here:
|
||||
continue
|
||||
region_hit_counter.append((base, rsize, len(hits_here)))
|
||||
|
||||
hits_set = set(hits_here)
|
||||
for idx in hits_here:
|
||||
total_hits += 1
|
||||
# CObjCell pair check
|
||||
if (idx + 0x24) in hits_set or (idx - 0x24) in hits_set:
|
||||
n_pair_at_24 += 1
|
||||
else:
|
||||
n_solo += 1
|
||||
|
||||
# Immediate prior DWORD class
|
||||
if idx >= 4:
|
||||
prev = struct.unpack_from("<I", data, idx - 4)[0]
|
||||
if prev == 0:
|
||||
prev_dword_kind["zero"] += 1
|
||||
elif CODE_LO <= prev < CODE_HI:
|
||||
prev_dword_kind["code_ptr"] += 1
|
||||
elif RDATA_LO <= prev < RDATA_HI:
|
||||
prev_dword_kind["rdata_ptr"] += 1
|
||||
elif 0x01000000 <= prev < 0x80000000:
|
||||
prev_dword_kind["heap_ptr"] += 1
|
||||
elif prev == TARGET:
|
||||
prev_dword_kind["self_marker"] += 1
|
||||
else:
|
||||
prev_dword_kind["scalar/other"] += 1
|
||||
|
||||
# 16-byte window before
|
||||
if idx >= 16:
|
||||
window = data[idx-16:idx]
|
||||
wd = struct.unpack("<IIII", window)
|
||||
if all(d == 0 for d in wd):
|
||||
prev16_all_zero += 1
|
||||
if any(CODE_LO <= d < CODE_HI for d in wd):
|
||||
prev16_has_code += 1
|
||||
|
||||
print(f"Total 0x{TARGET:08x} hits: {total_hits}")
|
||||
print(f" Paired at +/-0x24 (CObjCell layout candidate): {n_pair_at_24}")
|
||||
print(f" Solo: {n_solo}")
|
||||
print()
|
||||
print("Immediate prior DWORD classification:")
|
||||
for k_, v in prev_dword_kind.most_common():
|
||||
print(f" {k_:<15} {v:>7}")
|
||||
print()
|
||||
print(f"Prior 16 bytes all zero (likely object field): {prev16_all_zero}")
|
||||
print(f"Prior 16 bytes has code-ptr (likely baked data): {prev16_has_code}")
|
||||
print()
|
||||
print("=== Top 15 regions by hit count ===")
|
||||
region_hit_counter.sort(key=lambda x: -x[2])
|
||||
for base, rsize, c in region_hit_counter[:15]:
|
||||
print(f" base=0x{base:08x} size={rsize/1024:>8.1f}KB hits={c:>5} hits/KB={c*1024/rsize:.2f}")
|
||||
192
tools/clone_dump.py
Normal file
192
tools/clone_dump.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""clone_dump.py <pid> <out.dmp>
|
||||
|
||||
Take a non-disruptive full memory dump of a live process using
|
||||
process reflection (PssCaptureSnapshot) — the same mechanism procdump
|
||||
uses with -r 1 -ma. The target is only paused for ~1ms while the
|
||||
COW snapshot is created; the dump itself runs against the snapshot,
|
||||
not the live process.
|
||||
|
||||
This avoids the multi-second pause that MiniDumpWriteDump on a live
|
||||
PID would cause (and which disconnects AC clients from Coldeve).
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
# PSS flags
|
||||
PSS_CAPTURE_VA_CLONE = 0x00000001
|
||||
PSS_CAPTURE_HANDLES = 0x00000004
|
||||
PSS_CAPTURE_HANDLE_NAME_INFORMATION = 0x00000008
|
||||
PSS_CAPTURE_HANDLE_BASIC_INFORMATION= 0x00000010
|
||||
PSS_CAPTURE_HANDLE_TYPE_SPECIFIC_INFORMATION = 0x00000020
|
||||
PSS_CAPTURE_HANDLE_TRACE = 0x00000040
|
||||
PSS_CAPTURE_THREADS = 0x00000080
|
||||
PSS_CAPTURE_THREAD_CONTEXT = 0x00000100
|
||||
PSS_CAPTURE_THREAD_CONTEXT_EXTENDED = 0x00000200
|
||||
PSS_CAPTURE_VA_SPACE = 0x00000800
|
||||
PSS_CAPTURE_VA_SPACE_SECTION_INFORMATION = 0x00001000
|
||||
|
||||
PSS_CREATE_RELEASE_SECTION = 0x80000000
|
||||
PSS_CREATE_FORCE_BREAKAWAY = 0x40000000
|
||||
PSS_CREATE_USE_VM_ALLOCATIONS = 0x20000000
|
||||
|
||||
# Process access
|
||||
PROCESS_ALL_ACCESS = 0x1F0FFF
|
||||
|
||||
# MiniDumpType
|
||||
MINI_DUMP_WITH_FULL_MEMORY = 0x00000002
|
||||
MINI_DUMP_WITH_HANDLE_DATA = 0x00000004
|
||||
MINI_DUMP_WITH_UNLOADED_MODULES = 0x00000020
|
||||
MINI_DUMP_WITH_FULL_MEMORY_INFO = 0x00000800
|
||||
MINI_DUMP_WITH_THREAD_INFO = 0x00001000
|
||||
MINI_DUMP_WITH_TOKEN_INFORMATION = 0x00040000
|
||||
|
||||
DUMP_TYPE = (
|
||||
MINI_DUMP_WITH_FULL_MEMORY
|
||||
| MINI_DUMP_WITH_HANDLE_DATA
|
||||
| MINI_DUMP_WITH_UNLOADED_MODULES
|
||||
| MINI_DUMP_WITH_FULL_MEMORY_INFO
|
||||
| MINI_DUMP_WITH_THREAD_INFO
|
||||
)
|
||||
|
||||
|
||||
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
dbghelp = ctypes.WinDLL('dbghelp', use_last_error=True)
|
||||
|
||||
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
||||
OpenProcess.restype = wt.HANDLE
|
||||
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]
|
||||
CloseHandle.restype = wt.BOOL
|
||||
|
||||
PssCaptureSnapshot = k32.PssCaptureSnapshot
|
||||
PssCaptureSnapshot.argtypes = [wt.HANDLE, wt.DWORD, wt.DWORD, ctypes.POINTER(wt.HANDLE)]
|
||||
PssCaptureSnapshot.restype = wt.DWORD
|
||||
|
||||
PssFreeSnapshot = k32.PssFreeSnapshot
|
||||
PssFreeSnapshot.argtypes = [wt.HANDLE, wt.HANDLE]
|
||||
PssFreeSnapshot.restype = wt.DWORD
|
||||
|
||||
CreateFileW = k32.CreateFileW
|
||||
CreateFileW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, ctypes.c_void_p,
|
||||
wt.DWORD, wt.DWORD, wt.HANDLE]
|
||||
CreateFileW.restype = wt.HANDLE
|
||||
|
||||
MiniDumpWriteDump = dbghelp.MiniDumpWriteDump
|
||||
MiniDumpWriteDump.argtypes = [wt.HANDLE, wt.DWORD, wt.HANDLE,
|
||||
wt.DWORD, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
|
||||
MiniDumpWriteDump.restype = wt.BOOL
|
||||
|
||||
|
||||
# MINIDUMP_CALLBACK_INFORMATION + IsProcessSnapshotCallback support
|
||||
# to tell dbghelp the hProcess is actually a snapshot handle.
|
||||
|
||||
class MINIDUMP_CALLBACK_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("CallbackRoutine", ctypes.c_void_p),
|
||||
("CallbackParam", ctypes.c_void_p),
|
||||
]
|
||||
|
||||
# Callback type: BOOL CALLBACK MiniDumpCallback(PVOID CallbackParam, const PMINIDUMP_CALLBACK_INPUT, PMINIDUMP_CALLBACK_OUTPUT)
|
||||
MiniDumpCallback_T = ctypes.WINFUNCTYPE(wt.BOOL, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p)
|
||||
|
||||
IS_PROCESS_SNAPSHOT_CALLBACK = 16
|
||||
|
||||
def _callback(callback_param, input_ptr, output_ptr):
|
||||
# CallbackType is at offset 4 in MINIDUMP_CALLBACK_INPUT (after ProcessId DWORD)
|
||||
if not input_ptr:
|
||||
return True
|
||||
cb_type = ctypes.cast(input_ptr + 4, ctypes.POINTER(wt.ULONG))[0]
|
||||
if cb_type == IS_PROCESS_SNAPSHOT_CALLBACK:
|
||||
# Set ULONG at start of MINIDUMP_CALLBACK_OUTPUT to MiniDumpValidCallback (1)
|
||||
if output_ptr:
|
||||
ctypes.cast(output_ptr, ctypes.POINTER(wt.ULONG))[0] = 1
|
||||
return True
|
||||
|
||||
_callback_inst = MiniDumpCallback_T(_callback)
|
||||
|
||||
GetCurrentProcess = k32.GetCurrentProcess
|
||||
GetCurrentProcess.argtypes = []
|
||||
GetCurrentProcess.restype = wt.HANDLE
|
||||
|
||||
GENERIC_WRITE = 0x40000000
|
||||
CREATE_ALWAYS = 2
|
||||
FILE_ATTRIBUTE_NORMAL = 0x80
|
||||
INVALID_HANDLE_VALUE = wt.HANDLE(-1).value
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("out", help="output dump path (.dmp)")
|
||||
args = ap.parse_args()
|
||||
|
||||
out_path = os.path.abspath(args.out)
|
||||
|
||||
h_proc = OpenProcess(PROCESS_ALL_ACCESS, False, args.pid)
|
||||
if not h_proc:
|
||||
err = ctypes.get_last_error()
|
||||
print(f"OpenProcess({args.pid}) failed err={err}")
|
||||
sys.exit(2)
|
||||
|
||||
print(f"PID {args.pid}: opened, capturing COW snapshot...")
|
||||
capture_flags = (
|
||||
PSS_CAPTURE_VA_CLONE
|
||||
| PSS_CAPTURE_HANDLES
|
||||
| PSS_CAPTURE_HANDLE_NAME_INFORMATION
|
||||
| PSS_CAPTURE_HANDLE_BASIC_INFORMATION
|
||||
| PSS_CAPTURE_HANDLE_TYPE_SPECIFIC_INFORMATION
|
||||
| PSS_CAPTURE_HANDLE_TRACE
|
||||
| PSS_CAPTURE_THREADS
|
||||
| PSS_CAPTURE_THREAD_CONTEXT
|
||||
| PSS_CAPTURE_THREAD_CONTEXT_EXTENDED
|
||||
| PSS_CAPTURE_VA_SPACE
|
||||
| PSS_CAPTURE_VA_SPACE_SECTION_INFORMATION
|
||||
)
|
||||
h_snap = wt.HANDLE()
|
||||
# ContextFlags param: 0x0010001F = CONTEXT_FULL | CONTEXT_i386
|
||||
rc = PssCaptureSnapshot(h_proc, capture_flags, 0x0010001F, ctypes.byref(h_snap))
|
||||
if rc != 0:
|
||||
print(f"PssCaptureSnapshot failed rc={rc}")
|
||||
CloseHandle(h_proc)
|
||||
sys.exit(3)
|
||||
print(f" snapshot handle 0x{h_snap.value:x}")
|
||||
|
||||
h_file = CreateFileW(out_path, GENERIC_WRITE, 0, None,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, None)
|
||||
if h_file == INVALID_HANDLE_VALUE or h_file is None:
|
||||
err = ctypes.get_last_error()
|
||||
print(f"CreateFile {out_path} failed err={err}")
|
||||
PssFreeSnapshot(GetCurrentProcess(), h_snap)
|
||||
CloseHandle(h_proc)
|
||||
sys.exit(4)
|
||||
|
||||
print(f" writing dump to {out_path}...")
|
||||
cb_info = MINIDUMP_CALLBACK_INFORMATION()
|
||||
cb_info.CallbackRoutine = ctypes.cast(_callback_inst, ctypes.c_void_p).value
|
||||
cb_info.CallbackParam = None
|
||||
ok = MiniDumpWriteDump(h_snap, args.pid, h_file, DUMP_TYPE, None, None, ctypes.byref(cb_info))
|
||||
if not ok:
|
||||
err = ctypes.get_last_error()
|
||||
print(f"MiniDumpWriteDump failed err={err}")
|
||||
CloseHandle(h_file)
|
||||
PssFreeSnapshot(GetCurrentProcess(), h_snap)
|
||||
CloseHandle(h_proc)
|
||||
sys.exit(5)
|
||||
|
||||
CloseHandle(h_file)
|
||||
PssFreeSnapshot(GetCurrentProcess(), h_snap)
|
||||
CloseHandle(h_proc)
|
||||
|
||||
sz = os.path.getsize(out_path)
|
||||
print(f" OK: {sz/1e6:.1f} MB written")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
tools/compare_mesh_templates.py
Normal file
102
tools/compare_mesh_templates.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""compare_mesh_templates.py <pid>
|
||||
|
||||
Compare the byte signature (+0x04 to +0x18) of orphan vs held meshes
|
||||
to figure out what +0x04 means and whether orphans share a template.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
VTABLE = 0x007ed3b0
|
||||
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
|
||||
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress',ctypes.c_void_p),('AllocationBase',ctypes.c_void_p),
|
||||
('AllocationProtect',wt.DWORD),('PartitionId',wt.WORD),('RegionSize',ctypes.c_size_t),
|
||||
('State',wt.DWORD),('Protect',wt.DWORD),('Type',wt.DWORD)]
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes=[wt.DWORD,wt.BOOL,wt.DWORD]; k.OpenProcess.restype=wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes=[wt.HANDLE,ctypes.c_void_p,ctypes.POINTER(MBI),ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype=ctypes.c_size_t
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
|
||||
|
||||
rw_regions = []
|
||||
mbi=MBI(); addr=0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
if mbi.State==MEM_COMMIT and mbi.Type==MEM_PRIVATE and (mbi.Protect&0xff) in (0x04, 0x40):
|
||||
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
|
||||
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
|
||||
if addr>=0x80000000: break
|
||||
|
||||
mesh_data = {}
|
||||
for base, data in rw_regions:
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-0x40, 4):
|
||||
if struct.unpack_from('<I', data, off)[0] == VTABLE:
|
||||
mesh_data[base + off] = data[off:off+0x40]
|
||||
|
||||
mesh_addrs = set(mesh_data.keys())
|
||||
ref_counts = {a: 0 for a in mesh_data}
|
||||
for base, data in rw_regions:
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-4, 4):
|
||||
v = struct.unpack_from('<I', data, off)[0]
|
||||
if v in mesh_addrs:
|
||||
ra = base + off
|
||||
if ra in mesh_addrs: continue
|
||||
ref_counts[v] += 1
|
||||
|
||||
orphans = [a for a in mesh_data if ref_counts[a] == 0]
|
||||
held = [a for a in mesh_data if ref_counts[a] > 0]
|
||||
print(f'orphans: {len(orphans)} held: {len(held)}')
|
||||
|
||||
# Histogram +0x04 for orphans vs held
|
||||
def hist_off(addrs, offset, top_n=10):
|
||||
c = Counter()
|
||||
for a in addrs:
|
||||
v = struct.unpack_from('<I', mesh_data[a], offset)[0]
|
||||
c[v] += 1
|
||||
return c.most_common(top_n)
|
||||
|
||||
for off in [0x04, 0x08, 0x0c, 0x10, 0x14, 0x18, 0x1c, 0x20]:
|
||||
o = hist_off(orphans, off, 5)
|
||||
h_ = hist_off(held, off, 5)
|
||||
print(f'+0x{off:02x} orphans top5: {[(hex(v), n) for v,n in o]}')
|
||||
print(f'+0x{off:02x} held top5: {[(hex(v), n) for v,n in h_]}')
|
||||
|
||||
# Show: do MULTIPLE distinct held meshes share the same +0x04 = 0x252?
|
||||
held_with_252 = [a for a in held if struct.unpack_from('<I', mesh_data[a], 0x04)[0] == 0x252]
|
||||
orphans_with_252 = [a for a in orphans if struct.unpack_from('<I', mesh_data[a], 0x04)[0] == 0x252]
|
||||
print(f'+0x04=0x252: orphans={len(orphans_with_252)} held={len(held_with_252)}')
|
||||
|
||||
# Now: among orphans, find any "interior" field that differentiates (sample several orphans, look for non-identical bytes)
|
||||
print()
|
||||
print('=== Are all orphans byte-identical for first 0x40? ===')
|
||||
if orphans:
|
||||
first = mesh_data[orphans[0]]
|
||||
distinct = sum(1 for a in orphans if mesh_data[a] != first)
|
||||
print(f'orphans with bytes != first: {distinct} / {len(orphans)}')
|
||||
|
||||
# Find positions where bytes differ
|
||||
if distinct > 0:
|
||||
for i in range(0x40):
|
||||
byte_set = set(mesh_data[a][i] for a in orphans)
|
||||
if len(byte_set) > 1:
|
||||
print(f' +0x{i:02x}: varies — sample values: {sorted(byte_set)[:5]}')
|
||||
|
||||
# Pick an orphan and read its +0x2c field — that's the buffer pointer per earlier analysis
|
||||
print()
|
||||
print('=== Buffer pointer (+0x2c) of orphans ===')
|
||||
buf_addrs = []
|
||||
for a in orphans:
|
||||
bp = struct.unpack_from('<I', mesh_data[a], 0x2c)[0]
|
||||
buf_addrs.append(bp)
|
||||
unique_bufs = len(set(buf_addrs))
|
||||
print(f'orphans: {len(orphans)}, unique +0x2c values: {unique_bufs}')
|
||||
print(f'sample: {[hex(x) for x in buf_addrs[:5]]}')
|
||||
95
tools/count_gr_subclasses_live.py
Normal file
95
tools/count_gr_subclasses_live.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""count_gr_subclasses_live.py <pid>
|
||||
|
||||
Count live instances of each GraphicsResource subclass in a running
|
||||
process by scanning RW heap for vtable pointers. Used to measure
|
||||
whether v5 PurgeResource patch actually drains the leaked instances
|
||||
over time.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
|
||||
|
||||
|
||||
VTABLES = {
|
||||
"RenderSurface": 0x0079a67c,
|
||||
"RenderTexture": 0x0079c198,
|
||||
"CSurface": 0x007ca4dc,
|
||||
"ImgTex": 0x007cab04,
|
||||
"RenderVertexBufferD3D": 0x007e6520,
|
||||
"RenderTextureD3D": 0x00801a18,
|
||||
"RenderSurfaceD3D": 0x00801a94,
|
||||
"RenderIndexStreamD3D": 0x00801b64,
|
||||
}
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
||||
k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
args = ap.parse_args()
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}")
|
||||
sys.exit(2)
|
||||
|
||||
counts = {name: 0 for name in VTABLES}
|
||||
vt_set = {vt: name for name, vt in VTABLES.items()}
|
||||
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
|
||||
and pr in (0x04, 0x40)): # RW or RWX
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", data, off)[0]
|
||||
if v in vt_set:
|
||||
counts[vt_set[v]] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
print(f"PID {args.pid} @ {ts}")
|
||||
print(f"{'class':<25} {'vtable':<12} {'count':>6}")
|
||||
for name, vt in VTABLES.items():
|
||||
marker = " <- LEAKING (v5 patches this)" if name in ("RenderSurface", "RenderTexture") else ""
|
||||
print(f"{name:<25} 0x{vt:08x} {counts[name]:>6}{marker}")
|
||||
total = sum(counts.values())
|
||||
leakers = counts["RenderSurface"] + counts["RenderTexture"]
|
||||
print(f"{'total':<25} {'':<12} {total:>6}")
|
||||
print(f"{'leakers (RS+RT)':<25} {'':<12} {leakers:>6}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
93
tools/count_leak_classes.py
Normal file
93
tools/count_leak_classes.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""count_leak_classes.py <pid>
|
||||
|
||||
Count live instances of all known leak-candidate classes for diagnostic
|
||||
delta analysis. Emit timestamped, machine-parseable output.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
|
||||
|
||||
|
||||
VTABLES = {
|
||||
# GraphicsResource family (texture/surface cache)
|
||||
"GraphicsResource": 0x0079bf64,
|
||||
"RenderSurface": 0x0079a67c,
|
||||
"RenderTexture": 0x0079c198,
|
||||
"CSurface": 0x007ca4dc,
|
||||
"ImgTex": 0x007cab04,
|
||||
"RenderTextureD3D": 0x00801a18,
|
||||
"RenderSurfaceD3D": 0x00801a94,
|
||||
# UI
|
||||
"UIElement_UIItem": 0x007c0498,
|
||||
"NoticeHandler_subvt": 0x007ccb60,
|
||||
# CObjCell family
|
||||
"CObjCell_primary": 0x007c98e8,
|
||||
"CObjCell_subvt": 0x0079385c,
|
||||
"CEnvCell_primary": 0x007c9a60,
|
||||
# Physics
|
||||
"CPhysicsObj": 0x007c78ec,
|
||||
# Already-patched (reference)
|
||||
"Palette": 0x007caa08,
|
||||
"CGfxObj": 0x007ca418,
|
||||
"D3DXMesh": 0x007ed3b0,
|
||||
}
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
args = ap.parse_args()
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
counts = {name: 0 for name in VTABLES}
|
||||
vt_to_name = {vt: name for name, vt in VTABLES.items()}
|
||||
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", data, off)[0]
|
||||
if v in vt_to_name:
|
||||
counts[vt_to_name[v]] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
print(f"PID={args.pid} @ {ts}")
|
||||
for name, vt in VTABLES.items():
|
||||
print(f" {name:<22} 0x{vt:08x} {counts[name]:>6}")
|
||||
76
tools/count_one_pid.py
Normal file
76
tools/count_one_pid.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""count_one_pid.py <pid>
|
||||
Quick scan of a single PID for all leak-class vtable counts.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
|
||||
|
||||
VTABLES = {
|
||||
"uiitem": 0x007c0498,
|
||||
"palette": 0x007caa08,
|
||||
"cphysicsobj": 0x007c78ec,
|
||||
"renderSurf": 0x0079a67c,
|
||||
"renderSurfD3D": 0x00801a94,
|
||||
"renderTexD3D": 0x00801a18,
|
||||
"csurface": 0x007ca4dc,
|
||||
"imgtex": 0x007cab04,
|
||||
"cgfxobj": 0x007ca418,
|
||||
"d3dxmesh": 0x007ed3b0,
|
||||
}
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
counts = {n: 0 for n in VTABLES}
|
||||
vt_to_name = {vt: name for name, vt in VTABLES.items()}
|
||||
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", data, off)[0]
|
||||
if v in vt_to_name:
|
||||
counts[vt_to_name[v]] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
print(f"PID {pid}")
|
||||
for n in VTABLES:
|
||||
print(f" {n:14s} = {counts[n]:6d}")
|
||||
k.CloseHandle(h)
|
||||
86
tools/count_palettes_live.py
Normal file
86
tools/count_palettes_live.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""count_palettes_live.py <pid>
|
||||
Count Palette instances (vtable 0x007caa08) in a live process and
|
||||
break down by refcount.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
VTABLE = 0x007caa08
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD),
|
||||
("PartitionId", wt.WORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD),
|
||||
("Protect", wt.DWORD),
|
||||
("Type", wt.DWORD),
|
||||
]
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualQueryEx = k32.VirtualQueryEx
|
||||
VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
|
||||
ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t]
|
||||
VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
mbi = MEMORY_BASIC_INFORMATION()
|
||||
addr = 0
|
||||
n_total = 0
|
||||
rc_hist = Counter()
|
||||
maintainer_hist = Counter()
|
||||
m_numlinks_hist = Counter()
|
||||
|
||||
while VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
size = mbi.RegionSize
|
||||
st = mbi.State
|
||||
ty = mbi.Type
|
||||
pr = mbi.Protect & 0xFF
|
||||
if st == MEM_COMMIT and ty == MEM_PRIVATE and pr in (0x04, 0x40):
|
||||
buf = (ctypes.c_ubyte * size)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if ReadProcessMemory(h, base, buf, size, ctypes.byref(sz)) and sz.value > 0x48:
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end - 0x48, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == VTABLE:
|
||||
rc = struct.unpack_from("<I", data, off + 0x24)[0]
|
||||
main = struct.unpack_from("<I", data, off + 0x20)[0]
|
||||
numl = struct.unpack_from("<I", data, off + 0x04)[0]
|
||||
rc_hist[rc if rc < 100 else 100] += 1
|
||||
maintainer_hist[1 if main else 0] += 1
|
||||
m_numlinks_hist[numl & 0xff] += 1
|
||||
n_total += 1
|
||||
addr = base + size
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
print(f"PID {pid}: {n_total} Palette instances")
|
||||
print("refcount distribution (top 8):")
|
||||
for rc, n in rc_hist.most_common(8):
|
||||
print(f" rc={rc:<4} {n}")
|
||||
print(f"m_pMaintainer NULL: {maintainer_hist[0]}, non-NULL: {maintainer_hist[1]}")
|
||||
print(f"m_numLinks distribution (top 6):")
|
||||
for ml, n in m_numlinks_hist.most_common(6):
|
||||
print(f" ml={ml:<4} {n}")
|
||||
|
||||
CloseHandle(h)
|
||||
100
tools/count_physobj_partarray.py
Normal file
100
tools/count_physobj_partarray.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Count CPhysicsObj vs CPhysicsPart vs Position in given PIDs."""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys, subprocess
|
||||
from collections import Counter
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
CPHYSICSOBJ_VT = 0x007c78e0 # EoR CPhysicsObj vtable
|
||||
# CPhysicsPart doesn't have its own vtable (no virtual methods); we
|
||||
# detect via its embedded Position pair at offsets 48 + 120.
|
||||
# The signature is: two Position vts 72 bytes apart, with 0x3f800000 at -4.
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def scan_pid(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
return None
|
||||
positions = 0
|
||||
physobjs = 0
|
||||
parts_two_pos = 0 # length-2 Position arrays = CPhysicsPart signature
|
||||
parts_with_scale_1 = 0 # with 0x3f800000 in scale-z slot
|
||||
mbi = MBI(); addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if mbi.State == 0x1000 and mbi.Type == 0x20000 and pr in (0x04, 0x40):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
pos_offs = set()
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", data, off)[0]
|
||||
if v == POSITION_VT:
|
||||
positions += 1
|
||||
pos_offs.add(off)
|
||||
elif v == CPHYSICSOBJ_VT:
|
||||
physobjs += 1
|
||||
# CPhysicsPart: a position at offset N AND another at N+72 AND off>=48
|
||||
for off in pos_offs:
|
||||
if (off + 72) in pos_offs and (off - 72) not in pos_offs:
|
||||
# this is the array head — is this CPhysicsPart?
|
||||
# check -4 byte = scale-z float
|
||||
if off >= 4:
|
||||
m4 = struct.unpack_from("<I", data, off - 4)[0]
|
||||
parts_two_pos += 1
|
||||
if m4 == 0x3f800000: # 1.0f
|
||||
parts_with_scale_1 += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
k.CloseHandle(h)
|
||||
return positions, physobjs, parts_two_pos, parts_with_scale_1
|
||||
|
||||
|
||||
# Gather PIDs + titles
|
||||
out = subprocess.check_output(
|
||||
["powershell.exe", "-NoProfile", "-Command",
|
||||
"Get-Process acclient -EA SilentlyContinue | "
|
||||
"ForEach-Object { \"$($_.Id)|$($_.MainWindowTitle)\" }"],
|
||||
text=True).strip()
|
||||
pids_with_titles = []
|
||||
for ln in out.splitlines():
|
||||
if "|" not in ln: continue
|
||||
a,b = ln.split("|",1)
|
||||
try:
|
||||
pids_with_titles.append((int(a), b))
|
||||
except ValueError: pass
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
want = set(int(p) for p in sys.argv[1:])
|
||||
pids_with_titles = [(p,t) for (p,t) in pids_with_titles if p in want]
|
||||
|
||||
print(f"{'pid':>6} {'positions':>10} {'physobjs':>9} {'parts(2pos)':>12} {'parts(scale1)':>14} title")
|
||||
for pid, title in pids_with_titles:
|
||||
res = scan_pid(pid)
|
||||
if res is None:
|
||||
print(f"{pid:>6} NOACCESS")
|
||||
else:
|
||||
positions, physobjs, parts, parts_s1 = res
|
||||
print(f"{pid:>6} {positions:>10} {physobjs:>9} {parts:>12} {parts_s1:>14} {title}")
|
||||
80
tools/count_position_live.py
Normal file
80
tools/count_position_live.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""count_position_live.py [pid...]
|
||||
Count Position-class instances in live acclient processes.
|
||||
Position vtable = 0x00797910 (EoR).
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys, subprocess
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
pids = [int(p) for p in sys.argv[1:]]
|
||||
else:
|
||||
out = subprocess.check_output(
|
||||
["powershell.exe", "-NoProfile", "-Command",
|
||||
"Get-Process acclient -EA SilentlyContinue | "
|
||||
"ForEach-Object { \"$($_.Id)|$($_.MainWindowTitle)\" }"],
|
||||
text=True).strip()
|
||||
pids = []
|
||||
titles = {}
|
||||
for ln in out.splitlines():
|
||||
if "|" not in ln: continue
|
||||
a, b = ln.split("|", 1)
|
||||
try:
|
||||
pid = int(a)
|
||||
pids.append(pid)
|
||||
titles[pid] = b
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
def count_pid(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: return None
|
||||
cnt = 0
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if mbi.State == 0x1000 and mbi.Type == 0x20000 and pr in (0x04, 0x40):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
||||
cnt += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000: break
|
||||
k.CloseHandle(h)
|
||||
return cnt
|
||||
|
||||
print(f"{'pid':>6} {'positions':>10} title")
|
||||
for pid in sorted(pids):
|
||||
n = count_pid(pid)
|
||||
if n is None:
|
||||
print(f"{pid:>6} NOACCESS")
|
||||
else:
|
||||
try:
|
||||
print(f"{pid:>6} {n:>10} {titles.get(pid, '')}")
|
||||
except NameError:
|
||||
print(f"{pid:>6} {n:>10}")
|
||||
83
tools/count_uiitem_live.py
Normal file
83
tools/count_uiitem_live.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""count_uiitem_live.py <pid>
|
||||
|
||||
Count UIElement_UIItem instances in a live process by scanning RW heap
|
||||
for the primary vtable pointer 0x007c0498.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
|
||||
|
||||
UIITEM_VTABLE = 0x007c0498
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
args = ap.parse_args()
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
# Count UIITEM_VTABLE pointers AND count cleared cells (item-GUID == 0 at +0x5fc)
|
||||
all_instances = []
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", data, off)[0]
|
||||
if v == UIITEM_VTABLE:
|
||||
all_instances.append(mbi.BaseAddress + off)
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
# For each instance, read +0x5fc (item GUID)
|
||||
zero_guid = 0
|
||||
nonzero_guid = 0
|
||||
for inst_addr in all_instances:
|
||||
guid_addr = inst_addr + 0x5fc
|
||||
buf4 = (ctypes.c_ubyte * 4)()
|
||||
sz4 = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, guid_addr, buf4, 4, ctypes.byref(sz4)):
|
||||
guid = struct.unpack("<I", bytes(buf4))[0]
|
||||
if guid == 0:
|
||||
zero_guid += 1
|
||||
else:
|
||||
nonzero_guid += 1
|
||||
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
print(f"PID {args.pid} @ {ts}")
|
||||
print(f" UIElement_UIItem instances: {len(all_instances)}")
|
||||
print(f" cleared (item-GUID = 0): {zero_guid} <- leak signature")
|
||||
print(f" active (item-GUID != 0): {nonzero_guid}")
|
||||
41
tools/count_vtable_instances.py
Normal file
41
tools/count_vtable_instances.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""count_vtable_instances.py <dump.dmp> <vtable_va>
|
||||
Count how many objects in the dump have <vtable_va> as their first DWORD.
|
||||
Print the count and a few sample addresses.
|
||||
"""
|
||||
import struct, sys
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
md = MinidumpFile.parse(sys.argv[1])
|
||||
vt = int(sys.argv[2], 16)
|
||||
rdr = md.get_reader().get_buffered_reader()
|
||||
|
||||
scan = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
|
||||
scan.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
count = 0
|
||||
samples = []
|
||||
for base, size in scan:
|
||||
try:
|
||||
rdr.move(base); buf = rdr.read(size)
|
||||
except Exception: continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end - 4, 4):
|
||||
if struct.unpack_from("<I", buf, off)[0] == vt:
|
||||
count += 1
|
||||
if len(samples) < 5:
|
||||
samples.append(base + off)
|
||||
|
||||
print(f"instances of vtable 0x{vt:08x}: {count}")
|
||||
for s in samples:
|
||||
print(f" 0x{s:08x}")
|
||||
72
tools/count_weenie_live.py
Normal file
72
tools/count_weenie_live.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""count_weenie_live.py <pid> [<pid> ...]
|
||||
Count ACCWeenieObject + CWeenieObject vtable instances in committed-private RW
|
||||
heap regions of one or more live AC clients."""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
|
||||
VTABLES = {
|
||||
"ACCWeenieObject_pri": 0x007e4f70,
|
||||
"ACCWeenieObject_sec": 0x007e4f58,
|
||||
"CWeenieObject_pri": 0x007e4ed8,
|
||||
"CWeenieObject_sec": 0x007e4ec4,
|
||||
}
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
def scan(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) err={ctypes.get_last_error()}"); return None
|
||||
counts = {n: 0 for n in VTABLES}
|
||||
vt_to_name = {vt: name for name, vt in VTABLES.items()}
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
total_bytes = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
total_bytes += sz.value
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", data, off)[0]
|
||||
if v in vt_to_name:
|
||||
counts[vt_to_name[v]] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000: break
|
||||
k.CloseHandle(h)
|
||||
return counts, total_bytes
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"{'pid':>6} {'ACC_pri':>8} {'ACC_sec':>8} {'CW_pri':>8} {'CW_sec':>8} priv_MB")
|
||||
for arg in sys.argv[1:]:
|
||||
pid = int(arg)
|
||||
r = scan(pid)
|
||||
if r is None: continue
|
||||
c, b = r
|
||||
print(f"{pid:6d} {c['ACCWeenieObject_pri']:8d} {c['ACCWeenieObject_sec']:8d} "
|
||||
f"{c['CWeenieObject_pri']:8d} {c['CWeenieObject_sec']:8d} {b/1024/1024:7.1f}")
|
||||
533
tools/dashboard.py
Normal file
533
tools/dashboard.py
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
"""dashboard.py — local fleet leak dashboard.
|
||||
|
||||
Serves on http://localhost:8080.
|
||||
|
||||
/ -> HTML page with charts (WS + per-class instance counts)
|
||||
/data.json -> parsed snapshot data from artifacts/snapshots/main.tsv
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
TSV = ROOT / "artifacts" / "snapshots" / "main.tsv"
|
||||
REPORT_MD = ROOT / "REPORT.md"
|
||||
PORT = 8080
|
||||
|
||||
# Expected column order written by snapshot_compare.py
|
||||
HEADER_COLS = ["timestamp", "client", "label", "pid", "mb",
|
||||
"uiitem", "uiitem_cleared", "uiitem_active",
|
||||
"palette", "cphysicsobj",
|
||||
"renderSurf", "renderSurfD3D", "renderTexD3D",
|
||||
"csurface", "imgtex", "cgfxobj", "d3dxmesh"]
|
||||
|
||||
# Metrics we want to chart, in display order
|
||||
METRICS = ["mb", "palette", "renderSurf", "renderSurfD3D",
|
||||
"renderTexD3D", "csurface", "imgtex", "cphysicsobj",
|
||||
"uiitem"]
|
||||
|
||||
METRIC_LABELS = {
|
||||
"mb": "Memory (MB)",
|
||||
"palette": "Palette instances",
|
||||
"renderSurf": "RenderSurface base instances",
|
||||
"renderSurfD3D": "RenderSurfaceD3D instances",
|
||||
"renderTexD3D": "RenderTextureD3D instances",
|
||||
"csurface": "CSurface instances",
|
||||
"imgtex": "ImgTex instances",
|
||||
"cphysicsobj": "CPhysicsObj instances",
|
||||
"uiitem": "UIElement_UIItem instances",
|
||||
}
|
||||
|
||||
|
||||
def load_tsv():
|
||||
"""Parse main.tsv into {client: [(ts, {metric:val,...}), ...]}.
|
||||
|
||||
Rows where mb == 0 or 'DEAD' are skipped (stale-PID rows).
|
||||
Header row (with literal 'timestamp' in col 1) is skipped.
|
||||
"""
|
||||
out = {}
|
||||
if not TSV.exists():
|
||||
return out
|
||||
with open(TSV) as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\n")
|
||||
if not line: continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 5: continue
|
||||
if parts[0] == "timestamp": continue # header
|
||||
row = {}
|
||||
for i, col in enumerate(HEADER_COLS):
|
||||
if i >= len(parts): break
|
||||
row[col] = parts[i]
|
||||
# Skip dead-PID rows
|
||||
mb_str = row.get("mb", "")
|
||||
if mb_str in ("0", "DEAD", "NOACCESS", ""): continue
|
||||
try:
|
||||
ts = row["timestamp"]
|
||||
client = row["client"]
|
||||
metrics = {}
|
||||
for m in METRICS:
|
||||
v = row.get(m, "")
|
||||
if v == "": continue
|
||||
try: metrics[m] = int(v)
|
||||
except ValueError: pass
|
||||
# also include pid so the dashboard can detect restarts
|
||||
pid_str = row.get("pid", "")
|
||||
if pid_str:
|
||||
try: metrics["pid"] = int(pid_str)
|
||||
except ValueError: pass
|
||||
out.setdefault(client, []).append((ts, metrics))
|
||||
except KeyError:
|
||||
continue
|
||||
# Sort each client's series by timestamp
|
||||
for c in out:
|
||||
out[c].sort(key=lambda x: x[0])
|
||||
# Filter to rows matching the CURRENT (latest) PID per character so we
|
||||
# don't conflate pre-restart series with post-restart series under the
|
||||
# same character name. (Old PIDs become DEAD rows but were once alive
|
||||
# and have data we'd otherwise plot as a misleading plateau.)
|
||||
filtered = {}
|
||||
for c, series in out.items():
|
||||
if not series: continue
|
||||
latest_pid = series[-1][1].get("pid")
|
||||
if latest_pid is None:
|
||||
filtered[c] = series
|
||||
continue
|
||||
kept = [(ts, m) for ts, m in series if m.get("pid") == latest_pid]
|
||||
if kept: filtered[c] = kept
|
||||
return filtered
|
||||
|
||||
|
||||
HTML = """<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<title>Leak-Hunt Fleet Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background:#0f1419; color:#e6e6e6; }
|
||||
h1 { margin: 0 0 8px 0; font-size: 18px; }
|
||||
.meta { color: #888; font-size: 12px; margin-bottom: 16px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.chart-card { background: #1a1f29; border: 1px solid #2a3140; padding: 12px; border-radius: 6px; }
|
||||
.chart-card h2 { margin: 0 0 8px 0; font-size: 13px; color: #ccc; font-weight: 500; }
|
||||
canvas { height: 260px !important; }
|
||||
#status { font-size: 11px; color: #888; }
|
||||
button { background:#243044; color:#eee; border:1px solid #3a4458; padding:4px 10px; cursor:pointer; border-radius:3px; font-size:11px; }
|
||||
button:hover { background:#2e3c54; }
|
||||
.controls { margin-bottom: 12px; }
|
||||
table.summary { border-collapse: collapse; width: 100%; font-size: 11px; margin-bottom: 16px; }
|
||||
table.summary th, table.summary td { border: 1px solid #2a3140; padding: 4px 8px; text-align: right; }
|
||||
table.summary th { background: #1a1f29; cursor: pointer; user-select: none; color: #ccc; font-weight: 500; }
|
||||
table.summary th:hover { background: #243044; }
|
||||
table.summary td.client { text-align: left; font-weight: 500; }
|
||||
table.summary tr:nth-child(even) td { background: #15191f; }
|
||||
table.summary tr.control td { background: #2a1818 !important; color: #ffc; }
|
||||
table.summary tr.danger td.cap { color: #ff5050; font-weight: bold; }
|
||||
table.summary tr.warn td.cap { color: #ffaa00; }
|
||||
table.summary tr.ok td.cap { color: #50e0a0; }
|
||||
.grow-rate-pos { color: #ff8888; }
|
||||
.grow-rate-zero { color: #888; }
|
||||
.pill { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 10px; margin-left: 6px; }
|
||||
.pill-ctrl { background: #4a1818; color: #ffc; }
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>Leak-Hunt Fleet Dashboard <a href="/report" style="font-size:13px;font-weight:normal;color:#5da5da;text-decoration:none;margin-left:16px;">[Final Report →]</a></h1>
|
||||
<div class="meta">
|
||||
<span id="status">loading...</span>
|
||||
|
|
||||
<span>data: <code>artifacts/snapshots/main.tsv</code></span>
|
||||
|
|
||||
<button onclick="loadData()">refresh</button>
|
||||
|
|
||||
<button onclick="toggleAutoRefresh()" id="autoBtn">auto-refresh: off</button>
|
||||
|
|
||||
<button onclick="setRange(0)">all data</button>
|
||||
<button onclick="setRange(24)">last 24h</button>
|
||||
<button onclick="setRange(6)">last 6h</button>
|
||||
</div>
|
||||
<h2 style="font-size:13px;margin:16px 0 6px 0;color:#aaa;">Current state <span style="color:#666;font-weight:normal;font-size:11px;">(click headers to sort; growth rate = avg per hour from last 4 snapshots; cap = projected hours until 2048MB at current rate)</span></h2>
|
||||
<div id="summaryWrap"><table class="summary" id="summary"></table></div>
|
||||
<h2 style="font-size:13px;margin:16px 0 6px 0;color:#aaa;">Charts <span style="color:#666;font-weight:normal;font-size:11px;">(click legend names to toggle clients)</span></h2>
|
||||
<div class="grid" id="charts"></div>
|
||||
<script>
|
||||
const METRICS = __METRICS__;
|
||||
const METRIC_LABELS = __METRIC_LABELS__;
|
||||
const PALETTE = [
|
||||
"#5da5da","#f17cb0","#b2912f","#b276b2","#decf3f","#f15854",
|
||||
"#60bd68","#faa43a","#4d4d4d","#9c27b0","#00bcd4","#ff9800",
|
||||
"#cddc39","#e91e63","#3f51b5"
|
||||
];
|
||||
const CAP_MB = 2048;
|
||||
let charts = {};
|
||||
let autoTimer = null;
|
||||
let rangeHours = 0; // 0 = all
|
||||
let sortKey = "mb";
|
||||
let sortDir = -1; // -1 desc, +1 asc
|
||||
|
||||
function pickColor(client, idx) {
|
||||
if (client.toLowerCase().includes("jerry")) return "#ff3030"; // control = red
|
||||
return PALETTE[idx % PALETTE.length];
|
||||
}
|
||||
|
||||
function parseTs(ts) { return new Date(ts.replace(" ", "T")).getTime(); }
|
||||
|
||||
function filterByRange(series) {
|
||||
if (rangeHours <= 0 || series.length === 0) return series;
|
||||
const cutoff = parseTs(series[series.length-1][0]) - rangeHours * 3600 * 1000;
|
||||
return series.filter(([ts,]) => parseTs(ts) >= cutoff);
|
||||
}
|
||||
|
||||
function buildDatasets(data, metric) {
|
||||
const datasets = [];
|
||||
let idx = 0;
|
||||
for (const client of Object.keys(data).sort()) {
|
||||
const series = filterByRange(data[client]);
|
||||
const points = series
|
||||
.filter(([ts, m]) => m[metric] !== undefined)
|
||||
.map(([ts, m]) => ({ x: ts.replace(" ", "T"), y: m[metric] }));
|
||||
if (points.length === 0) continue;
|
||||
const isCtrl = client.toLowerCase().includes("jerry");
|
||||
datasets.push({
|
||||
label: client + (isCtrl ? " (control)" : ""),
|
||||
data: points,
|
||||
borderColor: pickColor(client, idx),
|
||||
backgroundColor: pickColor(client, idx) + "44",
|
||||
borderWidth: isCtrl ? 2.5 : 1.5,
|
||||
borderDash: isCtrl ? [6,3] : [],
|
||||
pointRadius: 0,
|
||||
tension: 0.15,
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
return datasets;
|
||||
}
|
||||
|
||||
// Compute per-client summary: latest value + hourly growth rate (last 4 snapshots) + projected hours to cap.
|
||||
function buildSummary(data) {
|
||||
const rows = [];
|
||||
for (const client of Object.keys(data)) {
|
||||
const series = data[client];
|
||||
if (series.length === 0) continue;
|
||||
const isCtrl = client.toLowerCase().includes("jerry");
|
||||
const last = series[series.length-1];
|
||||
const lastTs = last[0];
|
||||
const m = last[1];
|
||||
const lastPid = m.pid;
|
||||
// Uptime = time since first row with the CURRENT PID (each restart gets
|
||||
// a new PID, so this excludes pre-restart rows for the same character).
|
||||
let firstTsForPid = lastTs;
|
||||
for (const [ts, row] of series) {
|
||||
if (row.pid === lastPid) { firstTsForPid = ts; break; }
|
||||
}
|
||||
const uptimeH = (parseTs(lastTs) - parseTs(firstTsForPid)) / 3600 / 1000;
|
||||
|
||||
// growth rate = slope from last min(4, length) snapshots
|
||||
const tail = series.slice(Math.max(0, series.length - 4));
|
||||
const rates = {};
|
||||
if (tail.length >= 2) {
|
||||
const t0 = parseTs(tail[0][0]);
|
||||
const t1 = parseTs(tail[tail.length-1][0]);
|
||||
const dtH = (t1 - t0) / 3600 / 1000;
|
||||
if (dtH > 0) {
|
||||
for (const key of METRICS) {
|
||||
const v0 = tail[0][1][key];
|
||||
const v1 = tail[tail.length-1][1][key];
|
||||
if (v0 !== undefined && v1 !== undefined) rates[key] = (v1 - v0) / dtH;
|
||||
}
|
||||
}
|
||||
}
|
||||
// projected hours to 2GB cap
|
||||
let hoursToCap = Infinity;
|
||||
if (rates.mb !== undefined && rates.mb > 0 && m.mb !== undefined) {
|
||||
hoursToCap = (CAP_MB - m.mb) / rates.mb;
|
||||
}
|
||||
rows.push({
|
||||
client, isCtrl, lastTs, uptimeH, m, rates, hoursToCap
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function fmt(v, decimals=0) {
|
||||
if (v === undefined || v === null) return "-";
|
||||
if (!isFinite(v)) return "∞";
|
||||
return v.toLocaleString(undefined, {maximumFractionDigits: decimals, minimumFractionDigits: decimals});
|
||||
}
|
||||
|
||||
function rateClass(r) { if (!isFinite(r) || r === undefined) return ""; if (r === 0) return "grow-rate-zero"; return "grow-rate-pos"; }
|
||||
|
||||
function capClass(h) {
|
||||
if (!isFinite(h)) return "ok";
|
||||
if (h < 24) return "danger";
|
||||
if (h < 72) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const rows = buildSummary(data);
|
||||
rows.sort((a, b) => {
|
||||
let av, bv;
|
||||
if (sortKey === "client") { av = a.client; bv = b.client; }
|
||||
else if (sortKey === "uptime"){ av = a.uptimeH; bv = b.uptimeH; }
|
||||
else if (sortKey === "cap") { av = a.hoursToCap; bv = b.hoursToCap; }
|
||||
else if (sortKey.endsWith("_rate")) {
|
||||
const k = sortKey.replace("_rate","");
|
||||
av = a.rates[k] ?? -1; bv = b.rates[k] ?? -1;
|
||||
}
|
||||
else { av = a.m[sortKey] ?? -1; bv = b.m[sortKey] ?? -1; }
|
||||
if (typeof av === "string") return sortDir * av.localeCompare(bv);
|
||||
return sortDir * (av - bv);
|
||||
});
|
||||
|
||||
const colDefs = [
|
||||
{key:"client", label:"Client"},
|
||||
{key:"uptime", label:"Uptime (h)"},
|
||||
{key:"mb", label:"MB"},
|
||||
{key:"mb_rate", label:"MB/hr"},
|
||||
{key:"cap", label:"hrs→2GB"},
|
||||
{key:"palette", label:"Pal"},
|
||||
{key:"palette_rate", label:"Pal/hr"},
|
||||
{key:"renderSurf", label:"RSurf"},
|
||||
{key:"renderSurf_rate", label:"RSurf/hr"},
|
||||
{key:"renderSurfD3D", label:"RSD3D"},
|
||||
{key:"renderSurfD3D_rate", label:"RSD3D/hr"},
|
||||
{key:"csurface", label:"CSurf"},
|
||||
{key:"csurface_rate", label:"CSurf/hr"},
|
||||
{key:"imgtex", label:"ImgT"},
|
||||
{key:"imgtex_rate", label:"ImgT/hr"},
|
||||
];
|
||||
let html = "<thead><tr>";
|
||||
for (const c of colDefs) {
|
||||
const arrow = (sortKey === c.key) ? (sortDir < 0 ? " ▼" : " ▲") : "";
|
||||
html += `<th onclick="sortBy('${c.key}')">${c.label}${arrow}</th>`;
|
||||
}
|
||||
html += "</tr></thead><tbody>";
|
||||
for (const r of rows) {
|
||||
const cls = r.isCtrl ? "control" : capClass(r.hoursToCap);
|
||||
html += `<tr class="${cls}">`;
|
||||
html += `<td class="client">${r.client}${r.isCtrl?'<span class="pill pill-ctrl">control</span>':''}</td>`;
|
||||
html += `<td>${fmt(r.uptimeH, 1)}</td>`;
|
||||
html += `<td>${fmt(r.m.mb)}</td>`;
|
||||
html += `<td class="${rateClass(r.rates.mb)}">${fmt(r.rates.mb, 1)}</td>`;
|
||||
html += `<td class="cap">${fmt(r.hoursToCap, 0)}</td>`;
|
||||
html += `<td>${fmt(r.m.palette)}</td>`;
|
||||
html += `<td class="${rateClass(r.rates.palette)}">${fmt(r.rates.palette, 0)}</td>`;
|
||||
html += `<td>${fmt(r.m.renderSurf)}</td>`;
|
||||
html += `<td class="${rateClass(r.rates.renderSurf)}">${fmt(r.rates.renderSurf, 0)}</td>`;
|
||||
html += `<td>${fmt(r.m.renderSurfD3D)}</td>`;
|
||||
html += `<td class="${rateClass(r.rates.renderSurfD3D)}">${fmt(r.rates.renderSurfD3D, 0)}</td>`;
|
||||
html += `<td>${fmt(r.m.csurface)}</td>`;
|
||||
html += `<td class="${rateClass(r.rates.csurface)}">${fmt(r.rates.csurface, 0)}</td>`;
|
||||
html += `<td>${fmt(r.m.imgtex)}</td>`;
|
||||
html += `<td class="${rateClass(r.rates.imgtex)}">${fmt(r.rates.imgtex, 0)}</td>`;
|
||||
html += `</tr>`;
|
||||
}
|
||||
html += "</tbody>";
|
||||
document.getElementById("summary").innerHTML = html;
|
||||
}
|
||||
|
||||
function sortBy(k) {
|
||||
if (sortKey === k) sortDir *= -1;
|
||||
else { sortKey = k; sortDir = (k === "client") ? 1 : -1; }
|
||||
if (window._lastData) renderSummary(window._lastData);
|
||||
}
|
||||
|
||||
function setRange(h) {
|
||||
rangeHours = h;
|
||||
if (window._lastData) {
|
||||
for (const m of METRICS) {
|
||||
charts[m].data.datasets = buildDatasets(window._lastData, m);
|
||||
charts[m].update("none");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeChart(metric, canvas, data) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
return new Chart(ctx, {
|
||||
type: "line",
|
||||
data: { datasets: buildDatasets(data, metric) },
|
||||
options: {
|
||||
animation: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { type: "time",
|
||||
time: { unit: "hour", tooltipFormat: "yyyy-MM-dd HH:mm" },
|
||||
ticks: { color: "#999", maxRotation: 0 },
|
||||
grid: { color: "#222" } },
|
||||
y: { ticks: { color: "#999" }, grid: { color: "#222" }, beginAtZero: true }
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { color: "#ccc", boxWidth: 12, font:{size:10} }, position:"bottom" },
|
||||
tooltip: { mode: "nearest" }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const status = document.getElementById("status");
|
||||
status.textContent = "loading...";
|
||||
try {
|
||||
const res = await fetch("/data.json", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
window._lastData = data;
|
||||
const grid = document.getElementById("charts");
|
||||
const totalRows = Object.values(data).reduce((a,s)=>a+s.length,0);
|
||||
const lastTs = Object.values(data).flatMap(s=>s.map(p=>p[0])).sort().slice(-1)[0] || "n/a";
|
||||
status.textContent = `${Object.keys(data).length} clients, ${totalRows} datapoints, last @ ${lastTs}`;
|
||||
renderSummary(data);
|
||||
if (grid.children.length === 0) {
|
||||
// build cards first time
|
||||
for (const m of METRICS) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "chart-card";
|
||||
const h = document.createElement("h2");
|
||||
h.textContent = METRIC_LABELS[m] || m;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "c_" + m;
|
||||
card.append(h); card.append(canvas);
|
||||
grid.append(card);
|
||||
charts[m] = makeChart(m, canvas, data);
|
||||
}
|
||||
} else {
|
||||
// update existing
|
||||
for (const m of METRICS) {
|
||||
charts[m].data.datasets = buildDatasets(data, m);
|
||||
charts[m].update("none");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = "error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const btn = document.getElementById("autoBtn");
|
||||
if (autoTimer) {
|
||||
clearInterval(autoTimer);
|
||||
autoTimer = null;
|
||||
btn.textContent = "auto-refresh: off";
|
||||
} else {
|
||||
autoTimer = setInterval(loadData, 60000); // every 60s
|
||||
btn.textContent = "auto-refresh: 60s";
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
HTML = HTML.replace("__METRICS__", json.dumps(METRICS))
|
||||
HTML = HTML.replace("__METRIC_LABELS__", json.dumps(METRIC_LABELS))
|
||||
|
||||
|
||||
REPORT_HTML = """<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<title>Leak-Hunt — Final Report</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/core.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/c.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/cpp.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/x86asm.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
|
||||
<style>
|
||||
body { font-family: -apple-system, system-ui, "Segoe UI", sans-serif; margin: 0; background: #0f1419; color: #e6e6e6; }
|
||||
.nav { background: #1a1f29; border-bottom: 1px solid #2a3140; padding: 10px 24px; font-size: 13px; }
|
||||
.nav a { color: #5da5da; text-decoration: none; margin-right: 16px; }
|
||||
.nav a:hover { color: #8ec7ff; }
|
||||
main { max-width: 980px; margin: 24px auto; padding: 0 32px 80px; line-height: 1.55; }
|
||||
h1, h2, h3 { color: #fff; border-bottom: 1px solid #2a3140; padding-bottom: 6px; margin-top: 28px; }
|
||||
h1 { font-size: 28px; }
|
||||
h2 { font-size: 20px; }
|
||||
h3 { font-size: 16px; border-bottom: none; }
|
||||
h4 { font-size: 14px; border-bottom: none; }
|
||||
p, li { color: #d8d8d8; }
|
||||
a { color: #5da5da; }
|
||||
code { background: #1a1f29; padding: 1px 5px; border-radius: 3px; font-size: 90%; color: #f1c40f; }
|
||||
pre { background: #0c1014 !important; border: 1px solid #2a3140; border-radius: 5px; padding: 12px; overflow-x: auto; }
|
||||
pre code { background: transparent; padding: 0; color: #d8d8d8; font-size: 12.5px; line-height: 1.45; }
|
||||
table { border-collapse: collapse; margin: 12px 0; }
|
||||
th, td { border: 1px solid #2a3140; padding: 5px 10px; text-align: left; font-size: 13px; }
|
||||
th { background: #1a1f29; color: #ccc; }
|
||||
tr:nth-child(even) td { background: #15191f; }
|
||||
blockquote { border-left: 3px solid #5da5da; margin: 8px 0; padding: 4px 16px; color: #aaa; background: #1a1f29; }
|
||||
hr { border: 0; border-top: 1px solid #2a3140; margin: 24px 0; }
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="nav">
|
||||
<a href="/">← Dashboard</a>
|
||||
<a href="/report">Final Report</a>
|
||||
<span style="color:#666;">Asheron's Call Memory Leak Hunt</span>
|
||||
</div>
|
||||
<main id="content">Loading…</main>
|
||||
<script>
|
||||
async function loadReport() {
|
||||
const r = await fetch('/report.md');
|
||||
if (!r.ok) { document.getElementById('content').innerHTML = '<p>Failed to load REPORT.md (HTTP '+r.status+')</p>'; return; }
|
||||
const md = await r.text();
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
headerIds: true,
|
||||
});
|
||||
document.getElementById('content').innerHTML = marked.parse(md);
|
||||
// Re-run highlight on code blocks
|
||||
document.querySelectorAll('pre code').forEach(block => {
|
||||
try { hljs.highlightElement(block); } catch(e) {}
|
||||
});
|
||||
}
|
||||
hljs.registerLanguage('c', hljs.getLanguage ? undefined : null); // no-op safety
|
||||
loadReport();
|
||||
</script>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args): # silence per-request logs
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/" or self.path.startswith("/index"):
|
||||
self._send(200, "text/html", HTML.encode("utf-8"))
|
||||
elif self.path.startswith("/data.json"):
|
||||
data = load_tsv()
|
||||
payload = json.dumps(data).encode("utf-8")
|
||||
self._send(200, "application/json", payload)
|
||||
elif self.path.startswith("/report.md"):
|
||||
try:
|
||||
body = REPORT_MD.read_bytes()
|
||||
self._send(200, "text/markdown; charset=utf-8", body)
|
||||
except FileNotFoundError:
|
||||
self._send(404, "text/plain", b"REPORT.md not found\n")
|
||||
elif self.path == "/report" or self.path.startswith("/report/"):
|
||||
self._send(200, "text/html", REPORT_HTML.encode("utf-8"))
|
||||
else:
|
||||
self._send(404, "text/plain", b"not found\n")
|
||||
|
||||
def _send(self, code, ctype, body):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def main():
|
||||
with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), Handler) as srv:
|
||||
srv.allow_reuse_address = True
|
||||
print(f"Dashboard listening on http://localhost:{PORT}")
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
162
tools/diff_owner_scans.py
Normal file
162
tools/diff_owner_scans.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""diff_owner_scans.py <low.dmp> <high.dmp>
|
||||
|
||||
Run the owner-vtable scan internally on two dumps and emit a diff:
|
||||
which vtables show up disproportionately in the high-leak dump
|
||||
versus the low-leak baseline. The leaders are residual-leak suspects.
|
||||
|
||||
Output is ranked by ratio (high_count / low_count) with min thresholds
|
||||
to filter noise.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter, defaultdict
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def scan(path):
|
||||
md = MinidumpFile.parse(path)
|
||||
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_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))
|
||||
image_ranges.sort()
|
||||
def is_image(addr):
|
||||
for lo, hi in image_ranges:
|
||||
if lo <= addr < hi:
|
||||
return True
|
||||
if addr < lo:
|
||||
return False
|
||||
return False
|
||||
|
||||
leaked = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
|
||||
and 256*1024 <= r.RegionSize < 512*1024:
|
||||
leaked.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
deltas = [0, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x40, 0x50, 0x60]
|
||||
cand_to_region = {}
|
||||
for base, _sz in leaked:
|
||||
for d in deltas:
|
||||
cand_to_region[base + d] = base
|
||||
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue
|
||||
if ty == 0x1000000: continue
|
||||
if pr not in (0x04, 0x40): continue
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
hits = []
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v in cand_to_region:
|
||||
hits.append((base + off, cand_to_region[v], v, base, off, buf))
|
||||
|
||||
LOOKBACK = 0x200
|
||||
vtable_only = Counter()
|
||||
field_per_vt = defaultdict(Counter)
|
||||
|
||||
for hit_va, _leak_base, _ptr_val, _reg_base, off, buf in hits:
|
||||
start = max(0, off - LOOKBACK)
|
||||
for back in range(off - 4, start - 4, -4):
|
||||
if back < 0: break
|
||||
v = struct.unpack_from("<I", buf, back)[0]
|
||||
if v < 0x00400000 or v > 0x10000000:
|
||||
continue
|
||||
if is_image(v):
|
||||
vtable_only[v] += 1
|
||||
field_per_vt[v][off - back] += 1
|
||||
break
|
||||
|
||||
return {
|
||||
"leaked_regions": len(leaked),
|
||||
"vtable_counts": vtable_only,
|
||||
"field_per_vt": field_per_vt,
|
||||
"mod_of": mod_of,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
low_path, high_path = sys.argv[1], sys.argv[2]
|
||||
print(f"Scanning low-leak control: {low_path}")
|
||||
low = scan(low_path)
|
||||
print(f" leaked regions: {low['leaked_regions']}")
|
||||
print(f" unique vtables: {len(low['vtable_counts'])}")
|
||||
print()
|
||||
print(f"Scanning high-leak target: {high_path}")
|
||||
high = scan(high_path)
|
||||
print(f" leaked regions: {high['leaked_regions']}")
|
||||
print(f" unique vtables: {len(high['vtable_counts'])}")
|
||||
print()
|
||||
|
||||
# Baseline scale = ratio of leaked-region counts. Anything growing
|
||||
# significantly faster than this is suspect.
|
||||
scale = high['leaked_regions'] / max(low['leaked_regions'], 1)
|
||||
print(f"Baseline scale (high_leaked / low_leaked): {scale:.1f}x")
|
||||
print(f" vtables with ratio >> {scale:.0f}x are the residual-leak suspects")
|
||||
print()
|
||||
|
||||
# Combine vtable counts
|
||||
all_vts = set(low['vtable_counts']) | set(high['vtable_counts'])
|
||||
|
||||
# Rank by ratio with a minimum high-count floor so we ignore one-offs
|
||||
MIN_HIGH = 20
|
||||
rows = []
|
||||
for vt in all_vts:
|
||||
lc = low['vtable_counts'].get(vt, 0)
|
||||
hc = high['vtable_counts'].get(vt, 0)
|
||||
if hc < MIN_HIGH:
|
||||
continue
|
||||
# Avoid divide-by-zero; treat 0 baseline as huge ratio
|
||||
ratio = hc / max(lc, 0.5)
|
||||
delta = hc - lc
|
||||
rows.append((vt, lc, hc, ratio, delta))
|
||||
|
||||
# Top by ratio
|
||||
rows.sort(key=lambda r: r[3], reverse=True)
|
||||
print(f"=== Top vtables by ratio (high/low), min high-count {MIN_HIGH} ===")
|
||||
print(f"{'vtable':<12} {'low':>6} {'high':>6} {'ratio':>8} {'delta':>7} module fields")
|
||||
for vt, lc, hc, ratio, delta in rows[:30]:
|
||||
mod = high['mod_of'](vt) or "?"
|
||||
top_offs = high['field_per_vt'][vt].most_common(3)
|
||||
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
|
||||
print(f"0x{vt:08x} {lc:>6} {hc:>6} {ratio:>8.1f} {delta:>7} {mod:<40} {offs_str}")
|
||||
|
||||
# Top by absolute delta (also informative)
|
||||
print()
|
||||
print(f"=== Top vtables by absolute count delta (high - low) ===")
|
||||
rows.sort(key=lambda r: r[4], reverse=True)
|
||||
print(f"{'vtable':<12} {'low':>6} {'high':>6} {'ratio':>8} {'delta':>7} module")
|
||||
for vt, lc, hc, ratio, delta in rows[:20]:
|
||||
mod = high['mod_of'](vt) or "?"
|
||||
print(f"0x{vt:08x} {lc:>6} {hc:>6} {ratio:>8.1f} {delta:>7} {mod}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
tools/dump_260k_content.py
Normal file
73
tools/dump_260k_content.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""dump_260k_content.py <pid>
|
||||
For a few 260KB regions, dump first 128 bytes + mid + tail to characterize
|
||||
whether they're zeroed (free pool), structured (live d3d9 surface), or
|
||||
texture data."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(0x410, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
# Find 260KB regions
|
||||
candidates = []
|
||||
mbi = MBI(); addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
if mbi.State == 0x1000 and mbi.RegionSize == 266240 and (mbi.Type & 0x20000):
|
||||
candidates.append(base)
|
||||
next_addr = base + mbi.RegionSize
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
print(f"260KB regions: {len(candidates)}")
|
||||
|
||||
# Histogram of first DWORD across ALL of them
|
||||
from collections import Counter
|
||||
first_dwords = []
|
||||
for b in candidates:
|
||||
d = rd(h, b, 4)
|
||||
if d: first_dwords.append(struct.unpack('<I', d)[0])
|
||||
c = Counter(first_dwords)
|
||||
print("Top first-DWORD values (offset 0):")
|
||||
for v, n in c.most_common(8):
|
||||
print(f" 0x{v:08x}: {n} ({n*100/len(first_dwords):.1f}%)")
|
||||
|
||||
# Sample a few in detail
|
||||
import random
|
||||
random.seed(42)
|
||||
sample_count = 4
|
||||
sample = random.sample(candidates, sample_count)
|
||||
for b in sample:
|
||||
print(f"\n=== Region @0x{b:08x} (260KB) ===")
|
||||
for off, label in [(0, "first 64B"), (256, "+0x100"),
|
||||
(4096, "+0x1000 (start of pixel data?)"),
|
||||
(133120, "midpoint"),
|
||||
(262144, "+0x40000 (256KB mark)"),
|
||||
(266112, "last 128 bytes")]:
|
||||
d = rd(h, b + off, 64 if 'first' in label or 'last' not in label else 128)
|
||||
if not d: continue
|
||||
# Count zero bytes
|
||||
zeros = d.count(0)
|
||||
non_zero = len(d) - zeros
|
||||
print(f" +0x{off:06x} ({label}, {zeros}/{len(d)} zero): "
|
||||
f"{d[:32].hex(' ')}{'...' if len(d)>32 else ''}")
|
||||
|
||||
k.CloseHandle(h)
|
||||
81
tools/dump_cgfxobj.py
Normal file
81
tools/dump_cgfxobj.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""dump_cgfxobj.py <pid>
|
||||
|
||||
Enumerate CGfxObj instances (vtable 0x007ca418) in a live process and
|
||||
classify by:
|
||||
- refcount (+0x24): how many references
|
||||
- m_pMaintainer (+0x20): NULL = uncached, non-NULL = DBOCache-managed
|
||||
- constructed_mesh (+0x6c): mesh holder
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
VTABLE = 0x007ca418
|
||||
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
|
||||
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress',ctypes.c_void_p),('AllocationBase',ctypes.c_void_p),
|
||||
('AllocationProtect',wt.DWORD),('PartitionId',wt.WORD),('RegionSize',ctypes.c_size_t),
|
||||
('State',wt.DWORD),('Protect',wt.DWORD),('Type',wt.DWORD)]
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes=[wt.DWORD,wt.BOOL,wt.DWORD]; k.OpenProcess.restype=wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes=[wt.HANDLE,ctypes.c_void_p,ctypes.POINTER(MBI),ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype=ctypes.c_size_t
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print('open fail'); sys.exit(1)
|
||||
|
||||
instances = []
|
||||
mbi=MBI(); addr=0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
if mbi.State==MEM_COMMIT and mbi.Type==MEM_PRIVATE and (mbi.Protect&0xff) in (0x04,0x40):
|
||||
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data=bytes(buf[:sz.value]); end=(len(data)//4)*4
|
||||
for off in range(0, end-0x80, 4):
|
||||
if struct.unpack_from('<I', data, off)[0] == VTABLE:
|
||||
obj_addr = mbi.BaseAddress + off
|
||||
refcount = struct.unpack_from('<I', data, off + 0x24)[0]
|
||||
maintainer = struct.unpack_from('<I', data, off + 0x20)[0]
|
||||
mesh = struct.unpack_from('<I', data, off + 0x6c)[0]
|
||||
dataid = struct.unpack_from('<I', data, off + 0x28)[0]
|
||||
instances.append((obj_addr, refcount, maintainer, mesh, dataid))
|
||||
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
|
||||
if addr>=0x80000000: break
|
||||
|
||||
print(f'PID {pid}: {len(instances)} CGfxObj instances')
|
||||
|
||||
# Buckets
|
||||
maint_null_mesh_null = 0
|
||||
maint_null_mesh_set = 0
|
||||
maint_set_mesh_null = 0
|
||||
maint_set_mesh_set = 0
|
||||
rc_hist = Counter()
|
||||
for addr, rc, m, mesh, did in instances:
|
||||
rc_hist[min(rc, 100)] += 1
|
||||
if m == 0 and mesh == 0: maint_null_mesh_null += 1
|
||||
elif m == 0 and mesh != 0: maint_null_mesh_set += 1
|
||||
elif m != 0 and mesh == 0: maint_set_mesh_null += 1
|
||||
else: maint_set_mesh_set += 1
|
||||
|
||||
print(f'Quadrants (m_pMaintainer, constructed_mesh):')
|
||||
print(f' NULL/NULL: {maint_null_mesh_null}')
|
||||
print(f' NULL/SET: {maint_null_mesh_set} <- ORPHAN MESHES (potential leak)')
|
||||
print(f' SET/NULL: {maint_set_mesh_null}')
|
||||
print(f' SET/SET: {maint_set_mesh_set} <- cached with mesh (normal)')
|
||||
|
||||
print('refcount histogram (top 8):')
|
||||
for rc, n in rc_hist.most_common(8):
|
||||
print(f' rc={rc}: {n}')
|
||||
|
||||
# Show 5 examples of orphan
|
||||
print('Examples of NULL/SET orphans:')
|
||||
ex_count = 0
|
||||
for addr, rc, m, mesh, did in instances:
|
||||
if m == 0 and mesh != 0 and ex_count < 5:
|
||||
print(f' obj 0x{addr:08x} rc={rc} mesh=0x{mesh:08x} DataID=0x{did:08x}')
|
||||
ex_count += 1
|
||||
74
tools/dump_hot_region.py
Normal file
74
tools/dump_hot_region.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""dump_hot_region.py <pid> <base_addr> [num_hits=8]
|
||||
|
||||
Dump bytes around N occurrences of 0x0079385c in a specific region,
|
||||
showing 32 bytes before + 64 bytes after each hit. Goal: visualize
|
||||
the surrounding object structure to identify the class.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("base", type=lambda s: int(s, 0))
|
||||
ap.add_argument("--size", type=lambda s: int(s, 0), default=260*1024)
|
||||
ap.add_argument("--n", type=int, default=10)
|
||||
args = ap.parse_args()
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
k.ReadProcessMemory.restype = wt.BOOL
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
buf = (ctypes.c_ubyte * args.size)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, args.base, buf, args.size, ctypes.byref(sz)):
|
||||
print(f"read failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
data = bytes(buf[:sz.value])
|
||||
print(f"Read {sz.value} bytes from 0x{args.base:08x}")
|
||||
|
||||
# Find all hits
|
||||
TARGET = 0x0079385c
|
||||
target_bytes = struct.pack("<I", TARGET)
|
||||
hits = []
|
||||
pos = 0
|
||||
while True:
|
||||
idx = data.find(target_bytes, pos)
|
||||
if idx < 0: break
|
||||
if idx % 4 == 0:
|
||||
hits.append(idx)
|
||||
pos = idx + 1
|
||||
print(f"Found {len(hits)} hits in region")
|
||||
|
||||
# Compute hit-to-hit distances to see if there's a stride
|
||||
dists = [hits[i+1] - hits[i] for i in range(len(hits)-1)]
|
||||
dist_hist = Counter(dists)
|
||||
print("\nTop 15 inter-hit distances (in bytes):")
|
||||
for d, c in dist_hist.most_common(15):
|
||||
print(f" {d:>6} count={c}")
|
||||
|
||||
# Sample N hits and dump context
|
||||
step = max(1, len(hits) // args.n)
|
||||
samples = hits[::step][:args.n]
|
||||
|
||||
print(f"\n=== Sampling {len(samples)} hits with 64 bytes before + 80 after ===")
|
||||
for idx in samples:
|
||||
addr = args.base + idx
|
||||
start = max(0, idx - 64)
|
||||
end = min(len(data), idx + 80)
|
||||
chunk = data[start:end]
|
||||
print(f"\n--- hit at 0x{addr:08x} (region offset 0x{idx:x}) ---")
|
||||
for i in range(0, len(chunk), 16):
|
||||
row = chunk[i:i+16]
|
||||
addr_row = args.base + start + i
|
||||
hex_part = " ".join(f"{b:02x}" for b in row)
|
||||
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in row)
|
||||
marker = " <-- HIT" if (start + i) <= idx < (start + i + 16) else ""
|
||||
print(f" {addr_row:08x} {hex_part:<47} {ascii_part}{marker}")
|
||||
121
tools/dump_leaked_objects.py
Normal file
121
tools/dump_leaked_objects.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""dump_leaked_objects.py <dump.dmp>
|
||||
|
||||
For each object with primary vtable 0x007caa08, dump full state.
|
||||
Also dump the first 256 bytes of the buffer it points to (if any).
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
VTABLE_PRIMARY = 0x007caa08
|
||||
VTABLE_SECONDARY = 0x007ca9f4
|
||||
SECONDARY_OFFSET = 0x30
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Leaked regions
|
||||
leaked = []
|
||||
leaked_set = set()
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
|
||||
and 256*1024 <= r.RegionSize < 512*1024:
|
||||
leaked.append((r.BaseAddress, r.RegionSize))
|
||||
leaked_set.add(r.BaseAddress)
|
||||
def in_leaked(p):
|
||||
if p in leaked_set: return True
|
||||
for b, s in leaked:
|
||||
if b <= p < b + s: return (b, s)
|
||||
return False
|
||||
|
||||
# Find all instances of vtable 0x007caa08 at offset 0
|
||||
scan = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
|
||||
scan.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
objs = []
|
||||
for base, size in scan:
|
||||
try:
|
||||
reader.move(base); buf = reader.read(size)
|
||||
except Exception: continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end - 0x48, 4):
|
||||
if struct.unpack_from("<I", buf, off)[0] != VTABLE_PRIMARY: continue
|
||||
# Verify secondary vtable at +0x30
|
||||
sec = struct.unpack_from("<I", buf, off + SECONDARY_OFFSET)[0]
|
||||
if sec != VTABLE_SECONDARY: continue
|
||||
obj_addr = base + off
|
||||
fields = [struct.unpack_from("<I", buf, off + i*4)[0] for i in range(18)]
|
||||
objs.append((obj_addr, fields))
|
||||
|
||||
print(f"objects with primary vtable 0x{VTABLE_PRIMARY:08x}: {len(objs)}")
|
||||
print()
|
||||
|
||||
# Histogram +0x38 (size field) and +0x40 (buffer) leaked-in
|
||||
size_hist = Counter()
|
||||
buf_leaked = 0
|
||||
buf_null = 0
|
||||
refcount_hist = Counter()
|
||||
for addr, f in objs:
|
||||
# +0x10 = refcount per DBObj (slot 4 of 0x007caa08 is AddRef at refcount +0x10 ... actually
|
||||
# DBObj refcount per DBObj::AddRef decompile is at +0x24)
|
||||
rc = f[9] # +0x24
|
||||
sz_field = f[0xe] # +0x38
|
||||
buf_ptr = f[0x10] # +0x40
|
||||
refcount_hist[rc] += 1
|
||||
size_hist[sz_field] += 1
|
||||
if buf_ptr == 0:
|
||||
buf_null += 1
|
||||
else:
|
||||
r = in_leaked(buf_ptr)
|
||||
if r:
|
||||
buf_leaked += 1
|
||||
|
||||
print(f"+0x40 buffer pointer: null={buf_null}, in_leaked_256-512KB={buf_leaked}")
|
||||
print(f"+0x38 size field top 10:")
|
||||
for sz, n in size_hist.most_common(10):
|
||||
print(f" size=0x{sz:x} ({sz}) count={n}")
|
||||
print(f"+0x24 refcount top 10:")
|
||||
for rc, n in refcount_hist.most_common(10):
|
||||
print(f" rc={rc} count={n}")
|
||||
|
||||
# Show first 10 examples
|
||||
print()
|
||||
print("first 10 objects (addr, vtbl, +4..+0x44):")
|
||||
for addr, f in objs[:10]:
|
||||
flds = " ".join(f"{v:08x}" for v in f)
|
||||
print(f" 0x{addr:08x} {flds}")
|
||||
|
||||
# For objects with non-null +0x40, dump first bytes of that buffer
|
||||
print()
|
||||
print("=== sample buffers at +0x40 (first 64 bytes) ===")
|
||||
n_dumped = 0
|
||||
for addr, f in objs:
|
||||
if n_dumped >= 8: break
|
||||
buf_ptr = f[0x10]
|
||||
if buf_ptr == 0: continue
|
||||
try:
|
||||
reader.move(buf_ptr); raw = reader.read(64)
|
||||
except Exception:
|
||||
continue
|
||||
if not raw: continue
|
||||
h = " ".join(f"{b:02x}" for b in raw)
|
||||
print(f" obj 0x{addr:08x} +0x40 -> 0x{buf_ptr:08x}: {h}")
|
||||
n_dumped += 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
tools/dump_pdb_info.py
Normal file
98
tools/dump_pdb_info.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Dump the PDB info stream so we know exactly which acclient.exe build
|
||||
matches our PDB GUID. The PDB header points to stream 1 ("PDB Info") which
|
||||
contains: u32 version, u32 signature(timestamp), u32 age, 16-byte GUID.
|
||||
|
||||
Usage:
|
||||
py tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
def _ceil_div(a, b):
|
||||
return (a + b - 1) // b
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: dump_pdb_info.py <path-to-pdb>")
|
||||
sys.exit(1)
|
||||
|
||||
pdb_path = sys.argv[1]
|
||||
with open(pdb_path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
magic = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\x00\x00\x00"
|
||||
assert data.startswith(magic), "not an MSF 7.00 PDB"
|
||||
|
||||
block_size = struct.unpack_from("<I", data, 0x20)[0]
|
||||
num_blocks = struct.unpack_from("<I", data, 0x28)[0]
|
||||
num_dir_bytes = struct.unpack_from("<I", data, 0x2C)[0]
|
||||
block_map_addr = struct.unpack_from("<I", data, 0x34)[0]
|
||||
|
||||
print(f"block_size = {block_size}")
|
||||
print(f"num_blocks = {num_blocks}")
|
||||
print(f"num_dir_bytes = {num_dir_bytes}")
|
||||
print(f"block_map_addr = {block_map_addr}")
|
||||
|
||||
def read_page(idx):
|
||||
return data[idx * block_size : (idx + 1) * block_size]
|
||||
|
||||
dir_pages_needed = _ceil_div(num_dir_bytes, block_size)
|
||||
block_map = read_page(block_map_addr)
|
||||
dir_page_indices = struct.unpack_from(f"<{dir_pages_needed}I", block_map, 0)
|
||||
dir_data = bytearray()
|
||||
for pi in dir_page_indices:
|
||||
dir_data.extend(read_page(pi))
|
||||
dir_data = bytes(dir_data)
|
||||
|
||||
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
|
||||
stream_sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
|
||||
print(f"num_streams = {num_streams}")
|
||||
|
||||
offset = 4 + num_streams * 4
|
||||
streams = []
|
||||
for sz in stream_sizes:
|
||||
if sz == 0xFFFFFFFF:
|
||||
streams.append((0, []))
|
||||
continue
|
||||
n_pages = _ceil_div(sz, block_size)
|
||||
pages = struct.unpack_from(f"<{n_pages}I", dir_data, offset)
|
||||
offset += n_pages * 4
|
||||
streams.append((sz, list(pages)))
|
||||
|
||||
# Stream 1 = PDB Info Stream
|
||||
pdb_info_size, pdb_info_pages = streams[1]
|
||||
print(f"pdb_info_size = {pdb_info_size}")
|
||||
|
||||
pdb_info = bytearray()
|
||||
for pi in pdb_info_pages:
|
||||
pdb_info.extend(read_page(pi))
|
||||
pdb_info = bytes(pdb_info[:pdb_info_size])
|
||||
|
||||
version = struct.unpack_from("<I", pdb_info, 0)[0]
|
||||
signature = struct.unpack_from("<I", pdb_info, 4)[0]
|
||||
age = struct.unpack_from("<I", pdb_info, 8)[0]
|
||||
guid_bytes = pdb_info[12:28]
|
||||
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
|
||||
|
||||
sig_dt = datetime.datetime.fromtimestamp(signature, tz=datetime.timezone.utc)
|
||||
|
||||
print()
|
||||
print("=== PDB Info Stream ===")
|
||||
print(f"version = {version}")
|
||||
print(f"signature = 0x{signature:08x} ({signature})")
|
||||
print(f" -> linker timestamp UTC: {sig_dt.isoformat()}")
|
||||
print(f"age = {age}")
|
||||
print(f"GUID = {{{pdb_guid}}}")
|
||||
print()
|
||||
print("This is the GUID + age the matching acclient.exe must reference")
|
||||
print("in its CodeView entry. Find a binary whose linker timestamp")
|
||||
print(f"is around {sig_dt.strftime('%Y-%m-%d')}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
tools/dump_va.py
Normal file
37
tools/dump_va.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""dump_va.py <exe_path> <va> [n=64]
|
||||
Dump n bytes from a PE file at a given VA (using image base + .text section)."""
|
||||
import struct, sys
|
||||
|
||||
def main():
|
||||
path = sys.argv[1]
|
||||
va = int(sys.argv[2], 0)
|
||||
n = int(sys.argv[3]) if len(sys.argv) > 3 else 64
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
|
||||
n_sec = struct.unpack_from('<H', data, pe_off + 4 + 2)[0]
|
||||
opt_sz = struct.unpack_from('<H', data, pe_off + 4 + 16)[0]
|
||||
opt_off = pe_off + 4 + 20
|
||||
img_base = struct.unpack_from('<I', data, opt_off + 28)[0]
|
||||
sec_off = opt_off + opt_sz
|
||||
sections = []
|
||||
for i in range(n_sec):
|
||||
s = sec_off + i * 40
|
||||
name = data[s:s+8].rstrip(b'\x00').decode(errors='replace')
|
||||
vaddr = struct.unpack_from('<I', data, s + 12)[0]
|
||||
vsize = struct.unpack_from('<I', data, s + 8)[0]
|
||||
raddr = struct.unpack_from('<I', data, s + 20)[0]
|
||||
rsize = struct.unpack_from('<I', data, s + 16)[0]
|
||||
sections.append((name, img_base + vaddr, vsize, raddr, rsize))
|
||||
for name, sec_va, vsize, raddr, rsize in sections:
|
||||
if sec_va <= va < sec_va + vsize:
|
||||
file_off = (va - sec_va) + raddr
|
||||
chunk = data[file_off:file_off + n]
|
||||
print(f"@ 0x{va:08x} section=[{name}] file_off=0x{file_off:08x}")
|
||||
print(' '.join(f'{b:02x}' for b in chunk))
|
||||
return
|
||||
print(f"VA 0x{va:08x} not in any section")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
68
tools/dump_vtable.py
Normal file
68
tools/dump_vtable.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""dump_vtable.py <pid> <vtable_va> [n_slots=16]
|
||||
Read N vtable slots from a live process and dump first 16 bytes of each.
|
||||
Useful for fingerprinting classes — compare slot patterns to known vtables."""
|
||||
import ctypes, ctypes.wintypes as wt, sys
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
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
|
||||
|
||||
def read(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)):
|
||||
return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
def main():
|
||||
pid = int(sys.argv[1])
|
||||
vt_va = int(sys.argv[2], 0)
|
||||
n_slots = int(sys.argv[3]) if len(sys.argv) > 3 else 16
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess pid={pid} err={ctypes.get_last_error()}")
|
||||
sys.exit(2)
|
||||
|
||||
# Read N slots (4 bytes each)
|
||||
slot_bytes = read(h, vt_va, n_slots * 4)
|
||||
if not slot_bytes:
|
||||
print(f"ReadProcessMemory @ vtable 0x{vt_va:08x} err={ctypes.get_last_error()}")
|
||||
sys.exit(3)
|
||||
|
||||
print(f"vtable @ 0x{vt_va:08x} in pid {pid}")
|
||||
slots = []
|
||||
for i in range(n_slots):
|
||||
slot = int.from_bytes(slot_bytes[i*4:i*4+4], 'little')
|
||||
slots.append(slot)
|
||||
|
||||
for i, slot in enumerate(slots):
|
||||
prologue = read(h, slot, 16) if slot else None
|
||||
if prologue:
|
||||
hexs = prologue.hex(' ')
|
||||
hints = []
|
||||
if prologue[:1] == b'\x55': hints.append("push ebp")
|
||||
if prologue[:1] == b'\x56': hints.append("push esi")
|
||||
if prologue[:1] == b'\x53': hints.append("push ebx")
|
||||
if prologue[:3] == b'\xb0\x01\xc3': hints.append("mov al,1;ret (return-true stub)")
|
||||
if prologue[:3] == b'\xb0\x00\xc3': hints.append("mov al,0;ret (return-false stub)")
|
||||
if prologue[:3] == b'\x33\xc0\xc3': hints.append("xor eax,eax;ret (return-0 stub)")
|
||||
if prologue[:1] == b'\xc3': hints.append("bare ret (pure no-op)")
|
||||
if prologue[:1] == b'\xc2': hints.append(f"retn imm16")
|
||||
if prologue[:2] == b'\xe9\x00' or prologue[:1] == b'\xe9': hints.append("jmp (thunk)")
|
||||
hint_str = " -- " + "; ".join(hints) if hints else ""
|
||||
print(f" slot[{i:2d}] = 0x{slot:08x} {hexs}{hint_str}")
|
||||
else:
|
||||
print(f" slot[{i:2d}] = 0x{slot:08x} (unreadable)")
|
||||
|
||||
k.CloseHandle(h)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
279
tools/estimate_leak_bytes.py
Normal file
279
tools/estimate_leak_bytes.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""estimate_leak_bytes.py <dump.dmp>
|
||||
|
||||
Estimate total bytes leaked by three families:
|
||||
1. gm*UI panels -- NoticeHandler sub-vtable 0x007ccb60 at offset 0x5f8 of outer obj
|
||||
2. CObjCell/CEnvCell ClipPlaneList -- primary 0x007c98e8 / 0x007c9a60, teardown 0x0079385c at +0x30/+0x54
|
||||
3. CPhysicsObj stranded -- primary vtable 0x007c78ec, plus inner allocations at +0x98 and +0x108
|
||||
|
||||
Method:
|
||||
- Scan all private RW regions for vtable signatures.
|
||||
- For each match, peek at the heap header at (addr - 8) for the user-block size.
|
||||
Win32 NT-heap LFH blocks: size in (header[0] >> 0) * granularity (8 bytes on x86),
|
||||
but the encoded form is XOR'd with HeapKey. We instead approximate sizes by:
|
||||
(a) reading a few candidate offsets in heap headers, picking plausible values
|
||||
(b) for CObjCell/CPhysicsObj, FOLLOWING the inner-buffer pointer and reading
|
||||
ITS heap header similarly, summing
|
||||
(c) fallback: use known per-class size hints from ctor allocation analysis.
|
||||
- Print a comparison table.
|
||||
"""
|
||||
import struct, sys, os
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
# --- vtables of interest -----
|
||||
GM_NOTICE_VT = 0x007ccb60 # NoticeHandler sub-vtable at offset 0x5f8
|
||||
GM_NOTICE_OFFSET = 0x5f8
|
||||
|
||||
COBJCELL_PRIMARY_VT = 0x007c98e8
|
||||
CENVCELL_PRIMARY_VT = 0x007c9a60
|
||||
COBJCELL_TEARDOWN_VT = 0x0079385c # at +0x30 and +0x54 after Destroy()
|
||||
COBJCELL_CLIPPLANE_PTR_OFFSET = 0xdc
|
||||
|
||||
CPHYSICSOBJ_PRIMARY_VT = 0x007c78ec
|
||||
CPHYSICSOBJ_CHILDLIST_OFFSET = 0x98
|
||||
CPHYSICSOBJ_BUFFER_OFFSET = 0x108
|
||||
|
||||
# Fallback per-class sizes (educated guesses when heap header unreadable)
|
||||
GM_UI_DEFAULT_SIZE = 0x800 # 2KB: outer object alone, NoticeHandler at 0x5f8 + tail
|
||||
CPHYSICSOBJ_DEFAULT = 0x180 # 384B instance proper
|
||||
CHILDLIST_DEFAULT = 100 # `new(100)` per the spec
|
||||
CPHYSICSOBJ_BUF_DEF = 0x40 # rough; param_1[0x42] init
|
||||
COBJCELL_DEFAULT = 0x200 # 512B
|
||||
CLIPPLANELIST_HDR = 0x18
|
||||
CLIPPLANE_SIZE = 0x14
|
||||
CLIPPLANE_COUNT_AVG = 8
|
||||
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def get_scan_regions(md):
|
||||
out = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
|
||||
out.append((r.BaseAddress, r.RegionSize))
|
||||
return out
|
||||
|
||||
|
||||
def read_heap_user_size(reader, addr):
|
||||
"""
|
||||
Try to determine the user-block size at `addr`.
|
||||
|
||||
Strategy: scan the 16 bytes before `addr` looking for a 16-bit "BlockSize"
|
||||
field. In a Windows segment-heap or LFH block, the user data is preceded
|
||||
by a small struct where size*granularity > requested size. We look for a
|
||||
DWORD that, when multiplied by 8, yields a plausible size (32B..1MB) and
|
||||
is reasonably close to a power-of-2 round-up.
|
||||
|
||||
Return None if we can't trust the read.
|
||||
"""
|
||||
try:
|
||||
reader.move(addr - 16)
|
||||
raw = reader.read(16)
|
||||
except Exception:
|
||||
return None
|
||||
if not raw or len(raw) < 16:
|
||||
return None
|
||||
# Try various candidate fields. Heap header on x86 is 8 bytes:
|
||||
# [size:WORD][prevSize:WORD][segment_idx:BYTE][flags:BYTE][unused:BYTE][tag:BYTE]
|
||||
# size is XOR-encoded with heap's encoding key. So this is unreliable in
|
||||
# general. We fall back to None.
|
||||
return None
|
||||
|
||||
|
||||
def estimate_region_allocation(reader, regions_by_base, addr, default):
|
||||
"""
|
||||
If `addr` falls inside a region, and that region is suspiciously
|
||||
sized for the family, return the region size as a strong upper-bound
|
||||
estimate. Otherwise return `default`.
|
||||
|
||||
This works because Asheron's Call's leaked objects tend to land in
|
||||
*private* allocations sized in the 256KB..512KB band (per project
|
||||
memory). Smaller objects sit in shared heap regions and we can't
|
||||
isolate them.
|
||||
"""
|
||||
for base, size in regions_by_base:
|
||||
if base <= addr < base + size:
|
||||
# If the region is large (>=64KB), each instance only consumes
|
||||
# a fraction. We can't attribute the whole region to one obj.
|
||||
# Return default which is an authored per-instance estimate.
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def scan_vtable(reader, scan, target_vt):
|
||||
"""Return list of (region_base, offset, abs_addr) for each match."""
|
||||
out = []
|
||||
for base, size in scan:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end - 4, 4):
|
||||
if struct.unpack_from("<I", buf, off)[0] == target_vt:
|
||||
out.append((base, off, base + off, buf))
|
||||
break # We re-scan per region for full coverage below
|
||||
# Full scan
|
||||
return out
|
||||
|
||||
|
||||
def scan_vtable_all(reader, scan, target_vt):
|
||||
"""All hits, not just first per region."""
|
||||
hits = []
|
||||
for base, size in scan:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end - 4, 4):
|
||||
if struct.unpack_from("<I", buf, off)[0] == target_vt:
|
||||
hits.append((base + off, buf, off))
|
||||
return hits
|
||||
|
||||
|
||||
def read_dword(reader, addr):
|
||||
try:
|
||||
reader.move(addr)
|
||||
raw = reader.read(4)
|
||||
if not raw or len(raw) < 4: return None
|
||||
return struct.unpack("<I", raw)[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
md = MinidumpFile.parse(sys.argv[1])
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
scan = get_scan_regions(md)
|
||||
regions = [(b, s) for b, s in scan]
|
||||
print(f"scanning {len(scan)} private RW regions")
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# Family 1: gm*UI via NoticeHandler sub-vtable 0x007ccb60
|
||||
# NoticeHandler sits at offset 0x5f8 inside the outer gm*UI object.
|
||||
# --------------------------------------------------------------
|
||||
notice_hits = scan_vtable_all(reader, scan, GM_NOTICE_VT)
|
||||
print(f"\nNoticeHandler vt 0x{GM_NOTICE_VT:08x}: {len(notice_hits)} matches")
|
||||
|
||||
gm_subclass = Counter()
|
||||
gm_outer_addrs = []
|
||||
for abs_addr, buf, off in notice_hits:
|
||||
outer = abs_addr - GM_NOTICE_OFFSET
|
||||
# Read outer vtable
|
||||
outer_vt = read_dword(reader, outer)
|
||||
if outer_vt is None:
|
||||
continue
|
||||
gm_subclass[outer_vt] += 1
|
||||
gm_outer_addrs.append(outer)
|
||||
|
||||
print(f" unique outer vtables: {len(gm_subclass)}")
|
||||
for vt, n in gm_subclass.most_common(10):
|
||||
print(f" 0x{vt:08x} x{n}")
|
||||
|
||||
# Per-instance size: gm*UI panels are full UI widgets. The NoticeHandler
|
||||
# at 0x5f8 means outer object is AT LEAST 0x5f8 + sizeof(NoticeHandler).
|
||||
# A typical NoticeHandler is ~0x50. Plus child allocations (text buffers,
|
||||
# control list arrays, etc). Conservative: 0x800 = 2KB per instance.
|
||||
# The spec says ~352 instances; we measure however many we actually find.
|
||||
gm_count = len(gm_outer_addrs)
|
||||
gm_per = 0x800 # 2KB
|
||||
gm_total = gm_count * gm_per
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# Family 2: CObjCell ClipPlaneList
|
||||
# Find CObjCell-family instances by primary vtable, then follow +0xdc
|
||||
# to ClipPlaneList inner allocation.
|
||||
# --------------------------------------------------------------
|
||||
cobjcell_hits = []
|
||||
for vt_target in (COBJCELL_PRIMARY_VT, CENVCELL_PRIMARY_VT):
|
||||
hits = scan_vtable_all(reader, scan, vt_target)
|
||||
cobjcell_hits.extend(hits)
|
||||
print(f"\nCObjCell-family vt 0x{vt_target:08x}: {len(hits)} matches")
|
||||
|
||||
# Also count instances with teardown vtable at +0x30 (post-Destroy state)
|
||||
teardown_hits = []
|
||||
for base, size in scan:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end - 0x60, 4):
|
||||
v0 = struct.unpack_from("<I", buf, off)[0]
|
||||
if v0 != COBJCELL_TEARDOWN_VT: continue
|
||||
teardown_hits.append(base + off)
|
||||
print(f"teardown vt 0x{COBJCELL_TEARDOWN_VT:08x} matches: {len(teardown_hits)}")
|
||||
|
||||
# CObjCell instance contributes:
|
||||
# - the CObjCell instance memory itself (~512B)
|
||||
# - the leaked ClipPlaneList inner pointed to by +0xdc:
|
||||
# hdr (~24B) + DArray<ClipPlane>(N * 20B)
|
||||
cobjcell_count = len(cobjcell_hits)
|
||||
if cobjcell_count == 0:
|
||||
# Use teardown hits as proxy
|
||||
cobjcell_count = len(teardown_hits)
|
||||
cell_outer_per = COBJCELL_DEFAULT
|
||||
clipplane_per = CLIPPLANELIST_HDR + (CLIPPLANE_COUNT_AVG * CLIPPLANE_SIZE)
|
||||
cobjcell_per = cell_outer_per + clipplane_per
|
||||
cobjcell_total = cobjcell_count * cobjcell_per
|
||||
|
||||
# If the spec says 132 instances LEAKED but we find more, only the
|
||||
# leaked ones contributed. Per project memory the leak count is 132.
|
||||
# If we found significantly more, those are live instances. Use the
|
||||
# smaller of (scan_count, 132) for an honest total.
|
||||
cobjcell_leaked = min(cobjcell_count, 132) if cobjcell_count > 0 else 132
|
||||
cobjcell_total = cobjcell_leaked * cobjcell_per
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# Family 3: CPhysicsObj
|
||||
# --------------------------------------------------------------
|
||||
phys_hits = scan_vtable_all(reader, scan, CPHYSICSOBJ_PRIMARY_VT)
|
||||
print(f"\nCPhysicsObj vt 0x{CPHYSICSOBJ_PRIMARY_VT:08x}: {len(phys_hits)} matches")
|
||||
|
||||
phys_count = len(phys_hits)
|
||||
# Per-instance contribution:
|
||||
# - the instance itself
|
||||
# - CHILDLIST at +0x98 (100 bytes per spec)
|
||||
# - buffer at +0x108 (~64 bytes for param_1[0x42])
|
||||
phys_per = CPHYSICSOBJ_DEFAULT + CHILDLIST_DEFAULT + CPHYSICSOBJ_BUF_DEF
|
||||
# Cap to known-leaked count of 90 (the rest are live)
|
||||
phys_leaked = min(phys_count, 90) if phys_count > 0 else 90
|
||||
phys_total = phys_leaked * phys_per
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# Comparison table
|
||||
# --------------------------------------------------------------
|
||||
print()
|
||||
print("=" * 72)
|
||||
print(f"{'Family':<28} {'Inst':>6} {'AvgB':>8} {'TotalB':>10} {'TotalKB':>10}")
|
||||
print("-" * 72)
|
||||
rows = [
|
||||
("gm*UI (NoticeHandler)", gm_count, gm_per, gm_total),
|
||||
("CObjCell+ClipPlaneList", cobjcell_leaked, cobjcell_per, cobjcell_total),
|
||||
("CPhysicsObj stranded", phys_leaked, phys_per, phys_total),
|
||||
]
|
||||
grand = sum(r[3] for r in rows) or 1
|
||||
for name, n, per, tot in rows:
|
||||
pct = 100.0 * tot / grand
|
||||
print(f"{name:<28} {n:>6} {per:>8} {tot:>10} {tot/1024:>9.1f} {pct:5.1f}%")
|
||||
print("=" * 72)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
tools/extract_ust_tags.py
Normal file
74
tools/extract_ust_tags.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
extract_ust_tags.py <dump.dmp>
|
||||
|
||||
For every leaked 256-512 KB private RW region in a UST-tagged dump, extract
|
||||
the 4-byte presumptive UST tag at offset 0x14 (the byte after `01 00 00 00`
|
||||
in the heap entry's UST extension header).
|
||||
|
||||
If a single tag dominates the histogram, all (or most) leaked allocations
|
||||
came from the same call site — confirming F1 and identifying the leaking
|
||||
allocation site by its UST stack-trace index.
|
||||
"""
|
||||
import os, struct, sys
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
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()
|
||||
|
||||
cands = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
|
||||
and 256*1024 <= r.RegionSize < 512*1024:
|
||||
cands.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
print(f"candidate 256-512KB private regions: {len(cands)}")
|
||||
|
||||
# Read first 0x30 bytes of each region; classify
|
||||
tag_hist = Counter() # 4-byte tag at +0x14 -> count
|
||||
fmt_hist = Counter() # 8-byte at +0x20 (format header) -> count
|
||||
tag_to_format = {} # tag -> dominant format
|
||||
skipped = 0
|
||||
for base, size in cands:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(0x30)
|
||||
except Exception:
|
||||
skipped += 1; continue
|
||||
if len(buf) < 0x30:
|
||||
skipped += 1; continue
|
||||
|
||||
marker = struct.unpack_from('<I', buf, 0x10)[0] # the '01 00 00 00' marker
|
||||
tag = struct.unpack_from('<I', buf, 0x14)[0] # presumptive UST tag
|
||||
fmt = buf[0x20:0x28]
|
||||
tag_hist[(marker, tag)] += 1
|
||||
fmt_hist[fmt] += 1
|
||||
tag_to_format.setdefault((marker, tag), Counter())[fmt] += 1
|
||||
|
||||
print(f"skipped (unreadable): {skipped}")
|
||||
print()
|
||||
print(f"top (marker, tag) pairs at offsets (0x10, 0x14):")
|
||||
print(f" {'marker':>10} {'tag':>10} {'count':>6} {'dominant fmt header at +0x20':<24}")
|
||||
for (marker, tag), n in tag_hist.most_common(20):
|
||||
fmt_top = tag_to_format[(marker, tag)].most_common(1)[0]
|
||||
fmt_hex = " ".join(f"{b:02x}" for b in fmt_top[0])
|
||||
print(f" 0x{marker:08x} 0x{tag:08x} {n:>6} {fmt_hex}")
|
||||
|
||||
print()
|
||||
print(f"top format headers at +0x20 (8 bytes):")
|
||||
for fmt, n in fmt_hist.most_common(10):
|
||||
fmt_hex = " ".join(f"{b:02x}" for b in fmt)
|
||||
print(f" {fmt_hex} -> {n}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
130
tools/find_all_noop_slots.py
Normal file
130
tools/find_all_noop_slots.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""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()
|
||||
59
tools/find_alloc_size.py
Normal file
59
tools/find_alloc_size.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""find_alloc_size.py <exe_path> <hex_size>
|
||||
Search the .text section for `push <size>` followed by a call (operator new
|
||||
or HeapAlloc) — finds code sites that allocate buffers of that size."""
|
||||
import struct, sys
|
||||
|
||||
def main():
|
||||
path = sys.argv[1]
|
||||
target = int(sys.argv[2], 0)
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
|
||||
n_sec = struct.unpack_from('<H', data, pe_off + 4 + 2)[0]
|
||||
opt_off = pe_off + 4 + 20
|
||||
img_base = struct.unpack_from('<I', data, opt_off + 28)[0]
|
||||
opt_sz = struct.unpack_from('<H', data, pe_off + 4 + 16)[0]
|
||||
sec_off = opt_off + opt_sz
|
||||
for i in range(n_sec):
|
||||
s = sec_off + i*40
|
||||
name = data[s:s+8].rstrip(b'\x00').decode()
|
||||
if name == '.text':
|
||||
text_va = img_base + struct.unpack_from('<I', data, s+12)[0]
|
||||
raddr = struct.unpack_from('<I', data, s+20)[0]
|
||||
rsize = struct.unpack_from('<I', data, s+16)[0]
|
||||
text = data[raddr:raddr+rsize]
|
||||
break
|
||||
|
||||
# Patterns:
|
||||
# 68 XX XX XX XX e8 YY YY YY YY = push imm32; call rel32
|
||||
needle1 = bytes([0x68]) + struct.pack('<I', target)
|
||||
# And:
|
||||
# 6A XX = push imm8 (only if target fits in byte; unlikely for 0x41000)
|
||||
|
||||
print(f"Searching .text for `push 0x{target:08x}` followed by call...")
|
||||
off = 0
|
||||
found = 0
|
||||
while True:
|
||||
off = text.find(needle1, off)
|
||||
if off < 0: break
|
||||
# Check if next instruction (at +5) is a call (E8) or call indirect (FF 15 / FF 14)
|
||||
next_byte = text[off + 5] if off + 5 < len(text) else 0
|
||||
marker = ""
|
||||
if next_byte == 0xE8:
|
||||
target_rel = struct.unpack_from('<i', text, off + 6)[0]
|
||||
call_target = text_va + off + 5 + 5 + target_rel
|
||||
marker = f"-> call 0x{call_target:08x}"
|
||||
elif next_byte == 0xFF:
|
||||
marker = "-> call [...]"
|
||||
else:
|
||||
marker = f"-> next op 0x{next_byte:02x}"
|
||||
va = text_va + off
|
||||
print(f" 0x{va:08x}: push 0x{target:08x} {marker}")
|
||||
found += 1
|
||||
off += 5
|
||||
|
||||
print(f"\nTotal: {found} sites")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
98
tools/find_d3d9_via_iat.py
Normal file
98
tools/find_d3d9_via_iat.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""find_d3d9_via_iat.py <pid>
|
||||
Find d3d9.dll's load address by walking AC's import table.
|
||||
Strategy: locate the string "Direct3DCreate9" in AC's image, then find
|
||||
the IAT entry that resolves it. The IAT slot holds the runtime address
|
||||
of Direct3DCreate9 (= address INSIDE d3d9.dll). VirtualQuery on that
|
||||
address gives us d3d9.dll's base + size."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
# AC's image starts at 0x00400000. Read its full size from PE header.
|
||||
hdr = rd(h, 0x00400000 + 0x3C, 4)
|
||||
if not hdr: print("can't read AC PE header"); sys.exit(2)
|
||||
pe_off = struct.unpack('<I', hdr)[0]
|
||||
img_sz = struct.unpack('<I', rd(h, 0x00400000 + pe_off + 4 + 20 + 56, 4))[0]
|
||||
print(f"AC image: 0x00400000 .. 0x{0x00400000+img_sz:08x} ({img_sz/1024/1024:.1f} MB)")
|
||||
|
||||
# Read entire AC image
|
||||
ac = rd(h, 0x00400000, img_sz)
|
||||
if not ac: print("can't read AC image"); sys.exit(2)
|
||||
|
||||
# Find import directory: data dir entry 1
|
||||
data_dir_off = pe_off + 4 + 20 + 96 + 8 # 8 = entry 1 * 8 bytes per entry
|
||||
import_rva, import_size = struct.unpack_from('<II', ac, data_dir_off)
|
||||
print(f"Import dir RVA=0x{import_rva:x} size={import_size}")
|
||||
|
||||
# Walk import descriptors looking for d3d9.dll
|
||||
off = import_rva
|
||||
n_desc = 0
|
||||
while off + 20 <= len(ac):
|
||||
olt, ts, fc, name_rva, fthunk = struct.unpack_from('<IIIII', ac, off)
|
||||
if olt == 0 and name_rva == 0 and fthunk == 0:
|
||||
print(f"end of import desc at #{n_desc} (off=0x{off:x})")
|
||||
break
|
||||
if name_rva == 0:
|
||||
off += 20; n_desc += 1
|
||||
continue
|
||||
name = ac[name_rva:name_rva+32].split(b'\x00', 1)[0].decode(errors='replace')
|
||||
print(f" desc[{n_desc}] dll={name!r} IAT-RVA=0x{fthunk:x}")
|
||||
n_desc += 1
|
||||
if 'd3d9' in name.lower():
|
||||
print(f"\nFound import for {name}")
|
||||
print(f" OriginalFirstThunk RVA=0x{olt:x}")
|
||||
print(f" FirstThunk (IAT) RVA=0x{fthunk:x}")
|
||||
# Walk OLT to find Direct3DCreate9 index
|
||||
oft = olt or fthunk
|
||||
idx = 0
|
||||
while oft + 4 <= len(ac):
|
||||
entry = struct.unpack_from('<I', ac, oft)[0]
|
||||
if entry == 0: break
|
||||
if entry & 0x80000000:
|
||||
func_name = f"<ord {entry & 0xFFFF}>"
|
||||
else:
|
||||
# entry is an RVA to a Hint/Name struct: hint(2) + name(asciiz)
|
||||
func_name = ac[entry+2:entry+64].split(b'\x00', 1)[0].decode(errors='replace')
|
||||
# Read the IAT slot value (runtime address)
|
||||
iat_slot_va = 0x00400000 + fthunk + idx*4
|
||||
iat_val = struct.unpack('<I', rd(h, iat_slot_va, 4))[0]
|
||||
print(f" [{idx:2d}] {func_name:30s} IAT@0x{iat_slot_va:08x} -> 0x{iat_val:08x}")
|
||||
if func_name == 'Direct3DCreate9':
|
||||
# Use this to find d3d9.dll's range
|
||||
mbi = MBI()
|
||||
if k.VirtualQueryEx(h, iat_val, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
ab = mbi.AllocationBase or 0
|
||||
# Get image size from PE
|
||||
hdr2 = rd(h, ab + 0x3C, 4)
|
||||
pe2 = struct.unpack('<I', hdr2)[0]
|
||||
sz2 = struct.unpack('<I', rd(h, ab + pe2 + 4 + 20 + 56, 4))[0]
|
||||
print(f"\n*** d3d9.dll loaded at 0x{ab:08x}, size {sz2}")
|
||||
oft += 4
|
||||
idx += 1
|
||||
break
|
||||
off += 20
|
||||
else:
|
||||
print("walk fell off")
|
||||
|
||||
k.CloseHandle(h)
|
||||
113
tools/find_d3d_device.py
Normal file
113
tools/find_d3d_device.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""find_d3d_device.py <pid>
|
||||
Find AC's live IDirect3DDevice9 instance.
|
||||
1. Enumerate loaded modules; locate d3d9.dll's address range
|
||||
2. Walk private RW memory; find any pointer where:
|
||||
- The pointer itself is in private RW memory (object on heap)
|
||||
- The first DWORD (vtable) lies in d3d9.dll's read-only data range
|
||||
3. Report the first few matches. The one used most often is THE device."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct, collections
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
def find_d3d9_range(h):
|
||||
"""Find d3d9.dll's load range by looking at image-mapped regions and
|
||||
reading their PE headers to identify the module name."""
|
||||
candidates = []
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
seen_bases = set()
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
sz = mbi.RegionSize
|
||||
if mbi.State == 0x1000 and mbi.Type == 0x1000000: # MEM_IMAGE
|
||||
ab = mbi.AllocationBase or 0
|
||||
if ab not in seen_bases:
|
||||
seen_bases.add(ab)
|
||||
# Read PE header to identify the dll
|
||||
pe_off_b = rd(h, ab + 0x3C, 4)
|
||||
if pe_off_b and len(pe_off_b) == 4:
|
||||
pe_off = struct.unpack('<I', pe_off_b)[0]
|
||||
if pe_off < 0x1000:
|
||||
# Read SizeOfImage from optional header
|
||||
sz_b = rd(h, ab + pe_off + 4 + 20 + 56, 4)
|
||||
if sz_b:
|
||||
img_size = struct.unpack('<I', sz_b)[0]
|
||||
candidates.append((ab, img_size))
|
||||
next_addr = base + sz
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
# For each candidate image, look for the module name in its export table.
|
||||
# Cheaper: scan first 1KB for "d3d9" string.
|
||||
for ab, sz in candidates:
|
||||
chunk = rd(h, ab, 4096)
|
||||
if chunk and b'd3d9' in chunk.lower():
|
||||
return ab, sz
|
||||
return None, None
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
d3d9_base, d3d9_size = find_d3d9_range(h)
|
||||
print(f"d3d9.dll: base=0x{d3d9_base:08x} size={d3d9_size}" if d3d9_base else "d3d9.dll not found")
|
||||
if not d3d9_base:
|
||||
k.CloseHandle(h); sys.exit(0)
|
||||
|
||||
# Walk private RW; for each region, find DWORDs in d3d9's range; the addresses
|
||||
# THEMSELVES (where we found the DWORD) are candidates for "is a vtable pointer".
|
||||
# But we want the OBJECT addresses — i.e., the location holding the pointer is +0
|
||||
# of the object. So we report `holding_va` (= object address) where the pointer = vtable.
|
||||
vtable_hits = collections.Counter() # vtable_va -> count of pointers to it found
|
||||
sample_holders = collections.defaultdict(list)
|
||||
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
sz = mbi.RegionSize
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000 and (mbi.Protect & 0xFF) in (0x04, 0x40)
|
||||
and sz < 32*1024*1024):
|
||||
data = rd(h, base, sz)
|
||||
if data:
|
||||
n = len(data) // 4
|
||||
for i in range(n):
|
||||
v = struct.unpack_from('<I', data, i*4)[0]
|
||||
if d3d9_base <= v < d3d9_base + d3d9_size:
|
||||
# Looks like a pointer into d3d9 — IF v is itself a vtable
|
||||
# (rather than mid-code), the holder is a COM object.
|
||||
# We're interested in cases where this dword is at offset 0
|
||||
# of an aligned object. For now, just count vtable popularity.
|
||||
vtable_hits[v] += 1
|
||||
holder = base + i*4
|
||||
if len(sample_holders[v]) < 3:
|
||||
sample_holders[v].append(holder)
|
||||
next_addr = base + sz
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
k.CloseHandle(h)
|
||||
|
||||
print(f"\nTop 15 d3d9.dll pointer destinations (likely vtables):")
|
||||
print(f" {'vtable':>10} {'count':>6} sample holders")
|
||||
for vt, count in vtable_hits.most_common(15):
|
||||
holders_s = ' '.join(f'0x{h_:08x}' for h_ in sample_holders[vt])
|
||||
print(f" 0x{vt:08x} {count:>6} {holders_s}")
|
||||
121
tools/find_eor_rendersurface.py
Normal file
121
tools/find_eor_rendersurface.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""
|
||||
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()
|
||||
63
tools/find_holder.py
Normal file
63
tools/find_holder.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""find_holder.py <pid> <target_va>
|
||||
Scan all committed private RW memory in process for pointers equal to target_va.
|
||||
Reports addresses where the pointer was found + small context."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
target = int(sys.argv[2], 0)
|
||||
target_bytes = struct.pack('<I', target)
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
found = []
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
sz = mbi.RegionSize
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000 and (mbi.Protect & 0xFF) in (0x04, 0x40)
|
||||
and sz < 64*1024*1024):
|
||||
data = rd(h, base, sz)
|
||||
if data:
|
||||
off = 0
|
||||
while True:
|
||||
off = data.find(target_bytes, off)
|
||||
if off < 0: break
|
||||
if (off & 3) == 0:
|
||||
holder_va = base + off
|
||||
# Read 32 bytes around to see what context
|
||||
ctx = data[max(0, off-16): off+16].hex(' ')
|
||||
found.append((holder_va, ctx))
|
||||
if len(found) >= 20: break
|
||||
off += 4
|
||||
if len(found) >= 20: break
|
||||
next_addr = base + sz
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
k.CloseHandle(h)
|
||||
|
||||
print(f"Pointers to 0x{target:08x} (top {len(found)}):")
|
||||
for holder_va, ctx in found:
|
||||
print(f" @0x{holder_va:08x}: ...{ctx}...")
|
||||
15
tools/find_literals.py
Normal file
15
tools/find_literals.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""find_literals.py <exe> <hex_value>
|
||||
Count occurrences of a 32-bit LE literal in the binary."""
|
||||
import struct, sys
|
||||
path = sys.argv[1]
|
||||
target = int(sys.argv[2], 0)
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
needle = struct.pack('<I', target)
|
||||
off = 0; n = 0
|
||||
while True:
|
||||
off = data.find(needle, off)
|
||||
if off < 0: break
|
||||
n += 1
|
||||
off += 1
|
||||
print(f"0x{target:x}: {n} occurrences in binary")
|
||||
111
tools/find_mesh_holders.py
Normal file
111
tools/find_mesh_holders.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""find_mesh_holders.py <pid>
|
||||
|
||||
Find what classes hold D3DXMesh (vtable 0x007ed3b0) references in a
|
||||
live process.
|
||||
|
||||
Method:
|
||||
1. Enumerate D3DXMesh instances (DWORDs equal to vtable 0x007ed3b0)
|
||||
2. Scan all committed RW memory for DWORDs equal to a D3DXMesh
|
||||
instance address — those are pointers TO meshes
|
||||
3. For each pointer hit, walk backwards up to 0x100 bytes within
|
||||
the same region to find the containing object's vtable
|
||||
4. Histogram by (owner_vtable, offset_of_mesh_pointer_within_owner)
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
VTABLE = 0x007ed3b0
|
||||
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
|
||||
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000; MEM_IMAGE=0x1000000
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress',ctypes.c_void_p),('AllocationBase',ctypes.c_void_p),
|
||||
('AllocationProtect',wt.DWORD),('PartitionId',wt.WORD),('RegionSize',ctypes.c_size_t),
|
||||
('State',wt.DWORD),('Protect',wt.DWORD),('Type',wt.DWORD)]
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes=[wt.DWORD,wt.BOOL,wt.DWORD]; k.OpenProcess.restype=wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes=[wt.HANDLE,ctypes.c_void_p,ctypes.POINTER(MBI),ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype=ctypes.c_size_t
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print('open fail'); sys.exit(1)
|
||||
|
||||
# Pass 1: enumerate regions and snapshot RW + image
|
||||
rw_regions = [] # (base, data)
|
||||
image_ranges = []
|
||||
mbi=MBI(); addr=0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
if mbi.State==MEM_COMMIT and (mbi.Protect&0xff) != 0x01:
|
||||
if mbi.Type==MEM_IMAGE:
|
||||
image_ranges.append((mbi.BaseAddress, mbi.BaseAddress+mbi.RegionSize))
|
||||
elif mbi.Type==MEM_PRIVATE and (mbi.Protect&0xff) in (0x04, 0x40):
|
||||
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)) and sz.value:
|
||||
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
|
||||
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
|
||||
if addr>=0x80000000: break
|
||||
|
||||
def is_image(p):
|
||||
for lo, hi in image_ranges:
|
||||
if lo <= p < hi: return True
|
||||
return False
|
||||
|
||||
# Pass 2: find all D3DXMesh instance addresses
|
||||
mesh_addrs = set()
|
||||
for base, data in rw_regions:
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-4, 4):
|
||||
if struct.unpack_from('<I', data, off)[0] == VTABLE:
|
||||
mesh_addrs.add(base + off)
|
||||
print(f'D3DXMesh instances: {len(mesh_addrs)}')
|
||||
|
||||
# Pass 3: scan all RW memory for DWORDs equal to a mesh address (= pointer to a mesh)
|
||||
hits = [] # list of (referring_addr, mesh_addr)
|
||||
mesh_addr_set = mesh_addrs
|
||||
for base, data in rw_regions:
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-4, 4):
|
||||
v = struct.unpack_from('<I', data, off)[0]
|
||||
if v in mesh_addr_set:
|
||||
# exclude self-references (vtable slot inside the mesh's own object data)
|
||||
if base + off in mesh_addr_set: continue
|
||||
hits.append((base + off, v, base, off, data))
|
||||
print(f'pointers TO meshes: {len(hits)}')
|
||||
|
||||
# Pass 4: for each hit, look backwards within same region for vtable (image-resident DWORD)
|
||||
LOOKBACK = 0x100
|
||||
vtable_hits = Counter()
|
||||
field_offsets = Counter()
|
||||
examples = {}
|
||||
no_vt = 0
|
||||
for hit_va, ptr_val, base, off, data in hits:
|
||||
found = False
|
||||
start = max(0, off - LOOKBACK)
|
||||
for back in range(off - 4, start - 4, -4):
|
||||
if back < 0: break
|
||||
v = struct.unpack_from('<I', data, back)[0]
|
||||
if 0x00400000 <= v < 0x10000000 and is_image(v):
|
||||
field_off = off - back
|
||||
vtable_hits[(v, field_off)] += 1
|
||||
field_offsets[v] += 1
|
||||
ex = examples.setdefault((v, field_off), [])
|
||||
if len(ex) < 2:
|
||||
ex.append((hit_va, ptr_val))
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
no_vt += 1
|
||||
|
||||
print(f'pointers with no preceding vtable in 0x100 lookback: {no_vt}')
|
||||
print()
|
||||
print('=== Top owner vtables (D3DXMesh refs) ===')
|
||||
for vt, cnt in field_offsets.most_common(15):
|
||||
print(f' 0x{vt:08x} total refs: {cnt}')
|
||||
print()
|
||||
print('=== Top (vtable, field_offset) pairs ===')
|
||||
for (vt, fo), cnt in vtable_hits.most_common(20):
|
||||
ex = examples[(vt, fo)][0]
|
||||
print(f' 0x{vt:08x} +0x{fo:03x} count={cnt} e.g. hit@0x{ex[0]:08x} -> mesh@0x{ex[1]:08x}')
|
||||
93
tools/find_mesh_refs_inc_static.py
Normal file
93
tools/find_mesh_refs_inc_static.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""find_mesh_refs_inc_static.py <pid>
|
||||
|
||||
Like trace_mesh_holder but ALSO scans MEM_IMAGE regions (e.g., .data
|
||||
section of acclient.exe) for references to D3DXMesh instances.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
|
||||
VTABLE = 0x007ed3b0
|
||||
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
|
||||
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000; MEM_IMAGE=0x1000000
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress',ctypes.c_void_p),('AllocationBase',ctypes.c_void_p),
|
||||
('AllocationProtect',wt.DWORD),('PartitionId',wt.WORD),('RegionSize',ctypes.c_size_t),
|
||||
('State',wt.DWORD),('Protect',wt.DWORD),('Type',wt.DWORD)]
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes=[wt.DWORD,wt.BOOL,wt.DWORD]; k.OpenProcess.restype=wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes=[wt.HANDLE,ctypes.c_void_p,ctypes.POINTER(MBI),ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype=ctypes.c_size_t
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
|
||||
|
||||
all_regions = [] # tuples (base, data, kind)
|
||||
mbi=MBI(); addr=0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if mbi.State==MEM_COMMIT and pr != 0x01:
|
||||
kind = 'priv' if mbi.Type==MEM_PRIVATE else ('image' if mbi.Type==MEM_IMAGE else 'other')
|
||||
# Read writable regions of EITHER private or image
|
||||
if pr in (0x04, 0x40, 0x02): # RW, ERW, or RO
|
||||
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
all_regions.append((mbi.BaseAddress, bytes(buf[:sz.value]), kind))
|
||||
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
|
||||
if addr>=0x80000000: break
|
||||
|
||||
# Find mesh instance addresses (only in heap RW; not in image)
|
||||
mesh_addrs = set()
|
||||
for base, data, kind in all_regions:
|
||||
if kind != 'priv': continue
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-4, 4):
|
||||
if struct.unpack_from('<I', data, off)[0] == VTABLE:
|
||||
mesh_addrs.add(base + off)
|
||||
print(f'mesh instances found: {len(mesh_addrs)}')
|
||||
|
||||
# Count references to each mesh, distinguishing heap vs image
|
||||
heap_refs = {a: 0 for a in mesh_addrs}
|
||||
image_refs = {a: 0 for a in mesh_addrs}
|
||||
for base, data, kind in all_regions:
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-4, 4):
|
||||
v = struct.unpack_from('<I', data, off)[0]
|
||||
if v in mesh_addrs:
|
||||
ra = base + off
|
||||
if ra in mesh_addrs: continue
|
||||
if kind == 'priv':
|
||||
heap_refs[v] += 1
|
||||
elif kind == 'image':
|
||||
image_refs[v] += 1
|
||||
|
||||
# Classify
|
||||
true_orphans = 0 # 0 heap + 0 image refs
|
||||
heap_held = 0 # has at least 1 heap ref
|
||||
image_only = 0 # 0 heap but has image ref(s)
|
||||
for a in mesh_addrs:
|
||||
if heap_refs[a] == 0 and image_refs[a] == 0: true_orphans += 1
|
||||
elif heap_refs[a] > 0: heap_held += 1
|
||||
else: image_only += 1
|
||||
|
||||
print(f'true orphans (0 heap + 0 image refs): {true_orphans}')
|
||||
print(f'heap-held: {heap_held}')
|
||||
print(f'image-only refs (static globals): {image_only}')
|
||||
|
||||
# For image-only refs, show some examples (the static global location)
|
||||
print()
|
||||
print('Sample image-only refs:')
|
||||
shown = 0
|
||||
for a in mesh_addrs:
|
||||
if heap_refs[a] == 0 and image_refs[a] > 0 and shown < 10:
|
||||
# find the image ref locations
|
||||
for base, data, kind in all_regions:
|
||||
if kind != 'image': continue
|
||||
end = (len(data)//4)*4
|
||||
for off in range(0, end-4, 4):
|
||||
if struct.unpack_from('<I', data, off)[0] == a:
|
||||
print(f' mesh @ 0x{a:08x} <- image ref @ 0x{base+off:08x}')
|
||||
shown += 1
|
||||
if shown >= 10: break
|
||||
if shown >= 10: break
|
||||
133
tools/find_palette_cache.py
Normal file
133
tools/find_palette_cache.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""find_palette_cache.py <pid>
|
||||
|
||||
Find the live Palette CLOCache instance in a process.
|
||||
|
||||
Strategy:
|
||||
1. Scan all committed RW memory for DWORD == 0x007c6a98 (DBOCacheVtbl)
|
||||
2. For each hit, treat (hit - 0) as start of a CLOCache object
|
||||
3. Search the next 0x114 bytes for the Palette::Allocator pointer (0x004f7b70)
|
||||
4. If found, that CLOCache is Palette's. Print its address + relevant fields.
|
||||
|
||||
Fields we care about (from FUN_00416b50 / FreelistAdd):
|
||||
+0xf1: byte, cache config flag 1 (freelist enabled?)
|
||||
+0xf2: byte, cache config flag 2
|
||||
+0xfc: freelist MAX size (uint32)
|
||||
+0x100: freelist head ptr
|
||||
+0x104: freelist tail ptr
|
||||
+0x108: freelist current count
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
|
||||
VTABLE_DBOCACHE = 0x007c6a98
|
||||
ALLOCATOR_PALETTE = 0x004f7b70
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
|
||||
MEM_COMMIT = 0x1000
|
||||
MEM_PRIVATE = 0x20000
|
||||
MEM_IMAGE = 0x1000000
|
||||
PAGE_READWRITE = 0x04
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD),
|
||||
("PartitionId", wt.WORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD),
|
||||
("Protect", wt.DWORD),
|
||||
("Type", wt.DWORD),
|
||||
]
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualQueryEx = k32.VirtualQueryEx
|
||||
VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
|
||||
ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t]
|
||||
VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def read(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def main():
|
||||
pid = int(sys.argv[1])
|
||||
h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) err={ctypes.get_last_error()}"); return
|
||||
|
||||
mbi = MEMORY_BASIC_INFORMATION()
|
||||
addr = 0
|
||||
candidates = []
|
||||
while VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
size = mbi.RegionSize
|
||||
st = mbi.State
|
||||
ty = mbi.Type
|
||||
pr = mbi.Protect & 0xFF
|
||||
if st == MEM_COMMIT and ty == MEM_PRIVATE and pr in (PAGE_READWRITE, PAGE_EXECUTE_READWRITE):
|
||||
data = read(h, base, size)
|
||||
if data:
|
||||
for off in range(0, len(data) - 4, 4):
|
||||
v = struct.unpack_from("<I", data, off)[0]
|
||||
if v == VTABLE_DBOCACHE:
|
||||
candidates.append((base + off, data[off:off+0x120]))
|
||||
addr = base + size
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
print(f"DBOCache vtable hits: {len(candidates)}")
|
||||
|
||||
palette_cache = None
|
||||
for addr, blob in candidates:
|
||||
# Search the first 0x114 bytes for Palette::Allocator pointer
|
||||
for o in range(0, min(len(blob), 0x114) - 4, 4):
|
||||
if struct.unpack_from("<I", blob, o)[0] == ALLOCATOR_PALETTE:
|
||||
print(f" CLOCache @ 0x{addr:08x} has ALLOCATOR_PALETTE at offset +0x{o:x}")
|
||||
palette_cache = (addr, blob)
|
||||
break
|
||||
|
||||
if not palette_cache:
|
||||
print("Palette cache NOT found via Allocator pointer. Trying type_id scan...")
|
||||
# Try scanning for type_id == 10 at offset +0x?? of each cache
|
||||
for addr, blob in candidates:
|
||||
for o in range(0, min(len(blob), 0x114) - 4, 4):
|
||||
if struct.unpack_from("<I", blob, o)[0] == 0x0a:
|
||||
print(f" Cache @ 0x{addr:08x} has type_id=0xa at +0x{o:x}")
|
||||
return
|
||||
|
||||
addr, blob = palette_cache
|
||||
print(f"\n=== Palette CLOCache @ 0x{addr:08x} ===")
|
||||
# Read key fields
|
||||
cfg1 = blob[0xf1]
|
||||
cfg2 = blob[0xf2]
|
||||
maxsz = struct.unpack_from("<I", blob, 0xfc)[0]
|
||||
head = struct.unpack_from("<I", blob, 0x100)[0]
|
||||
tail = struct.unpack_from("<I", blob, 0x104)[0]
|
||||
count = struct.unpack_from("<I", blob, 0x108)[0]
|
||||
print(f" +0xf1 cfg1: 0x{cfg1:02x}")
|
||||
print(f" +0xf2 cfg2: 0x{cfg2:02x}")
|
||||
print(f" +0xfc freelist_max: {maxsz} (0x{maxsz:x})")
|
||||
print(f" +0x100 head: 0x{head:08x}")
|
||||
print(f" +0x104 tail: 0x{tail:08x}")
|
||||
print(f" +0x108 count: {count}")
|
||||
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
tools/find_parent_null_writes.py
Normal file
78
tools/find_parent_null_writes.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""find_parent_null_writes.py <exe_path>
|
||||
Scan the .text section for instructions that null the field at offset +0x40
|
||||
of any register-pointed structure (CPhysicsObj's parent field).
|
||||
Patterns:
|
||||
c7 4? 40 00 00 00 00 mov dword ptr [reg+0x40], 0
|
||||
89 4? 40 mov [reg+0x40], reg (then we'd need to check for xor'd reg)
|
||||
c7 8? 40 00 00 00 00 00 00 00 mov [reg+0x40], 0 with disp32
|
||||
Reports each match's VA + 16 bytes of surrounding context."""
|
||||
import struct, sys
|
||||
|
||||
def parse_pe(path):
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
|
||||
coff = pe_off + 4
|
||||
n_sections = struct.unpack_from('<H', data, coff + 2)[0]
|
||||
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
|
||||
opt_off = coff + 20
|
||||
image_base = struct.unpack_from('<I', data, opt_off + 28)[0]
|
||||
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(errors='replace')
|
||||
vaddr = struct.unpack_from('<I', data, s + 12)[0]
|
||||
rsize = struct.unpack_from('<I', data, s + 16)[0]
|
||||
raddr = struct.unpack_from('<I', data, s + 20)[0]
|
||||
sections.append((name, vaddr, rsize, raddr))
|
||||
return data, image_base, sections
|
||||
|
||||
def main():
|
||||
path = sys.argv[1]
|
||||
data, base, sections = parse_pe(path)
|
||||
|
||||
text = None
|
||||
for name, vaddr, rsize, raddr in sections:
|
||||
if name == '.text':
|
||||
text = (base + vaddr, data[raddr:raddr + rsize])
|
||||
break
|
||||
if not text:
|
||||
print("no .text"); sys.exit(2)
|
||||
text_va, text_bytes = text
|
||||
print(f".text @ 0x{text_va:08x} size={len(text_bytes)}")
|
||||
|
||||
# Pattern: c7 4? 40 00 00 00 00 (7 bytes — mov [reg+0x40], imm32 == 0)
|
||||
# The ? is the ModRM byte's low nibble = register encoding.
|
||||
# Modrm 0x40-0x47 = [reg+disp8]; we want 0x40 (disp8 follows = 0x40)
|
||||
# Wait: opcode c7 /0 = mov mem, imm32. ModRM for [reg+disp8] is 01 xxx 100 = 0x44 if base+SIB,
|
||||
# or 01 000 reg for [reg+disp8] = 0x40..0x47.
|
||||
# Actually the agent's site had: c7 46 40 00 00 00 00
|
||||
# c7 = mov reg/mem32, imm32 (opcode /0 in modrm)
|
||||
# 46 = modrm: mod=01 (disp8), reg=/0 (000), rm=110 (esi) → [esi+disp8]
|
||||
# 40 = disp8 = 0x40
|
||||
# 00 00 00 00 = imm32 = 0
|
||||
# So we want: c7 4r 40 00 00 00 00 where r ∈ {0..7} but r=4 (esp) means SIB follows.
|
||||
# Valid r: 0,1,2,3,5,6,7 (skipping esp)
|
||||
matches = []
|
||||
for off in range(len(text_bytes) - 7):
|
||||
b0 = text_bytes[off]
|
||||
if b0 != 0xC7: continue
|
||||
b1 = text_bytes[off + 1]
|
||||
if (b1 & 0xF8) != 0x40: continue # mod=01, reg=000, rm in low 3
|
||||
if b1 == 0x44: continue # rm=esp needs SIB
|
||||
if text_bytes[off + 2] != 0x40: continue # disp8 must be 0x40
|
||||
if text_bytes[off + 3:off + 7] != b'\x00\x00\x00\x00': continue
|
||||
va = text_va + off
|
||||
ctx = text_bytes[max(0, off-8): off+12].hex(' ')
|
||||
regs = ['eax','ecx','edx','ebx','esp','ebp','esi','edi']
|
||||
rm = b1 & 7
|
||||
matches.append((va, regs[rm], ctx))
|
||||
|
||||
print(f"\nFound {len(matches)} occurrences of mov dword ptr [reg+0x40], 0:")
|
||||
print()
|
||||
for va, reg, ctx in matches:
|
||||
print(f" 0x{va:08x} [{reg}+0x40] = 0 context: ...{ctx}...")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
165
tools/find_rendersurfaces.py
Normal file
165
tools/find_rendersurfaces.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""
|
||||
find_rendersurfaces.py <dump.dmp>
|
||||
|
||||
Definitive diagnostic to distinguish:
|
||||
F1 (cache-freelist leak): leaked surfaces have m_numLinks == 1 (cache-only)
|
||||
F2 (upstream-holder leak): leaked surfaces have m_numLinks > 1
|
||||
|
||||
Method:
|
||||
1. Enumerate the leaked 256-512 KB private RW regions (the BGRA buffers).
|
||||
2. For each leaked region's base address R, search the entire committed
|
||||
memory of the dump for any 4-byte value == R. That match location L
|
||||
is a pointer field — most likely RenderSurface::m_pSurfaceBits.
|
||||
3. From L, walk backwards in 4-byte steps looking for a DWORD that points
|
||||
into acclient.exe's image range. That DWORD is the containing object's
|
||||
vtable; its address is the object's base B.
|
||||
4. Read m_numLinks at B + 0x24 (DBObj layout).
|
||||
5. Histogram by (vtable, m_numLinks). The dominant vtable is RenderSurface.
|
||||
The mode of m_numLinks for that vtable answers the question.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
def _enum_int(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: find_rendersurfaces.py <dump.dmp>", file=sys.stderr); sys.exit(1)
|
||||
path = sys.argv[1]
|
||||
if not os.path.exists(path):
|
||||
print(f"not found: {path}", file=sys.stderr); sys.exit(1)
|
||||
|
||||
md = MinidumpFile.parse(path)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
# Acclient.exe image range — used to validate vtable pointers
|
||||
acl = next((m for m in md.modules.modules
|
||||
if os.path.basename(m.name).lower() == "acclient.exe"), None)
|
||||
if acl is None:
|
||||
print("acclient.exe not in module list", file=sys.stderr); sys.exit(1)
|
||||
acl_lo, acl_hi = acl.baseaddress, acl.baseaddress + acl.size
|
||||
print(f"acclient.exe: 0x{acl_lo:08x} - 0x{acl_hi:08x} size={acl.size}")
|
||||
|
||||
# Step 1: leaked 256-512KB private RW regions
|
||||
leaked_regions = set()
|
||||
for r in md.memory_info.infos:
|
||||
st = _enum_int(r.State); ty = _enum_int(r.Type); pr = _enum_int(r.Protect) & 0xFF
|
||||
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
|
||||
and 256*1024 <= r.RegionSize < 512*1024:
|
||||
leaked_regions.add(r.BaseAddress)
|
||||
print(f"leaked 256-512 KB private regions: {len(leaked_regions)}")
|
||||
|
||||
# Step 2: scan every committed (private OR mapped — but not Image) RW region
|
||||
# for occurrences of any leaked-region base address as a 4-byte LE value.
|
||||
# We're looking for pointers in heap allocations, not file-backed data.
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st = _enum_int(r.State); ty = _enum_int(r.Type); pr = _enum_int(r.Protect) & 0xFF
|
||||
if st != 0x1000: # only COMMIT
|
||||
continue
|
||||
if ty == 0x1000000: # skip Image (DLL .data sections — unlikely to hold our pointers)
|
||||
continue
|
||||
if pr in (0x04, 0x40): # READWRITE or EXECUTE_READWRITE
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
print(f"scanning {len(scan_regions)} writable regions...")
|
||||
|
||||
matches = [] # list of (pointer_location, region_value)
|
||||
total_scanned_bytes = 0
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
total_scanned_bytes += len(buf)
|
||||
# Walk 4-byte aligned positions
|
||||
# Fast path: chunk through using struct.unpack_from
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
val = struct.unpack_from("<I", buf, off)[0]
|
||||
if val in leaked_regions:
|
||||
matches.append((base + off, val))
|
||||
print(f"scanned {total_scanned_bytes/(1024*1024):.1f} MB")
|
||||
print(f"pointers to leaked regions found: {len(matches)}")
|
||||
|
||||
# Step 3 + 4: for each pointer location, walk backwards looking for the vtable
|
||||
# of the containing object. Then read m_numLinks at +0x24.
|
||||
findings = [] # list of (object_base, vtable, m_numLinks, m_pMaintainer, m_pSurfaceBits_offset)
|
||||
for ptr_loc, region_val in matches:
|
||||
# Walk backwards in 4-byte steps; max object size 0x140
|
||||
found = False
|
||||
for back in range(0x20, 0x140, 4):
|
||||
obj_base = ptr_loc - back
|
||||
try:
|
||||
reader.move(obj_base)
|
||||
hdr = reader.read(0x40)
|
||||
except Exception:
|
||||
continue
|
||||
if not hdr or len(hdr) < 0x40:
|
||||
continue
|
||||
vtbl = struct.unpack_from("<I", hdr, 0)[0]
|
||||
if not (acl_lo <= vtbl < acl_hi):
|
||||
continue
|
||||
# Candidate. Sanity-check m_pMaintainer (offset 0x20) and m_numLinks (0x24)
|
||||
mtnr, num_links = struct.unpack_from("<II", hdr, 0x20)
|
||||
if not (1 <= num_links <= 1000): # plausible refcount
|
||||
continue
|
||||
if mtnr != 0 and not (0x00010000 <= mtnr < 0x80000000):
|
||||
continue
|
||||
findings.append((obj_base, vtbl, num_links, mtnr, back))
|
||||
found = True
|
||||
break
|
||||
|
||||
print(f"resolved RenderSurface objects: {len(findings)}")
|
||||
print()
|
||||
|
||||
# Group by vtable
|
||||
by_vtable = defaultdict(list)
|
||||
for obj_base, vtbl, num_links, mtnr, off in findings:
|
||||
by_vtable[vtbl].append((num_links, off))
|
||||
|
||||
print(f"vtable groups (sorted by candidate count):")
|
||||
print(f" {'vtable':>10} {'rva':>10} {'count':>6} {'mode m_numLinks':<20} {'m_pSurfaceBits offset histogram':<48}")
|
||||
for vtbl in sorted(by_vtable, key=lambda v: -len(by_vtable[v])):
|
||||
rows = by_vtable[vtbl]
|
||||
rc_counter = Counter(r[0] for r in rows)
|
||||
off_counter = Counter(r[1] for r in rows)
|
||||
rc_top = ", ".join(f"{k}->{v}" for k, v in rc_counter.most_common(5))
|
||||
# Show ALL offsets seen, not just top 3
|
||||
off_full = ", ".join(f"0x{k:x}->{v}" for k, v in off_counter.most_common(8))
|
||||
print(f" 0x{vtbl:08x} 0x{vtbl-acl_lo:08x} {len(rows):>6} {rc_top:<20} {off_full}")
|
||||
|
||||
# The dominant vtable + dominant m_pSurfaceBits offset is RenderSurface.
|
||||
# Report its m_numLinks distribution.
|
||||
top_vtable, top_rows = max(by_vtable.items(), key=lambda kv: len(kv[1]))
|
||||
print()
|
||||
print(f"=== DIAGNOSTIC RESULT ===")
|
||||
print(f"dominant vtable: 0x{top_vtable:08x} RVA 0x{top_vtable-acl_lo:08x}")
|
||||
rc_counter = Counter(r[0] for r in top_rows)
|
||||
print(f"m_numLinks distribution for that vtable ({len(top_rows)} objects):")
|
||||
for rc, cnt in rc_counter.most_common(10):
|
||||
pct = 100.0 * cnt / len(top_rows)
|
||||
print(f" m_numLinks = {rc:>4} {cnt:>5} objects ({pct:.1f}%)")
|
||||
mode_rc = rc_counter.most_common(1)[0][0]
|
||||
print()
|
||||
if mode_rc == 1:
|
||||
print(f"VERDICT: mode m_numLinks == 1 → cache-only (FINDING_001 family — cache-freelist leak)")
|
||||
elif mode_rc > 1:
|
||||
print(f"VERDICT: mode m_numLinks == {mode_rc} → external holder(s) present (FINDING_002 family — upstream leak)")
|
||||
else:
|
||||
print(f"VERDICT: unexpected mode m_numLinks == {mode_rc}; inspect manually")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
tools/find_subclasses.py
Normal file
78
tools/find_subclasses.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""find_subclasses.py <dump.dmp> <parent_vtable> <noop_addr>
|
||||
|
||||
Find vtables that look like subclasses of a parent class (share parent's
|
||||
slot 1 = CopyInto address, indicating inheritance not overridden).
|
||||
For each such vtable, report slot 2 — if it equals noop_addr, the
|
||||
subclass inherits the no-op stub (= leak source).
|
||||
|
||||
Used to find GraphicsResource subclasses that inherit the no-op
|
||||
PurgeResource and therefore leak forever.
|
||||
"""
|
||||
import struct, sys
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
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])
|
||||
parent_vtable = int(sys.argv[2], 0)
|
||||
noop_addr = int(sys.argv[3], 0)
|
||||
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
# Read parent's slot 1 - that's the "shared" value to look for
|
||||
reader.move(parent_vtable + 4)
|
||||
parent_slot1 = struct.unpack("<I", reader.read(4))[0]
|
||||
print(f"Parent vtable: 0x{parent_vtable:08x}")
|
||||
print(f"Parent slot 1 (CopyInto-like): 0x{parent_slot1:08x}")
|
||||
print(f"No-op stub: 0x{noop_addr:08x}")
|
||||
print()
|
||||
|
||||
# Image regions to scan (only image-typed RW)
|
||||
image_ranges = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st == 0x1000 and ty == 0x1000000 and pr in (0x02, 0x04, 0x40):
|
||||
image_ranges.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
print(f"scanning {len(image_ranges)} image regions...")
|
||||
|
||||
found = [] # (vtable_addr, slot0, slot2, slot3)
|
||||
for base, size in image_ranges:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end - 16, 4):
|
||||
slot1 = struct.unpack_from("<I", buf, off + 4)[0]
|
||||
if slot1 != parent_slot1:
|
||||
continue
|
||||
slot0 = struct.unpack_from("<I", buf, off)[0]
|
||||
slot2 = struct.unpack_from("<I", buf, off + 8)[0]
|
||||
slot3 = struct.unpack_from("<I", buf, off + 12)[0]
|
||||
# Sanity: slot 0 (dtor) should be a code pointer in image
|
||||
if slot0 < 0x00400000 or slot0 > 0x10000000:
|
||||
continue
|
||||
# Skip parent itself
|
||||
if base + off == parent_vtable:
|
||||
continue
|
||||
found.append((base + off, slot0, slot2, slot3))
|
||||
|
||||
print(f"candidate subclass vtables: {len(found)}")
|
||||
print()
|
||||
print(f"{'vtable':<12} {'slot0 dtor':<12} {'slot2 Purge':<12} {'slot3 Restore':<12} inherits_noop?")
|
||||
for vt, s0, s2, s3 in found:
|
||||
marker = "*** LEAKS — inherits no-op PurgeResource ***" if s2 == noop_addr else ""
|
||||
print(f"0x{vt:08x} 0x{s0:08x} 0x{s2:08x} 0x{s3:08x} {marker}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
148
tools/find_ust_backtraces.py
Normal file
148
tools/find_ust_backtraces.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""
|
||||
find_ust_backtraces.py <dump.dmp>
|
||||
|
||||
Scans writable memory in the dump for runs of consecutive 4-byte values that
|
||||
all look like return addresses in acclient.exe's executable image range —
|
||||
these are UST backtraces stored in the heap's UserStackTraceDB.
|
||||
|
||||
The Win10 x86 UST database is a `STACK_TRACE_DATABASE` allocated by ntdll;
|
||||
each entry is a `RTL_STACK_TRACE_ENTRY` with:
|
||||
void* HashChain
|
||||
ULONG TraceCount
|
||||
USHORT Index
|
||||
USHORT Depth
|
||||
PVOID BackTrace[Depth] // depth typically 12-16
|
||||
|
||||
We don't try to parse the DB structure (too version-dependent). Instead we
|
||||
detect entries by their backtrace shape: 8+ consecutive pointers into the
|
||||
acclient code range, surrounded by non-pointer data.
|
||||
|
||||
For each detected backtrace, report (top frame, depth, first 8 frames).
|
||||
Histogram by leaf frame to find the dominant allocation site.
|
||||
"""
|
||||
import os, struct, sys
|
||||
from collections import Counter, defaultdict
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Find acclient.exe image range
|
||||
acl = next((m for m in md.modules.modules
|
||||
if os.path.basename(m.name).lower() == "acclient.exe"), None)
|
||||
if acl is None: sys.exit("acclient.exe not in modules")
|
||||
acl_lo, acl_hi = acl.baseaddress, acl.baseaddress + acl.size
|
||||
# Constrain to the .text section. Without PE-header parsing here we
|
||||
# bound: text starts at base+0x1000 (typical), ends around 0x800000 in
|
||||
# EoR (the highest 2013 function RVAs land near 0x6c000; data sections
|
||||
# follow). False positives from .rdata UTF-16 strings (e.g., 0x0066006f
|
||||
# = "of") dominated the first run; tightening to a code-only range
|
||||
# eliminates them.
|
||||
text_lo = acl.baseaddress + 0x1000
|
||||
text_hi = acl.baseaddress + 0x6c0000 # estimated end of .text
|
||||
acl_lo, acl_hi = text_lo, text_hi
|
||||
print(f"acclient.exe text range (estimated): 0x{acl_lo:08x} - 0x{acl_hi:08x}")
|
||||
|
||||
# Iterate writable committed regions (heap-like)
|
||||
regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue
|
||||
if ty == 0x1000000: continue # skip Image
|
||||
if pr in (0x04, 0x40):
|
||||
regions.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
print(f"scanning {len(regions)} writable regions")
|
||||
|
||||
MIN_DEPTH = 6 # min consecutive acclient pointers to call it a backtrace
|
||||
MAX_DEPTH = 32 # cap scan length per candidate
|
||||
|
||||
leaf_hist = Counter()
|
||||
backtraces = [] # list of (location, depth, frames)
|
||||
total_scanned = 0
|
||||
|
||||
for base, size in regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
total_scanned += len(buf)
|
||||
|
||||
# Walk 4-byte aligned positions
|
||||
end = (len(buf) // 4) * 4
|
||||
n = end // 4
|
||||
# Pre-decode as uint32 array for speed
|
||||
words = struct.unpack_from(f"<{n}I", buf, 0)
|
||||
|
||||
def looks_like_code_addr(v):
|
||||
"""True if v looks like an actual code-section pointer, not data."""
|
||||
if not (acl_lo <= v < acl_hi):
|
||||
return False
|
||||
# UTF-16 ASCII pattern: bytes 1 and 3 are 0x00, bytes 0 and 2 are
|
||||
# printable ASCII (0x20-0x7F). Rejects values like 0x0066006f
|
||||
# ("of"), 0x00610074 ("ta"), etc.
|
||||
b0 = v & 0xFF; b1 = (v >> 8) & 0xFF
|
||||
b2 = (v >> 16) & 0xFF; b3 = (v >> 24) & 0xFF
|
||||
if b1 == 0 and b3 == 0 and (0x20 <= b0 <= 0x7F) and (0x20 <= b2 <= 0x7F):
|
||||
return False
|
||||
return True
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
# Find run of consecutive acclient pointers
|
||||
if not looks_like_code_addr(words[i]):
|
||||
i += 1; continue
|
||||
j = i
|
||||
while j < n and looks_like_code_addr(words[j]) and (j - i) < MAX_DEPTH:
|
||||
j += 1
|
||||
depth = j - i
|
||||
if depth >= MIN_DEPTH:
|
||||
frames = words[i:j]
|
||||
# Uniqueness check — real backtraces have >70% unique frames.
|
||||
# Repeated-value runs (e.g. pixel data 0x00a000a0 repeated)
|
||||
# have low uniqueness.
|
||||
uniq = len(set(frames))
|
||||
if uniq < max(4, int(depth * 0.7)):
|
||||
i = j; continue
|
||||
location = base + i * 4
|
||||
backtraces.append((location, depth, frames))
|
||||
leaf_hist[frames[0]] += 1
|
||||
i = j
|
||||
|
||||
print(f"scanned {total_scanned/(1024*1024):.1f} MB")
|
||||
print(f"backtraces detected (>= {MIN_DEPTH} frames): {len(backtraces)}")
|
||||
print()
|
||||
|
||||
# Histogram by leaf frame (top of stack)
|
||||
print(f"top 30 leaf frames (most common 'top-of-call-stack' address):")
|
||||
print(f" {'leaf abs':>10} {'leaf rva':>10} {'count':>6} ")
|
||||
for leaf, cnt in leaf_hist.most_common(30):
|
||||
print(f" 0x{leaf:08x} 0x{leaf-acl_lo:08x} {cnt:>6}")
|
||||
|
||||
print()
|
||||
# For the top leaf, show a few sample backtraces
|
||||
if leaf_hist:
|
||||
top_leaf, _ = leaf_hist.most_common(1)[0]
|
||||
print(f"=== sample backtraces with top leaf 0x{top_leaf:08x} (RVA 0x{top_leaf-acl_lo:08x}) ===")
|
||||
shown = 0
|
||||
for loc, depth, frames in backtraces:
|
||||
if frames[0] != top_leaf: continue
|
||||
print(f" at 0x{loc:08x} depth={depth}")
|
||||
for k, fr in enumerate(frames[:12]):
|
||||
print(f" [{k:2d}] 0x{fr:08x} RVA 0x{fr-acl_lo:08x}")
|
||||
shown += 1
|
||||
if shown >= 5: break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
50
tools/find_vtable_refs.py
Normal file
50
tools/find_vtable_refs.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""find_vtable_refs.py <dump.dmp> <addr_hex>
|
||||
Scan ALL committed memory (not just RW writable) for any DWORD == addr.
|
||||
Used to verify whether an alleged vtable address is referenced anywhere
|
||||
in the dump's process memory.
|
||||
"""
|
||||
import os, struct, sys
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
md = MinidumpFile.parse(sys.argv[1])
|
||||
target = int(sys.argv[2], 16)
|
||||
print(f"searching for 0x{target:08x} in dump {sys.argv[1]}")
|
||||
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
# Scan everything committed
|
||||
hits = []
|
||||
total = 0
|
||||
for r in md.memory_info.infos:
|
||||
st = _ei(r.State); ty = _ei(r.Type); pr = _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue # not committed
|
||||
if pr == 0x01: continue # no-access
|
||||
try:
|
||||
reader.move(r.BaseAddress)
|
||||
buf = reader.read(r.RegionSize)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
total += len(buf)
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v == target:
|
||||
type_name = {0x20000: "Private", 0x40000: "Mapped", 0x1000000: "Image"}.get(ty, hex(ty))
|
||||
hits.append((r.BaseAddress + off, type_name))
|
||||
|
||||
print(f"scanned {total/(1024*1024):.1f} MB")
|
||||
print(f"hits: {len(hits)}")
|
||||
# Histogram by region type
|
||||
hist = Counter(h[1] for h in hits)
|
||||
for t, c in hist.most_common():
|
||||
print(f" {t}: {c}")
|
||||
print("first 15 hits:")
|
||||
for addr, t in hits[:15]:
|
||||
print(f" 0x{addr:08x} ({t})")
|
||||
55
tools/find_vtable_xrefs.py
Normal file
55
tools/find_vtable_xrefs.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""find_vtable_xrefs.py <exe_path> <vtable_va>
|
||||
Scan a PE for code/data references to a vtable VA.
|
||||
A constructor's vtable-store is the canonical 'who owns this vtable' xref.
|
||||
Pure stdlib — parses PE headers by hand."""
|
||||
import struct, sys
|
||||
|
||||
def parse_pe(path):
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
if data[:2] != b'MZ': raise ValueError("not PE")
|
||||
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
|
||||
if data[pe_off:pe_off+4] != b'PE\0\0': raise ValueError("no PE sig")
|
||||
coff = pe_off + 4
|
||||
n_sections = struct.unpack_from('<H', data, coff + 2)[0]
|
||||
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
|
||||
opt_off = coff + 20
|
||||
# PE32 optional header: ImageBase at +28
|
||||
image_base = struct.unpack_from('<I', data, opt_off + 28)[0]
|
||||
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(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]
|
||||
raddr = struct.unpack_from('<I', data, s + 20)[0]
|
||||
sections.append((name, vaddr, vsize, raddr, rsize))
|
||||
return data, image_base, sections
|
||||
|
||||
def main():
|
||||
path = sys.argv[1]
|
||||
vt = int(sys.argv[2], 0)
|
||||
needle = struct.pack('<I', vt)
|
||||
|
||||
data, base, sections = parse_pe(path)
|
||||
print(f"image base = 0x{base:08x}")
|
||||
|
||||
for name, vaddr, vsize, raddr, rsize in sections:
|
||||
sec_va = base + vaddr
|
||||
sec_data = data[raddr:raddr + rsize]
|
||||
n = 0
|
||||
for m_off in range(0, len(sec_data) - 3):
|
||||
if sec_data[m_off:m_off+4] == needle:
|
||||
va = sec_va + m_off
|
||||
lo = max(0, m_off - 8)
|
||||
ctx = sec_data[lo: m_off+12].hex(' ')
|
||||
print(f" [{name:8s}] xref @ 0x{va:08x}: ...{ctx}...")
|
||||
n += 1
|
||||
if n >= 80:
|
||||
print(f" (stopping after 80 in {name})")
|
||||
break
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
118
tools/fleet_monitor.sh
Normal file
118
tools/fleet_monitor.sh
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env bash
|
||||
# Combined fleet monitor:
|
||||
# - HB every 30 min
|
||||
# - Snapshot every 1 h (appends to artifacts/snapshots/main.tsv)
|
||||
# - Every 60 s: scan for acclient PIDs in-world (title has "Coldeve-"),
|
||||
# apply v3b -> v5 -> v11 -> v12 in cascade. Skip Jerry (control).
|
||||
#
|
||||
# Each patcher is idempotent — re-runs are no-ops when bytes already in
|
||||
# patched state. To keep event log clean, only emits AUTO-* events for
|
||||
# PIDs we haven't already seen as "done" for a given patch.
|
||||
set -u
|
||||
PY="C:/Users/acbot/AppData/Local/Programs/Python/Python312/python.exe"
|
||||
cd /c/Users/acbot/leakhunt
|
||||
|
||||
last_hb=0
|
||||
last_snap=0
|
||||
|
||||
# Per-PID, per-patch tracking sets (sentinel files in /tmp)
|
||||
SEEN_DIR="/tmp/fleet_mon_seen"
|
||||
mkdir -p "$SEEN_DIR"
|
||||
|
||||
mark_seen() { touch "$SEEN_DIR/${1}-${2}"; }
|
||||
is_seen() { [ -f "$SEEN_DIR/${1}-${2}" ]; }
|
||||
|
||||
while true; do
|
||||
now=$(date +%s)
|
||||
|
||||
# ===== heartbeat: every 30 min =====
|
||||
if [ $((now - last_hb)) -ge 1800 ]; then
|
||||
last_hb=$now
|
||||
hb=$(powershell.exe -NoProfile -Command \
|
||||
"Get-Process acclient -EA SilentlyContinue | ForEach-Object { \"\$(\$_.Id)=\$([int](\$_.WorkingSet64/1MB))MB\" } | Sort-Object" \
|
||||
2>/dev/null | tr -d '\r' | tr '\n' ' ')
|
||||
alive=$(echo "$hb" | tr ' ' '\n' | grep -c '=')
|
||||
echo "HB $(date -u +%Y-%m-%dT%H:%M:%S) $hb ALIVE=${alive}"
|
||||
fi
|
||||
|
||||
# ===== snapshot: every 1 h =====
|
||||
if [ $((now - last_snap)) -ge 3600 ]; then
|
||||
last_snap=$now
|
||||
snap_log="artifacts/snapshots/last_snap.log"
|
||||
rows_before=$(wc -l < artifacts/snapshots/main.tsv 2>/dev/null || echo 0)
|
||||
{
|
||||
echo "=== run $(date) ==="
|
||||
echo "pwd=$(pwd)"
|
||||
echo "PY=$PY"
|
||||
echo "py-version=$("$PY" --version 2>&1)"
|
||||
echo "argv0-test=$("$PY" -c "import sys; print(sys.argv)" 2>&1)"
|
||||
echo "--- invoking snapshot ---"
|
||||
"$PY" tools/snapshot_compare.py artifacts/snapshots/main.tsv
|
||||
echo "--- exit=$? ---"
|
||||
} > "$snap_log" 2>&1
|
||||
snap_exit=$?
|
||||
rows_after=$(wc -l < artifacts/snapshots/main.tsv 2>/dev/null || echo 0)
|
||||
rows_added=$((rows_after - rows_before))
|
||||
if [ $rows_added -gt 0 ]; then
|
||||
echo "SNAPSHOT @$(date +%H:%M) appended ${rows_added} rows"
|
||||
else
|
||||
echo "SNAPSHOT-FAIL @$(date +%H:%M) exit=$snap_exit rows_added=$rows_added (log: $snap_log)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ===== auto-patch cascade: every loop (60 s) =====
|
||||
pid_titles=$(powershell.exe -NoProfile -Command \
|
||||
"Get-Process acclient -EA SilentlyContinue | ForEach-Object { \"\$(\$_.Id)|\$(\$_.MainWindowTitle)\" }" \
|
||||
2>/dev/null | tr -d '\r')
|
||||
|
||||
while IFS='|' read -r pid title; do
|
||||
[ -z "$pid" ] && continue
|
||||
# Only patch in-world clients (skip splash screen)
|
||||
if [ -z "$title" ] || ! echo "$title" | grep -q "Coldeve-"; then continue; fi
|
||||
# Skip Jerry (control)
|
||||
if echo "$title" | grep -qi "Jerry"; then continue; fi
|
||||
|
||||
# Apply in cascade order — one patch per cycle so any AV from a
|
||||
# bad patch only takes down one phase.
|
||||
for patch in v3b v5 v11 v12 v14; do
|
||||
if is_seen "$pid" "$patch"; then continue; fi
|
||||
case "$patch" in
|
||||
v3b) script="tools/patch_palette_v3b.py"; extra="" ;;
|
||||
v5) script="tools/patch_purge_v5_test.py"; extra="" ;;
|
||||
v11) script="tools/patch_v11_test.py"; extra="" ;;
|
||||
v12) script="tools/patch_v12_test.py"; extra="" ;;
|
||||
v14) script="tools/patch_v14_cenvcell_clipplane.py"; extra="--apply" ;;
|
||||
esac
|
||||
if [ -n "$extra" ]; then
|
||||
result=$("$PY" "$script" "$pid" "$extra" 2>&1)
|
||||
else
|
||||
result=$("$PY" "$script" "$pid" 2>&1)
|
||||
fi
|
||||
tail=$(echo "$result" | tail -1)
|
||||
# idempotent skip detection
|
||||
if echo "$result" | grep -q "already patched\|already has a CALL"; then
|
||||
mark_seen "$pid" "$patch"
|
||||
continue
|
||||
fi
|
||||
# DLL-form detection: if v5 says slots already point elsewhere (not
|
||||
# the no-op stub), the DLL applied this; treat as success.
|
||||
if [ "$patch" = "v5" ] && echo "$result" | grep -q "UNEXPECTED.*not the no-op stub"; then
|
||||
mark_seen "$pid" "$patch"
|
||||
echo "AUTO-V5-DLL-APPLIED PID=$pid title=\"$title\" $(date +%H:%M:%S)"
|
||||
continue
|
||||
fi
|
||||
if echo "$result" | grep -q "OK\|reverted; now\|patched; now"; then
|
||||
mark_seen "$pid" "$patch"
|
||||
echo "AUTO-${patch^^} PID=$pid title=\"$title\" $(date +%H:%M:%S)"
|
||||
else
|
||||
# Mark FAIL as seen so we don't retry-spam every 60s.
|
||||
mark_seen "$pid" "$patch"
|
||||
echo "AUTO-${patch^^}-FAIL PID=$pid title=\"$title\" tail=\"$tail\""
|
||||
fi
|
||||
# only do ONE patch action per PID per cycle (cascade staggered)
|
||||
break
|
||||
done
|
||||
done <<< "$pid_titles"
|
||||
|
||||
sleep 60
|
||||
done
|
||||
66
tools/histogram_eor_alloc_sizes.py
Normal file
66
tools/histogram_eor_alloc_sizes.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""
|
||||
histogram_eor_alloc_sizes.py
|
||||
Decompile every EoR operator_new caller, extract the size constant, histogram.
|
||||
"""
|
||||
import re, urllib.request
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
EOR = "http://192.168.1.98:8081"
|
||||
|
||||
def http(path, **p):
|
||||
qs = "&".join(f"{k}={v}" for k, v in p.items())
|
||||
url = f"{EOR}{path}?{qs}" if qs else f"{EOR}{path}"
|
||||
with urllib.request.urlopen(url, timeout=60) as r:
|
||||
return r.read().decode("utf-8", errors="replace")
|
||||
|
||||
# Get all xrefs to operator_new
|
||||
all_refs = []; off = 0
|
||||
while True:
|
||||
raw = http("/xrefs_to", address="0x005df0f5", offset=off, limit=500)
|
||||
batch = [m.group(2) for m in re.finditer(r"From ([0-9a-f]+) in (\S+)", raw)]
|
||||
if not batch: break
|
||||
all_refs.extend(batch)
|
||||
if len(batch) < 500: break
|
||||
off += 500
|
||||
|
||||
# Dedup owner names
|
||||
owners = sorted(set(all_refs))
|
||||
addrs = [int(m.group(1), 16) for n in owners for m in [re.match(r"FUN_([0-9a-f]+)", n)] if m]
|
||||
print(f"{len(addrs)} unique owners to scan")
|
||||
|
||||
def scan(addr):
|
||||
try:
|
||||
body = http("/decompile_function_by_address", address=f"0x{addr:08x}")
|
||||
except Exception:
|
||||
return (addr, [])
|
||||
# Extract operator_new size args
|
||||
sizes = []
|
||||
for m in re.finditer(r"FUN_005df0f5\((0x[0-9a-fA-F]+|\d+)\)", body):
|
||||
v = m.group(1)
|
||||
sizes.append(int(v, 0))
|
||||
for m in re.finditer(r"thunk_FUN_005df0f5\((0x[0-9a-fA-F]+|\d+)\)", body):
|
||||
v = m.group(1)
|
||||
sizes.append(int(v, 0))
|
||||
return (addr, sizes)
|
||||
|
||||
results = {}
|
||||
size_hist = Counter()
|
||||
with ThreadPoolExecutor(max_workers=24) as ex:
|
||||
futures = [ex.submit(scan, a) for a in addrs]
|
||||
for fut in as_completed(futures):
|
||||
addr, sizes = fut.result()
|
||||
results[addr] = sizes
|
||||
for s in sizes:
|
||||
size_hist[s] += 1
|
||||
|
||||
print(f"\n=== size histogram of operator_new calls ===")
|
||||
for sz, cnt in sorted(size_hist.items(), key=lambda x: -x[1])[:40]:
|
||||
print(f" 0x{sz:08x} ({sz:>10}) : {cnt}")
|
||||
|
||||
print(f"\n=== sizes near 0x100-0x200 (RenderSurface candidates) ===")
|
||||
for sz in sorted(size_hist):
|
||||
if 0xf0 <= sz <= 0x250:
|
||||
# Find addrs that call operator_new with this size
|
||||
callers = [a for a, sizes in results.items() if sz in sizes]
|
||||
print(f" 0x{sz:x} ({sz}) -> {len(callers)} callers: {[hex(a) for a in callers[:8]]}")
|
||||
52
tools/histogram_region_for_vt.py
Normal file
52
tools/histogram_region_for_vt.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""histogram_region_for_vt.py <dump> <vt>
|
||||
Show distribution of regions containing the given vtable signature, with region sizes.
|
||||
Also pick 5 hit addresses and read 0x40 bytes after the vtable to see what fields look like.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
md = MinidumpFile.parse(sys.argv[1])
|
||||
target = int(sys.argv[2], 16)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
region_sizes = Counter()
|
||||
hit_addrs = []
|
||||
total_hits = 0
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40):
|
||||
continue
|
||||
try:
|
||||
reader.move(r.BaseAddress)
|
||||
buf = reader.read(r.RegionSize)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
hits_here = 0
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", buf, off)[0] == target:
|
||||
hits_here += 1
|
||||
total_hits += 1
|
||||
if len(hit_addrs) < 30:
|
||||
hit_addrs.append((r.BaseAddress + off, buf[off:off+0x40] if off+0x40 <= len(buf) else b''))
|
||||
if hits_here:
|
||||
region_sizes[(r.RegionSize, hits_here)] += 1
|
||||
|
||||
print(f"Total hits: {total_hits}")
|
||||
print(f"\nRegions containing this vtable (region_size, hits_per_region, count):")
|
||||
for (rs, hph), c in sorted(region_sizes.items(), key=lambda x: -x[1])[:30]:
|
||||
density = hph / (rs / 0x100) # hits per 256 bytes
|
||||
print(f" region_size={rs:>8} hits_per_region={hph:>5} count={c:>5} density={density:.3f}/256B")
|
||||
|
||||
print(f"\nFirst 10 hit addresses + 0x40 bytes after the vtable:")
|
||||
for addr, data in hit_addrs[:10]:
|
||||
print(f" 0x{addr:08x}: ", end="")
|
||||
for i in range(0, min(0x40, len(data)), 4):
|
||||
print(f"{struct.unpack_from('<I', data, i)[0]:08x} ", end="")
|
||||
print()
|
||||
63
tools/identify_d3d_vtable.py
Normal file
63
tools/identify_d3d_vtable.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""identify_d3d_vtable.py <pid> <candidate_va>
|
||||
Read consecutive DWORDs from a candidate vtable address and report how
|
||||
many of them are valid d3d9.dll function pointers.
|
||||
IDirect3DDevice9 has 119 slots, IDirect3DTexture9 ~17, IDirect3D9 ~16.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
|
||||
|
||||
D3D9_BASE = 0x6F4E0000
|
||||
D3D9_END = 0x6F4E0000 + 0x17A000
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
va = int(sys.argv[2], 0)
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
data = rd(h, va, 4 * 130)
|
||||
if not data: print(f"unreadable at 0x{va:08x}"); sys.exit(3)
|
||||
|
||||
# Count consecutive valid d3d9 function pointers
|
||||
slot_count = 0
|
||||
slots = []
|
||||
for i in range(130):
|
||||
if i*4 + 4 > len(data): break
|
||||
p = struct.unpack_from('<I', data, i*4)[0]
|
||||
if D3D9_BASE <= p < D3D9_END:
|
||||
slot_count = i + 1 # at least up to here, all valid
|
||||
slots.append(p)
|
||||
else:
|
||||
# First non-d3d9 entry
|
||||
slots.append(p)
|
||||
if i > 3:
|
||||
break
|
||||
|
||||
print(f"Vtable @ 0x{va:08x}: valid d3d9 slots = {slot_count}")
|
||||
print(f" Slot 0: 0x{slots[0]:08x}")
|
||||
if slot_count > 2: print(f" Slot 2: 0x{slots[2]:08x} (would be IUnknown::Release)")
|
||||
if slot_count > 16: print(f" Slot 16: 0x{slots[16]:08x} (could be IDirect3D9::CreateDevice if D3D9 vtable, or IDirect3DDevice9::Reset)")
|
||||
if slot_count > 23: print(f" Slot 23: 0x{slots[23]:08x} (would be IDirect3DDevice9::CreateTexture)")
|
||||
if slot_count > 28: print(f" Slot 28: 0x{slots[28]:08x} (would be IDirect3DDevice9::CreateRenderTarget)")
|
||||
|
||||
# Identify
|
||||
if slot_count >= 119:
|
||||
print(f"\n*** LIKELY IDirect3DDevice9 (119 slots expected) ***")
|
||||
elif 14 <= slot_count <= 20:
|
||||
print(f"\nLIKELY IDirect3D9 or IDirect3DTexture9 (small)")
|
||||
elif 4 <= slot_count <= 10:
|
||||
print(f"\nLIKELY IUnknown derivative (very small)")
|
||||
else:
|
||||
print(f"\nUnknown — {slot_count} slots")
|
||||
|
||||
k.CloseHandle(h)
|
||||
118
tools/identify_holders_by_module.py
Normal file
118
tools/identify_holders_by_module.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""identify_holders_by_module.py <pid>
|
||||
For all 260KB private RW regions, scan ALL readable memory in the process for
|
||||
pointers, then group hits by owning module (DLL/exe).
|
||||
Reveals which DLL maintains a cache pointing at these chunks."""
|
||||
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
from collections import defaultdict
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
psapi = ctypes.windll.psapi
|
||||
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
class MODULEINFO(ctypes.Structure):
|
||||
_fields_ = [("lpBaseOfDll", ctypes.c_void_p), ("SizeOfImage", wt.DWORD),
|
||||
("EntryPoint", ctypes.c_void_p)]
|
||||
|
||||
psapi.EnumProcessModulesEx.argtypes = [wt.HANDLE, ctypes.POINTER(wt.HMODULE), wt.DWORD, ctypes.POINTER(wt.DWORD), wt.DWORD]
|
||||
psapi.GetModuleFileNameExA.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.c_char_p, wt.DWORD]
|
||||
psapi.GetModuleInformation.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.POINTER(MODULEINFO), wt.DWORD]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(0x410, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
# Enumerate modules
|
||||
modules = [] # list of (base, end, name)
|
||||
needed = wt.DWORD(0)
|
||||
hmods = (wt.HMODULE * 1024)()
|
||||
if psapi.EnumProcessModulesEx(h, hmods, ctypes.sizeof(hmods), ctypes.byref(needed), 0x03):
|
||||
n = needed.value // ctypes.sizeof(wt.HMODULE)
|
||||
name = ctypes.create_string_buffer(260)
|
||||
info = MODULEINFO()
|
||||
for i in range(n):
|
||||
psapi.GetModuleFileNameExA(h, hmods[i], name, 260)
|
||||
nm = name.value.decode(errors='replace')
|
||||
nm_short = nm.split('\\')[-1].split('/')[-1].lower()
|
||||
if psapi.GetModuleInformation(h, hmods[i], ctypes.byref(info), ctypes.sizeof(info)):
|
||||
modules.append((info.lpBaseOfDll, info.lpBaseOfDll + info.SizeOfImage, nm_short))
|
||||
modules.sort()
|
||||
print(f"Found {len(modules)} modules")
|
||||
|
||||
def find_module(va):
|
||||
# Linear scan — module count small
|
||||
for base, end, name in modules:
|
||||
if base <= va < end:
|
||||
return name
|
||||
return "<not-in-image>"
|
||||
|
||||
# Find all 260KB regions
|
||||
candidates = []
|
||||
mbi = MBI(); addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
if mbi.State == 0x1000 and mbi.RegionSize == 266240 and (mbi.Type & 0x20000):
|
||||
if (mbi.Protect & 0xFF) in (0x04, 0x40):
|
||||
candidates.append(base)
|
||||
next_addr = base + mbi.RegionSize
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
print(f"Found {len(candidates)} candidate 260KB regions")
|
||||
target_set = set(candidates)
|
||||
|
||||
# Walk ALL readable committed memory and find pointers to candidates,
|
||||
# group by owning module
|
||||
holders_by_module = defaultdict(int)
|
||||
holders_by_module_unique = defaultdict(set) # which orphan target each module hits
|
||||
scanned_total = 0
|
||||
mbi = MBI(); addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
sz = mbi.RegionSize
|
||||
if mbi.State == 0x1000 and (mbi.Protect & 0xFF) in (0x02, 0x04, 0x20, 0x40): # any readable
|
||||
# Skip huge regions to keep this fast
|
||||
if sz <= 64 * 1024 * 1024:
|
||||
data = rd(h, base, sz)
|
||||
if data:
|
||||
scanned_total += sz
|
||||
# Walk dwords
|
||||
for off in range(0, len(data) - 3, 4):
|
||||
v = int.from_bytes(data[off:off+4], 'little')
|
||||
if v in target_set:
|
||||
holder_va = base + off
|
||||
mod = find_module(holder_va)
|
||||
holders_by_module[mod] += 1
|
||||
holders_by_module_unique[mod].add(v)
|
||||
next_addr = base + sz
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
print(f"\nScanned {scanned_total/1024/1024:.0f} MB total")
|
||||
print(f"\nHolders by module (top 20):")
|
||||
sorted_mods = sorted(holders_by_module.items(), key=lambda x: -x[1])
|
||||
for mod, hits in sorted_mods[:20]:
|
||||
unique = len(holders_by_module_unique[mod])
|
||||
print(f" {mod:30s} hits={hits:6d} unique_targets={unique}")
|
||||
|
||||
print(f"\nTotal hits: {sum(holders_by_module.values())}")
|
||||
print(f"Total unique targets: {len(set().union(*holders_by_module_unique.values()))} of {len(candidates)}")
|
||||
|
||||
k.CloseHandle(h)
|
||||
89
tools/identify_mystery_vtables.py
Normal file
89
tools/identify_mystery_vtables.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""identify_mystery_vtables.py <pid> <vtable1> [vtable2 ...]
|
||||
|
||||
For each vtable address, dump the first 16 function pointers and try to
|
||||
match them against references/symbols.json for class identification.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, json, struct, sys, os
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("vtables", nargs="+")
|
||||
args = ap.parse_args()
|
||||
|
||||
vtables = [int(v, 0) for v in args.vtables]
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
k.ReadProcessMemory.restype = wt.BOOL
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess failed err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
def read(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])
|
||||
|
||||
# Load symbols
|
||||
syms = {}
|
||||
sym_path = os.path.join(os.path.dirname(__file__), "..", "references", "symbols.json")
|
||||
if os.path.exists(sym_path):
|
||||
with open(sym_path) as f:
|
||||
for s in json.load(f):
|
||||
try:
|
||||
a = int(s["address"], 16)
|
||||
syms[a] = s.get("name", "?")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"Loaded {len(syms)} symbols")
|
||||
|
||||
# Note: these are EoR addresses; we don't have an EoR symbols file.
|
||||
# But we may still see overlapping addresses (low frequency) — or near-match heuristic.
|
||||
# Better: just dump and let the human interpret. Also dump strings near each
|
||||
# class' first method (often vftable strings live around the .rdata block).
|
||||
|
||||
for vt in vtables:
|
||||
print(f"\n=== vtable 0x{vt:08x} ===")
|
||||
data = read(vt, 64)
|
||||
if not data:
|
||||
print(" unreadable"); continue
|
||||
for i in range(16):
|
||||
p = struct.unpack_from("<I", data, i*4)[0]
|
||||
name = syms.get(p, "")
|
||||
# Also probe for proximity matches (within 0x40)
|
||||
near = ""
|
||||
if not name:
|
||||
for d in range(0, 0x40, 1):
|
||||
if (p - d) in syms:
|
||||
near = f" ~ {syms[p-d]}+0x{d:x}"; break
|
||||
print(f" [{i:2d}] 0x{p:08x} {name}{near}")
|
||||
|
||||
# Also look around the vtable - 4 (often points to RTTI structure)
|
||||
rtti_slot = read(vt - 4, 4)
|
||||
if rtti_slot:
|
||||
rtti_ptr = struct.unpack("<I", rtti_slot)[0]
|
||||
print(f" RTTI slot (vt-4): 0x{rtti_ptr:08x}")
|
||||
# Read RTTI COL: typeDescriptor at +0xC
|
||||
col = read(rtti_ptr, 0x20)
|
||||
if col:
|
||||
type_desc = struct.unpack_from("<I", col, 0xC)[0]
|
||||
print(f" RTTI typeDescriptor: 0x{type_desc:08x}")
|
||||
# Type descriptor: vtable(4) + spare(4) + name(zero-terminated str)
|
||||
name_addr = type_desc + 8
|
||||
name_bytes = read(name_addr, 128)
|
||||
if name_bytes:
|
||||
end = name_bytes.find(b'\x00')
|
||||
if end > 0:
|
||||
try:
|
||||
decoded = name_bytes[:end].decode("latin-1")
|
||||
print(f" RTTI name: {decoded!r}")
|
||||
except Exception:
|
||||
print(f" RTTI raw: {name_bytes[:end]!r}")
|
||||
59
tools/inspect_regions.py
Normal file
59
tools/inspect_regions.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
inspect_regions.py <dump.dmp> [N]
|
||||
Print the first 0x80 bytes of N sample 256-512 KB private RW regions.
|
||||
Useful for eyeballing what the leaked allocations contain.
|
||||
"""
|
||||
import sys, os
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
def _enum_int(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def hexdump(buf, base):
|
||||
lines = []
|
||||
for i in range(0, len(buf), 16):
|
||||
chunk = buf[i:i+16]
|
||||
hexs = " ".join(f"{b:02x}" for b in chunk)
|
||||
ascii = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
||||
lines.append(f" {base+i:08x} {hexs:<47} {ascii}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
dump = sys.argv[1]
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 12
|
||||
|
||||
md = MinidumpFile.parse(dump)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
cands = []
|
||||
for r in md.memory_info.infos:
|
||||
st = _enum_int(r.State)
|
||||
ty = _enum_int(r.Type)
|
||||
pr = _enum_int(r.Protect) & 0xFF
|
||||
sz = r.RegionSize
|
||||
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
|
||||
and 256*1024 <= sz < 512*1024:
|
||||
cands.append((r.BaseAddress, sz))
|
||||
|
||||
print(f"=== {os.path.basename(dump)} : {len(cands)} candidate 256-512KB regions ===")
|
||||
# Sample evenly across all candidates
|
||||
step = max(1, len(cands) // n)
|
||||
samples = cands[::step][:n]
|
||||
|
||||
for base, size in samples:
|
||||
print(f"\n--- region 0x{base:08x} size={size} ({size/1024:.1f} KB) ---")
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(0x80)
|
||||
except Exception as e:
|
||||
print(f" read failed: {e}"); continue
|
||||
print(hexdump(buf, base))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
53
tools/inspect_vtable.py
Normal file
53
tools/inspect_vtable.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""inspect_vtable.py <dump.dmp> <vtable_va> [num_slots]
|
||||
Read N dwords starting at vtable_va. For each, mark whether it is in
|
||||
code memory (image, executable). A valid vtable is a row of >= 4
|
||||
contiguous code pointers.
|
||||
"""
|
||||
import struct, sys
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
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])
|
||||
vt = int(sys.argv[2], 16)
|
||||
n = int(sys.argv[3]) if len(sys.argv) > 3 else 32
|
||||
|
||||
# Module map
|
||||
mods = []
|
||||
for m in md.modules.modules:
|
||||
mods.append((m.baseaddress, m.size, m.name.split("\\")[-1]))
|
||||
def mod_of(a):
|
||||
for b, s, nm in mods:
|
||||
if b <= a < b + s: return nm
|
||||
return None
|
||||
|
||||
# Executable image-region cache
|
||||
exec_ranges = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st == 0x1000 and ty == 0x1000000 and pr in (0x20, 0x80):
|
||||
exec_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
|
||||
def is_exec(a):
|
||||
for lo, hi in exec_ranges:
|
||||
if lo <= a < hi: return True
|
||||
return False
|
||||
|
||||
rdr = md.get_reader().get_buffered_reader()
|
||||
rdr.move(vt)
|
||||
buf = rdr.read(n * 4)
|
||||
print(f"vtable @ 0x{vt:08x} ({mod_of(vt) or '?'}):")
|
||||
for i in range(n):
|
||||
v = struct.unpack_from("<I", buf, i*4)[0]
|
||||
owner = mod_of(v) or "?"
|
||||
exe = "CODE" if is_exec(v) else " "
|
||||
print(f" [{i:2d}] +0x{i*4:02x} 0x{v:08x} {exe} ({owner})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
293
tools/install_leakfix.py
Normal file
293
tools/install_leakfix.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"""install_leakfix.py — Install leakfix.dll into an Asheron's Call install.
|
||||
|
||||
Patches acclient.exe to statically import leakfix.dll. Adds a new PE
|
||||
section ".limport" containing a rebuilt import table that includes
|
||||
leakfix.dll alongside the original imports.
|
||||
|
||||
Usage:
|
||||
python install_leakfix.py [ac_dir]
|
||||
|
||||
Defaults ac_dir to "C:\\Turbine\\Asheron's Call".
|
||||
|
||||
The script:
|
||||
1. Locates acclient.exe and leakfix.dll in ac_dir
|
||||
2. Checks whether acclient.exe already imports leakfix.dll
|
||||
(idempotent — exits cleanly if already patched)
|
||||
3. Saves a backup of the original acclient.exe (if no backup exists)
|
||||
4. Builds a new import table with leakfix.dll added at the end
|
||||
5. Appends a new PE section ".limport" containing the new import table
|
||||
6. Updates the PE Optional Header DataDirectory[1] (Import Table)
|
||||
to point at the new section
|
||||
7. Updates NumberOfSections in the COFF header
|
||||
|
||||
acclient.exe section table has 1 unused slot already (verified — it has
|
||||
7 sections but room for 8 in headers), so we can append cleanly without
|
||||
needing to move .text.
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import hashlib
|
||||
|
||||
DEFAULT_AC_DIR = r"C:\Turbine\Asheron's Call"
|
||||
|
||||
# leakfix.dll exports — leakfix.dll has only DllMain (no exported functions
|
||||
# we need to call by name), so the import name table just needs ONE entry
|
||||
# with a fake function name or an ordinal import. Easiest: import by ordinal
|
||||
# 1 if the DLL exports anything, or use a forwarder. But leakfix.dll has no
|
||||
# exports, so we need to add one — see leakfix DLL build for a stub export.
|
||||
#
|
||||
# For a DLL that has *zero* exports, an empty IAT entry (just the terminator)
|
||||
# in the import descriptor would mean "load the DLL but don't bind anything".
|
||||
# Windows still calls DllMain on load. That's what we want.
|
||||
#
|
||||
# Required structure per import descriptor:
|
||||
# OriginalFirstThunk (RVA to import lookup table) - terminator entry only
|
||||
# TimeDateStamp = 0
|
||||
# ForwarderChain = 0
|
||||
# Name (RVA to DLL name string)
|
||||
# FirstThunk (RVA to import address table) - terminator entry only
|
||||
|
||||
def hexdump_at(data, off, n=64):
|
||||
chunk = data[off:off+n]
|
||||
return ' '.join(f'{b:02x}' for b in chunk)
|
||||
|
||||
def find_section(data, pe_off, name):
|
||||
num_sections = struct.unpack_from('<H', data, pe_off + 6)[0]
|
||||
opt_header_size = struct.unpack_from('<H', data, pe_off + 20)[0]
|
||||
sect_off = pe_off + 24 + opt_header_size
|
||||
for i in range(num_sections):
|
||||
so = sect_off + i*40
|
||||
sname = data[so:so+8].rstrip(b'\0')
|
||||
if sname == name.encode():
|
||||
return so, i
|
||||
return None, -1
|
||||
|
||||
def get_sections(data, pe_off):
|
||||
num_sections = struct.unpack_from('<H', data, pe_off + 6)[0]
|
||||
opt_header_size = struct.unpack_from('<H', data, pe_off + 20)[0]
|
||||
sect_off = pe_off + 24 + opt_header_size
|
||||
out = []
|
||||
for i in range(num_sections):
|
||||
so = sect_off + i*40
|
||||
out.append({
|
||||
'name': data[so:so+8].rstrip(b'\0').decode(errors='replace'),
|
||||
'vsize': struct.unpack_from('<I', data, so+8)[0],
|
||||
'vaddr': struct.unpack_from('<I', data, so+12)[0],
|
||||
'rsize': struct.unpack_from('<I', data, so+16)[0],
|
||||
'rawoff': struct.unpack_from('<I', data, so+20)[0],
|
||||
'chars': struct.unpack_from('<I', data, so+36)[0],
|
||||
'tabentry_off': so,
|
||||
})
|
||||
return out
|
||||
|
||||
def rva_to_off(sections, rva):
|
||||
for s in sections:
|
||||
if s['vaddr'] <= rva < s['vaddr'] + s['vsize']:
|
||||
return s['rawoff'] + (rva - s['vaddr'])
|
||||
return None
|
||||
|
||||
def already_imports_leakfix(data, pe_off, sections):
|
||||
opt_off = pe_off + 24
|
||||
dd_off = opt_off + 96
|
||||
import_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
|
||||
foff = rva_to_off(sections, import_rva)
|
||||
if foff is None: return False
|
||||
while True:
|
||||
name_rva = struct.unpack_from('<I', data, foff + 12)[0]
|
||||
if name_rva == 0: return False
|
||||
name_foff = rva_to_off(sections, name_rva)
|
||||
if name_foff is None: return False
|
||||
name_end = data.index(b'\0', name_foff)
|
||||
dll_name = data[name_foff:name_end].decode(errors='replace').lower()
|
||||
if 'leakfix' in dll_name:
|
||||
return True
|
||||
foff += 20
|
||||
|
||||
def align(n, a):
|
||||
return (n + a - 1) & ~(a - 1)
|
||||
|
||||
def patch(ac_dir):
|
||||
exe_path = os.path.join(ac_dir, 'acclient.exe')
|
||||
dll_path = os.path.join(ac_dir, 'leakfix.dll')
|
||||
if not os.path.exists(exe_path):
|
||||
print(f"ERROR: {exe_path} not found")
|
||||
return 1
|
||||
if not os.path.exists(dll_path):
|
||||
print(f"ERROR: {dll_path} not found")
|
||||
return 1
|
||||
|
||||
with open(exe_path, 'rb') as f:
|
||||
data = bytearray(f.read())
|
||||
orig_sha = hashlib.sha256(data).hexdigest()
|
||||
print(f"acclient.exe: {len(data):,} bytes, SHA-256 {orig_sha}")
|
||||
|
||||
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
|
||||
sections = get_sections(data, pe_off)
|
||||
print(f"\nCurrent sections ({len(sections)}):")
|
||||
for s in sections:
|
||||
print(f" {s['name']:10s} vaddr=0x{s['vaddr']:08x} vsize=0x{s['vsize']:08x} raw=0x{s['rawoff']:08x} rsize=0x{s['rsize']:08x}")
|
||||
|
||||
if already_imports_leakfix(data, pe_off, sections):
|
||||
print("\n[already patched] acclient.exe already imports leakfix.dll — nothing to do.")
|
||||
return 0
|
||||
|
||||
# Backup original (if no backup exists)
|
||||
backup = exe_path + '.bare_original'
|
||||
if not os.path.exists(backup):
|
||||
shutil.copy2(exe_path, backup)
|
||||
print(f"\nbackup saved: {backup}")
|
||||
else:
|
||||
print(f"\nbackup exists: {backup}")
|
||||
|
||||
# Read PE optional header info we'll need
|
||||
opt_off = pe_off + 24
|
||||
file_align = struct.unpack_from('<I', data, opt_off + 36)[0]
|
||||
sect_align = struct.unpack_from('<I', data, opt_off + 32)[0]
|
||||
print(f"file_align=0x{file_align:x} sect_align=0x{sect_align:x}")
|
||||
|
||||
# Verify we have a free slot in the section header table.
|
||||
num_sections = struct.unpack_from('<H', data, pe_off + 6)[0]
|
||||
opt_header_size = struct.unpack_from('<H', data, pe_off + 20)[0]
|
||||
sect_table_off = pe_off + 24 + opt_header_size
|
||||
next_sect_entry = sect_table_off + num_sections * 40
|
||||
first_section_raw = min(s['rawoff'] for s in sections if s['rawoff'] > 0)
|
||||
if next_sect_entry + 40 > first_section_raw:
|
||||
print(f"\nERROR: no room in section table at file 0x{next_sect_entry:x} "
|
||||
f"(first section raw 0x{first_section_raw:x}). Would need to expand headers.")
|
||||
return 1
|
||||
print(f"section table free slot at 0x{next_sect_entry:x}, first section raw 0x{first_section_raw:x}: OK")
|
||||
|
||||
# Read original import table to copy as-is
|
||||
dd_off = opt_off + 96
|
||||
old_imp_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
|
||||
old_imp_size = struct.unpack_from('<I', data, dd_off + 12)[0]
|
||||
old_imp_foff = rva_to_off(sections, old_imp_rva)
|
||||
# Count old entries (excluding terminator)
|
||||
walk = old_imp_foff
|
||||
old_n = 0
|
||||
while struct.unpack_from('<I', data, walk + 12)[0] != 0:
|
||||
old_n += 1
|
||||
walk += 20
|
||||
old_iid_total = (old_n + 1) * 20 # +1 terminator
|
||||
print(f"old imports: {old_n} entries, IID block {old_iid_total} bytes at RVA 0x{old_imp_rva:x}")
|
||||
|
||||
# === Build new section content ===
|
||||
#
|
||||
# Layout in the new section (.limport) at vaddr V (chosen as next aligned
|
||||
# virtual address past last section):
|
||||
# +0: Import Descriptors (rebuilt: old N + leakfix + terminator)
|
||||
# +(N+2)*20: IAT terminator for leakfix (one ULONG = 0)
|
||||
# +(N+2)*20+4: INT terminator for leakfix (one ULONG = 0)
|
||||
# +(N+2)*20+8: Hint/Name table for leakfix (empty — none needed)
|
||||
# +(N+2)*20+8: "leakfix.dll\0" name string
|
||||
#
|
||||
# Wait — actually for a DLL with no required imports we'd have an empty IAT/INT
|
||||
# consisting of just a 0 terminator. But Windows loaders are fine with that.
|
||||
|
||||
last = max(sections, key=lambda s: s['vaddr'] + s['vsize'])
|
||||
new_vaddr = align(last['vaddr'] + last['vsize'], sect_align)
|
||||
new_rawoff = align(len(data), file_align)
|
||||
print(f"new section: vaddr=0x{new_vaddr:08x} rawoff=0x{new_rawoff:08x}")
|
||||
|
||||
# Lay out contents
|
||||
blob = bytearray()
|
||||
# 1) IID block placeholder (size = (old_n + 2) * 20)
|
||||
iid_start = 0
|
||||
iid_size = (old_n + 2) * 20
|
||||
blob += b'\0' * iid_size
|
||||
# 2) Empty IAT/INT terminator for leakfix (4 bytes each, RVA pointers stored
|
||||
# in the leakfix descriptor)
|
||||
leakfix_int_off = len(blob)
|
||||
blob += b'\0\0\0\0' # INT terminator
|
||||
leakfix_iat_off = len(blob)
|
||||
blob += b'\0\0\0\0' # IAT terminator
|
||||
# 3) leakfix.dll name string
|
||||
leakfix_name_off = len(blob)
|
||||
blob += b'leakfix.dll\0'
|
||||
# Pad to file alignment
|
||||
while len(blob) % 4 != 0:
|
||||
blob += b'\0'
|
||||
|
||||
new_section_size = len(blob)
|
||||
new_section_raw_size = align(new_section_size, file_align)
|
||||
blob += b'\0' * (new_section_raw_size - new_section_size)
|
||||
|
||||
# Fill in IIDs: copy old N, then add leakfix at index N, then terminator
|
||||
# Each IID: OriginalFirstThunk, TimeDateStamp, ForwarderChain, Name, FirstThunk = 5 * 4B = 20B
|
||||
# Old IIDs we copy verbatim (their RVAs already point into the existing import section)
|
||||
blob[iid_start:iid_start + old_n*20] = data[old_imp_foff : old_imp_foff + old_n*20]
|
||||
# leakfix IID at index N
|
||||
leakfix_iid_off = iid_start + old_n * 20
|
||||
leakfix_iid = bytearray(20)
|
||||
struct.pack_into('<I', leakfix_iid, 0, new_vaddr + leakfix_int_off) # OriginalFirstThunk (INT)
|
||||
struct.pack_into('<I', leakfix_iid, 4, 0) # TimeDateStamp
|
||||
struct.pack_into('<I', leakfix_iid, 8, 0) # ForwarderChain
|
||||
struct.pack_into('<I', leakfix_iid, 12, new_vaddr + leakfix_name_off) # Name
|
||||
struct.pack_into('<I', leakfix_iid, 16, new_vaddr + leakfix_iat_off) # FirstThunk (IAT)
|
||||
blob[leakfix_iid_off:leakfix_iid_off + 20] = leakfix_iid
|
||||
# Terminator IID already zeroed
|
||||
|
||||
# === Append new section ===
|
||||
if new_rawoff > len(data):
|
||||
data += b'\0' * (new_rawoff - len(data)) # pad to alignment
|
||||
data += blob
|
||||
|
||||
# === Write new section header ===
|
||||
new_sect_hdr = bytearray(40)
|
||||
new_sect_hdr[0:8] = b'.limport' # name
|
||||
struct.pack_into('<I', new_sect_hdr, 8, new_section_size) # VirtualSize
|
||||
struct.pack_into('<I', new_sect_hdr, 12, new_vaddr) # VirtualAddress
|
||||
struct.pack_into('<I', new_sect_hdr, 16, new_section_raw_size) # SizeOfRawData
|
||||
struct.pack_into('<I', new_sect_hdr, 20, new_rawoff) # PointerToRawData
|
||||
struct.pack_into('<I', new_sect_hdr, 36, 0xC0000040) # READ|WRITE|INITIALIZED_DATA
|
||||
data[next_sect_entry:next_sect_entry + 40] = new_sect_hdr
|
||||
|
||||
# === Update PE headers ===
|
||||
struct.pack_into('<H', data, pe_off + 6, num_sections + 1) # NumberOfSections
|
||||
# DataDirectory[1] (Import Table) — point at new IID block
|
||||
struct.pack_into('<I', data, dd_off + 8, new_vaddr + iid_start)
|
||||
struct.pack_into('<I', data, dd_off + 12, iid_size)
|
||||
# SizeOfImage = aligned end of new section
|
||||
new_size_of_image = align(new_vaddr + new_section_size, sect_align)
|
||||
struct.pack_into('<I', data, opt_off + 56, new_size_of_image)
|
||||
|
||||
# === Save patched executable ===
|
||||
out_path = exe_path
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(data)
|
||||
new_sha = hashlib.sha256(data).hexdigest()
|
||||
print(f"\nPatched acclient.exe written: {len(data):,} bytes, SHA-256 {new_sha}")
|
||||
print(f" delta vs original: +{len(data) - os.path.getsize(backup):,} bytes")
|
||||
print(f" new section: .limport @ vaddr 0x{new_vaddr:08x} raw 0x{new_rawoff:08x}")
|
||||
print(f"\n[OK] acclient.exe now imports leakfix.dll. Restart any running clients.")
|
||||
return 0
|
||||
|
||||
def verify(ac_dir):
|
||||
"""Read patched exe and confirm leakfix.dll is in imports."""
|
||||
exe_path = os.path.join(ac_dir, 'acclient.exe')
|
||||
with open(exe_path, 'rb') as f:
|
||||
data = f.read()
|
||||
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
|
||||
sections = get_sections(data, pe_off)
|
||||
if already_imports_leakfix(data, pe_off, sections):
|
||||
print(f"[verified] {exe_path} imports leakfix.dll.")
|
||||
return 0
|
||||
print(f"[FAILED] {exe_path} does NOT import leakfix.dll.")
|
||||
return 1
|
||||
|
||||
def main():
|
||||
ac_dir = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_AC_DIR
|
||||
cmd = sys.argv[2] if len(sys.argv) > 2 else 'patch'
|
||||
print(f"AC directory: {ac_dir}\nCommand: {cmd}\n")
|
||||
if cmd == 'patch':
|
||||
return patch(ac_dir)
|
||||
elif cmd == 'verify':
|
||||
return verify(ac_dir)
|
||||
else:
|
||||
print(f"unknown command: {cmd}")
|
||||
return 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
81
tools/list_image_modules.py
Normal file
81
tools/list_image_modules.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""list_image_modules.py <pid>
|
||||
Enumerate all MEM_IMAGE allocation bases. For each, read the PE export
|
||||
table to grab the module name. List with sizes."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
def get_module_name(h, base):
|
||||
"""Read PE export name from the module."""
|
||||
hdr = rd(h, base + 0x3C, 4)
|
||||
if not hdr or len(hdr) != 4: return None
|
||||
pe_off = struct.unpack('<I', hdr)[0]
|
||||
if pe_off > 0x1000: return None
|
||||
# Read optional header, find export directory
|
||||
opt_off = base + pe_off + 4 + 20
|
||||
# Export RVA at opt_off + 96 (for PE32)
|
||||
expdir_b = rd(h, opt_off + 96, 8)
|
||||
if not expdir_b: return None
|
||||
exp_rva, exp_size = struct.unpack('<II', expdir_b)
|
||||
if not exp_rva or exp_size < 12: return None
|
||||
# Read first 64 bytes of export dir; Name RVA is at offset 12
|
||||
exp = rd(h, base + exp_rva, 64)
|
||||
if not exp or len(exp) < 16: return None
|
||||
name_rva = struct.unpack_from('<I', exp, 12)[0]
|
||||
name_bytes = rd(h, base + name_rva, 64)
|
||||
if not name_bytes: return None
|
||||
n = name_bytes.split(b'\x00', 1)[0]
|
||||
return n.decode(errors='replace')
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
seen = {}
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
sz = mbi.RegionSize
|
||||
if mbi.State == 0x1000 and mbi.Type == 0x1000000:
|
||||
ab = mbi.AllocationBase or 0
|
||||
if ab not in seen:
|
||||
# Read SizeOfImage
|
||||
hdr = rd(h, ab + 0x3C, 4)
|
||||
img_size = 0
|
||||
if hdr:
|
||||
pe_off = struct.unpack('<I', hdr)[0]
|
||||
sz_b = rd(h, ab + pe_off + 4 + 20 + 56, 4)
|
||||
if sz_b:
|
||||
img_size = struct.unpack('<I', sz_b)[0]
|
||||
name = get_module_name(h, ab) or "?"
|
||||
seen[ab] = (name, img_size)
|
||||
next_addr = base + sz
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
k.CloseHandle(h)
|
||||
|
||||
print(f"{len(seen)} image bases found:")
|
||||
print(f" {'base':>10} {'size':>9} name")
|
||||
for ab in sorted(seen):
|
||||
name, sz = seen[ab]
|
||||
print(f" 0x{ab:08x} {sz:>9} {name}")
|
||||
172
tools/manual_purge.py
Normal file
172
tools/manual_purge.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""manual_purge.py <pid>
|
||||
|
||||
Force a UIItem pool drain by calling UpdateEmptySlots on every
|
||||
UIElement_ItemList instance in the target process. Bypasses any
|
||||
visibility-trigger questions — just calls the trim function directly
|
||||
via CreateRemoteThread.
|
||||
|
||||
Requires v8-minimal applied (otherwise UpdateEmptySlots bails on the
|
||||
internal IsVisible guard).
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
|
||||
|
||||
|
||||
UPDATEEMPTYSLOTS_VA = 0x004e4390
|
||||
# Heuristic: ItemList primary vtable — derive from xrefs.
|
||||
# For our test, we scan heap for ItemList vtable matches.
|
||||
# Actually we don't have the ItemList vtable VA — we need to find ItemLists
|
||||
# differently. Best: scan for UIItem container pattern (this->+0x608 valid).
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_VM_WRITE = 0x20
|
||||
PROCESS_VM_OPERATION = 0x8
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
PROCESS_CREATE_THREAD = 0x2
|
||||
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
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, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
|
||||
k.VirtualAllocEx.restype = wt.LPVOID
|
||||
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
k.CreateRemoteThread.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t,
|
||||
ctypes.c_void_p, ctypes.c_void_p, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
k.CreateRemoteThread.restype = wt.HANDLE
|
||||
k.WaitForSingleObject = k.WaitForSingleObject
|
||||
k.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
|
||||
k.WaitForSingleObject.restype = wt.DWORD
|
||||
|
||||
|
||||
def find_itemlist_instances(h):
|
||||
"""Scan heap for objects whose first DWORD points to a code address
|
||||
AND whose +0x610 looks like a small positive int (count) AND whose
|
||||
+0x608 points to a valid memory address (the items array).
|
||||
Returns list of candidate addresses."""
|
||||
results = []
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if mbi.State == 0x1000 and mbi.Type == 0x20000 and pr in (0x04, 0x40):
|
||||
try:
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
# Walk DWORD-aligned, check pattern at each potential instance
|
||||
end = len(data) - 0x614
|
||||
for off in range(0, end, 4):
|
||||
# vtable check: first DWORD points into image
|
||||
vt = struct.unpack_from("<I", data, off)[0]
|
||||
if vt < 0x00400000 or vt > 0x00800000:
|
||||
continue
|
||||
# +0x610 count check: small positive int
|
||||
cnt = struct.unpack_from("<I", data, off + 0x610)[0]
|
||||
if cnt == 0 or cnt > 2000:
|
||||
continue
|
||||
# +0x608 array pointer check: into private heap
|
||||
arr = struct.unpack_from("<I", data, off + 0x608)[0]
|
||||
if arr < 0x00400000 or arr > 0x80000000:
|
||||
continue
|
||||
results.append(mbi.BaseAddress + off)
|
||||
except Exception:
|
||||
pass
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def call_remote(h, target_va, ecx_arg):
|
||||
"""Allocate a tiny stub that loads ECX and calls target_va, then RETs.
|
||||
CreateRemoteThread runs the stub. ecx_arg = the this pointer."""
|
||||
# Stub: mov ecx, ecx_arg; mov eax, target_va; call eax; xor eax, eax; ret 4
|
||||
# Thread proc signature is DWORD WINAPI(LPVOID arg) — arg comes in as [esp+4]
|
||||
# We'll use the arg passed by CreateRemoteThread as the this ptr.
|
||||
stub = bytes([
|
||||
0x8b, 0x4c, 0x24, 0x04, # mov ecx, [esp+4] ; this
|
||||
0xb8,
|
||||
target_va & 0xff, (target_va >> 8) & 0xff,
|
||||
(target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va
|
||||
0xff, 0xd0, # call eax
|
||||
0x33, 0xc0, # xor eax, eax
|
||||
0xc2, 0x04, 0x00, # ret 4
|
||||
])
|
||||
page = k.VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
if not page:
|
||||
raise OSError(f"VirtualAllocEx failed err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not k.WriteProcessMemory(h, page, stub, len(stub), ctypes.byref(sz)):
|
||||
raise OSError(f"WriteProcessMemory stub failed")
|
||||
tid = wt.DWORD(0)
|
||||
th = k.CreateRemoteThread(h, None, 0, page, ctypes.c_void_p(ecx_arg), 0, ctypes.byref(tid))
|
||||
if not th:
|
||||
raise OSError(f"CreateRemoteThread failed err={ctypes.get_last_error()}")
|
||||
k.WaitForSingleObject(th, 5000) # 5 sec timeout
|
||||
k.CloseHandle(th)
|
||||
# Note: leaving stub page allocated (would need to free after)
|
||||
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--scan-only", action="store_true",
|
||||
help="just print candidate ItemList instances, don't call drain")
|
||||
ap.add_argument("--limit", type=int, default=999,
|
||||
help="max instances to drain (default 999)")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = k.OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
|
||||
PROCESS_QUERY_INFORMATION | PROCESS_CREATE_THREAD,
|
||||
False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
print(f"Scanning PID {args.pid} for ItemList candidates...")
|
||||
candidates = find_itemlist_instances(h)
|
||||
print(f"Found {len(candidates)} candidates")
|
||||
for c in candidates[:20]:
|
||||
print(f" 0x{c:08x}")
|
||||
if len(candidates) > 20:
|
||||
print(f" ... +{len(candidates)-20} more")
|
||||
|
||||
if args.scan_only:
|
||||
sys.exit(0)
|
||||
|
||||
if not candidates:
|
||||
print("No ItemList candidates found")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\nCalling UpdateEmptySlots on first {min(args.limit, len(candidates))} candidates...")
|
||||
for i, c in enumerate(candidates[:args.limit]):
|
||||
try:
|
||||
call_remote(h, UPDATEEMPTYSLOTS_VA, c)
|
||||
print(f" [{i+1}/{min(args.limit, len(candidates))}] called on 0x{c:08x}")
|
||||
except Exception as e:
|
||||
print(f" [{i+1}] FAILED on 0x{c:08x}: {e}")
|
||||
break
|
||||
|
||||
print("\nDone. Recount with count_uiitem_live.py to see effect.")
|
||||
k.CloseHandle(h)
|
||||
165
tools/owner_vtable_scan.py
Normal file
165
tools/owner_vtable_scan.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""
|
||||
owner_vtable_scan.py <dump.dmp>
|
||||
|
||||
Goal: identify which class owns the leaked 256-512KB buffers.
|
||||
|
||||
Method:
|
||||
1. Enumerate leaked 256-512KB Private RW regions (the "leak set").
|
||||
2. Build a set of candidate "pointer-to-buffer" values:
|
||||
region_base + delta for delta in {0, 8, 0x10, 0x18, 0x20, 0x30, 0x40}
|
||||
(covers different heap-header sizes incl. +ust, +hpa).
|
||||
3. Scan ALL committed RW memory for any DWORD whose value is in that
|
||||
candidate set. For each hit, the containing word at offset
|
||||
(hit_addr - field_offset) might be a field inside some object.
|
||||
4. For each hit, look BACKWARDS within the same heap entry for a vtable
|
||||
(a DWORD pointing into image memory, typically rdata). The first
|
||||
valid vtable found is the owner-object's vtable.
|
||||
5. Histogram by (owner_vtable, field_offset). The top entries reveal
|
||||
which class+field owns the leaked buffer.
|
||||
|
||||
Output: top vtable hits with their image-module attribution.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter, defaultdict
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Module map -> attribute vtable addresses
|
||||
mods = []
|
||||
for m in md.modules.modules:
|
||||
mods.append((m.baseaddress, m.size, m.name))
|
||||
def mod_of(addr):
|
||||
for b, s, n in mods:
|
||||
if b <= addr < b + s:
|
||||
return n.split("\\")[-1]
|
||||
return None
|
||||
|
||||
# Image-region ranges (for vtable validation)
|
||||
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))
|
||||
image_ranges.sort()
|
||||
def is_image(addr):
|
||||
for lo, hi in image_ranges:
|
||||
if lo <= addr < hi:
|
||||
return True
|
||||
if addr < lo:
|
||||
return False
|
||||
return False
|
||||
|
||||
# Leaked 256-512KB regions
|
||||
leaked = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
|
||||
and 256*1024 <= r.RegionSize < 512*1024:
|
||||
leaked.append((r.BaseAddress, r.RegionSize))
|
||||
print(f"leaked 256-512KB private RW regions: {len(leaked)}")
|
||||
|
||||
# Build candidate "pointer values" set
|
||||
deltas = [0, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x40, 0x50, 0x60]
|
||||
cand_to_region = {}
|
||||
for base, _sz in leaked:
|
||||
for d in deltas:
|
||||
cand_to_region[base + d] = base
|
||||
print(f"candidate pointer values: {len(cand_to_region)} (across {len(deltas)} deltas)")
|
||||
|
||||
# Scan all committed RW regions
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue
|
||||
if ty == 0x1000000: continue # skip Image
|
||||
if pr not in (0x04, 0x40): continue
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
total_bytes = sum(s for _, s in scan_regions)
|
||||
print(f"scanning {len(scan_regions)} writable non-image regions ({total_bytes/(1024*1024):.1f} MB)")
|
||||
|
||||
# Build a per-region buffer cache so we can do "lookback within same region"
|
||||
hits = [] # list of (hit_va, region_base_of_leaked_buf, value_pointed_at)
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v in cand_to_region:
|
||||
hits.append((base + off, cand_to_region[v], v, base, off, buf))
|
||||
print(f"raw pointer-into-leaked hits: {len(hits)}")
|
||||
|
||||
if not hits:
|
||||
print("no hits — leaked buffers are orphaned (no live pointers to them).")
|
||||
return
|
||||
|
||||
# For each hit, walk backwards within the same buffer up to N words looking
|
||||
# for a DWORD that is in image memory and aligned (vtable candidate).
|
||||
# Treat the hit as a "field at offset (off - vtbl_off) inside an object".
|
||||
LOOKBACK_BYTES = 0x200 # 512 bytes back
|
||||
|
||||
vtable_hits = Counter() # (vtable, field_offset) -> count
|
||||
vtable_only_hits = Counter() # vtable -> count
|
||||
field_offsets_per_vtable = defaultdict(Counter)
|
||||
examples = defaultdict(list)
|
||||
no_vtable = 0
|
||||
|
||||
for hit_va, leaked_base, ptr_val, reg_base, off, buf in hits:
|
||||
start = max(0, off - LOOKBACK_BYTES)
|
||||
# Walk backwards in 4-byte steps from (off - 4) down to start
|
||||
found = False
|
||||
for back in range(off - 4, start - 4, -4):
|
||||
if back < 0: break
|
||||
v = struct.unpack_from("<I", buf, back)[0]
|
||||
if v < 0x00400000 or v > 0x10000000:
|
||||
continue
|
||||
if is_image(v):
|
||||
vtable = v
|
||||
field_off = off - back
|
||||
vtable_hits[(vtable, field_off)] += 1
|
||||
vtable_only_hits[vtable] += 1
|
||||
field_offsets_per_vtable[vtable][field_off] += 1
|
||||
if len(examples[(vtable, field_off)]) < 3:
|
||||
examples[(vtable, field_off)].append((hit_va, leaked_base, ptr_val))
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
no_vtable += 1
|
||||
|
||||
print(f"hits with no preceding vtable in 0x200 lookback: {no_vtable}")
|
||||
print(f"unique (vtable, field_off) pairs: {len(vtable_hits)}")
|
||||
print(f"unique vtables: {len(vtable_only_hits)}")
|
||||
print()
|
||||
|
||||
print("=== Top vtables (regardless of field offset) ===")
|
||||
for vtbl, cnt in vtable_only_hits.most_common(25):
|
||||
owner = mod_of(vtbl) or "?"
|
||||
# Show the top field offsets seen for this vtable
|
||||
top_offs = field_offsets_per_vtable[vtbl].most_common(4)
|
||||
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
|
||||
print(f" 0x{vtbl:08x} count={cnt:<5} ({owner}) offsets: {offs_str}")
|
||||
|
||||
print()
|
||||
print("=== Top (vtable, field_offset) pairs ===")
|
||||
for (vtbl, off), cnt in vtable_hits.most_common(25):
|
||||
owner = mod_of(vtbl) or "?"
|
||||
ex = examples[(vtbl, off)][0]
|
||||
print(f" 0x{vtbl:08x} +0x{off:03x} count={cnt:<5} ({owner}) e.g. hit@0x{ex[0]:08x} -> leaked@0x{ex[1]:08x} val=0x{ex[2]:08x}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
160
tools/patch_cgfxobj_v4_test.py
Normal file
160
tools/patch_cgfxobj_v4_test.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""patch_cgfxobj_v4_test.py <pid> [--revert]
|
||||
|
||||
EXPERIMENTAL: Override CGfxObj's vtable slot 11 (ReleaseSubObjects,
|
||||
currently the DBObj no-op stub at 0x004154a0) with a tiny injected
|
||||
thunk that calls D3DPolyRender::DestroyMesh on this->constructed_mesh
|
||||
(field +0x6c).
|
||||
|
||||
If the orphan D3DXMesh leak comes from CGfxObj instances reaching
|
||||
DBOCache::FreeObject without their mesh being released first, this
|
||||
patch fixes that.
|
||||
|
||||
Risks:
|
||||
- If any code reads CGfxObj->constructed_mesh after cache-add and
|
||||
before re-Get-then-InitLoad, it will deref NULL and crash.
|
||||
- This is the same risk shape as v1 Palette patch which DID crash
|
||||
on copy-from-cached-source reads. For CGfxObj, the equivalent
|
||||
"read from cached source" path may or may not exist. Test on a
|
||||
single client first.
|
||||
|
||||
Mechanism:
|
||||
- VirtualAllocEx in target process for a 64-byte RWX page
|
||||
- Write the 18-byte thunk:
|
||||
53 push ebx
|
||||
8b d9 mov ebx, ecx (ecx = this)
|
||||
83 c3 6c add ebx, 0x6c (ebx = &this->constructed_mesh)
|
||||
53 push ebx
|
||||
b8 e0 d1 59 00 mov eax, 0x0059d1e0 (D3DPolyRender::DestroyMesh)
|
||||
ff d0 call eax
|
||||
83 c4 04 add esp, 4
|
||||
5b pop ebx
|
||||
b0 01 mov al, 1 (return 1)
|
||||
c3 ret
|
||||
- VirtualProtectEx + WriteProcessMemory to flip the vtable slot at
|
||||
0x007ca418 + 0x2c = 0x007ca444 from 0x004154a0 to the thunk addr.
|
||||
|
||||
Revert:
|
||||
- Restore vtable slot to 0x004154a0
|
||||
- The allocated page is leaked (intentional — easier than tracking
|
||||
its address)
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
|
||||
|
||||
VTABLE_CGFXOBJ = 0x007ca418
|
||||
SLOT_RELEASE_SUBOBJ = 0x2c
|
||||
SLOT_VA = VTABLE_CGFXOBJ + SLOT_RELEASE_SUBOBJ # 0x007ca444
|
||||
NOOP_STUB_VA = 0x004154a0
|
||||
DESTROY_MESH_VA = 0x0059d1e0
|
||||
|
||||
|
||||
THUNK = bytes([
|
||||
0x53, # push ebx
|
||||
0x8b, 0xd9, # mov ebx, ecx
|
||||
0x83, 0xc3, 0x6c, # add ebx, 0x6c
|
||||
0x53, # push ebx
|
||||
0xb8, 0xe0, 0xd1, 0x59, 0x00, # mov eax, 0x0059d1e0
|
||||
0xff, 0xd0, # call eax
|
||||
0x83, 0xc4, 0x04, # add esp, 4
|
||||
0x5b, # pop ebx
|
||||
0xb0, 0x01, # mov al, 1
|
||||
0xc3, # ret
|
||||
])
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_READWRITE = 0x04
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
VirtualAllocEx = k32.VirtualAllocEx
|
||||
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
|
||||
VirtualAllocEx.restype = wt.LPVOID
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true",
|
||||
help="restore the original no-op stub")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = ctypes.c_uint(0)
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, SLOT_VA, ctypes.byref(cur), 4, ctypes.byref(sz)):
|
||||
print(f"read SLOT_VA failed: err={ctypes.get_last_error()}"); sys.exit(3)
|
||||
print(f"PID {args.pid} vtable slot @ 0x{SLOT_VA:08x} current: 0x{cur.value:08x}")
|
||||
|
||||
if args.revert:
|
||||
if cur.value == NOOP_STUB_VA:
|
||||
print(f" already pointing at no-op stub — nothing to revert")
|
||||
CloseHandle(h); return
|
||||
old_prot = wt.DWORD(0)
|
||||
VirtualProtectEx(h, SLOT_VA, 4, PAGE_READWRITE, ctypes.byref(old_prot))
|
||||
new = ctypes.c_uint(NOOP_STUB_VA)
|
||||
WriteProcessMemory(h, SLOT_VA, ctypes.byref(new), 4, ctypes.byref(sz))
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, SLOT_VA, 4, old_prot.value, ctypes.byref(restored))
|
||||
print(f" reverted to 0x{NOOP_STUB_VA:08x}")
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur.value != NOOP_STUB_VA:
|
||||
print(f" UNEXPECTED current value (wanted 0x{NOOP_STUB_VA:08x}) — refusing to patch")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
|
||||
# Allocate RWX page for thunk
|
||||
thunk_va = VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
if not thunk_va:
|
||||
print(f"VirtualAllocEx failed: err={ctypes.get_last_error()}"); sys.exit(5)
|
||||
print(f" thunk page allocated @ 0x{thunk_va:08x}")
|
||||
|
||||
# Write thunk
|
||||
if not WriteProcessMemory(h, thunk_va, THUNK, len(THUNK), ctypes.byref(sz)):
|
||||
print(f"write thunk failed: err={ctypes.get_last_error()}"); sys.exit(6)
|
||||
|
||||
# Flip vtable slot
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, SLOT_VA, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
|
||||
print(f"VirtualProtectEx vtable failed: err={ctypes.get_last_error()}"); sys.exit(7)
|
||||
new = ctypes.c_uint(thunk_va)
|
||||
WriteProcessMemory(h, SLOT_VA, ctypes.byref(new), 4, ctypes.byref(sz))
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, SLOT_VA, 4, old_prot.value, ctypes.byref(restored))
|
||||
|
||||
# Verify
|
||||
ReadProcessMemory(h, SLOT_VA, ctypes.byref(cur), 4, ctypes.byref(sz))
|
||||
print(f" vtable slot now 0x{cur.value:08x} (patched OK)")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
96
tools/patch_freeobject_v2.py
Normal file
96
tools/patch_freeobject_v2.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""patch_freeobject_v2.py <pid> [--revert]
|
||||
|
||||
v2 patch: force DBOCache::FreeObject to always take the DELETE path
|
||||
(never add to freelist). This causes every Release-to-refcount-1 to
|
||||
invoke the proper destructor, which calls operator delete[] on the
|
||||
Palette ARGB buffer (and similar for other DBObj-derived classes).
|
||||
|
||||
Target byte: EoR 0x004169bf
|
||||
Original: 0x74 (jz delete_path)
|
||||
Patched: 0xeb (jmp delete_path) — unconditional
|
||||
|
||||
Effect:
|
||||
- DBOCache::FreeObject(this, obj) now always:
|
||||
obj->ReleaseSubObjects(); obj->in_use = 0; this->vt[0x44](obj);
|
||||
- Skips the "if freelist enabled, push to freelist" branch
|
||||
- Affects ALL DBObj-derived classes (not just Palette)
|
||||
|
||||
Risk:
|
||||
- Performance: cache misses now require full alloc + load instead of
|
||||
reuse from freelist. For frequently-used classes, may add latency.
|
||||
- Correctness: should be safe — delete path uses proper destructor
|
||||
that already handles all field cleanup.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, sys
|
||||
|
||||
TARGET_ADDR = 0x004169bf
|
||||
EXPECTED_VAL = 0x74 # jz
|
||||
PATCHED_VAL = 0xeb # jmp
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
|
||||
wt.DWORD, ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: patch_freeobject_v2.py <pid> [--revert]"); sys.exit(1)
|
||||
pid = int(sys.argv[1])
|
||||
revert = "--revert" in sys.argv
|
||||
|
||||
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
|
||||
False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = ctypes.c_ubyte(0); sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 1, ctypes.byref(sz)):
|
||||
print(f"read failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(3)
|
||||
print(f"PID {pid} byte @ 0x{TARGET_ADDR:08x} current: 0x{cur.value:02x}")
|
||||
|
||||
target = EXPECTED_VAL if revert else PATCHED_VAL
|
||||
expect_before = PATCHED_VAL if revert else EXPECTED_VAL
|
||||
|
||||
if cur.value == target:
|
||||
print(f" already {'reverted' if revert else 'patched'}")
|
||||
CloseHandle(h); return
|
||||
if cur.value != expect_before:
|
||||
print(f" UNEXPECTED current value (wanted 0x{expect_before:02x}) — aborting")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, TARGET_ADDR, 1, PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
print(f"VirtualProtectEx failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
|
||||
new = ctypes.c_ubyte(target)
|
||||
if not WriteProcessMemory(h, TARGET_ADDR, ctypes.byref(new), 1, ctypes.byref(sz)):
|
||||
print(f"write failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, TARGET_ADDR, 1, old_prot.value, ctypes.byref(restored))
|
||||
|
||||
ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 1, ctypes.byref(sz))
|
||||
print(f" new value: 0x{cur.value:02x} ({'reverted' if revert else 'patched'})")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
106
tools/patch_palette_v1.py
Normal file
106
tools/patch_palette_v1.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""patch_palette_v1.py <pid> [--revert]
|
||||
|
||||
Apply (or revert) the Palette leak fix to a running acclient.exe.
|
||||
|
||||
Mechanism:
|
||||
Overwrite Palette's primary vtable slot +0x2c (which is the
|
||||
DBObj::ReleaseSubObjects no-op stub) to point at Palette's existing
|
||||
PurgeResource function (slot +0x3c) instead. This causes
|
||||
DBOCache::FreeObject's ReleaseSubObjects call to actually free the
|
||||
ARGB buffer when the palette goes to the freelist.
|
||||
|
||||
Target: EoR vtable 0x007caa08, slot +0x2c
|
||||
Replace value: 0x004154a0 (no-op stub, returns 1)
|
||||
With value: 0x0053ecf0 (PurgeResource — frees +0x40 buffer)
|
||||
|
||||
Safety:
|
||||
- Idempotent: only patches if current value matches the no-op stub
|
||||
- Revert via --revert flag
|
||||
- Saves before-image to backup
|
||||
- The dtor at 0x0053f0d0 guards against NULL — no double-free risk
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
VT_PRIMARY = 0x007caa08
|
||||
SLOT_OFFSET = 0x2c
|
||||
TARGET_ADDR = VT_PRIMARY + SLOT_OFFSET # 0x007caa34
|
||||
EXPECTED_VAL = 0x004154a0 # no-op ReleaseSubObjects stub
|
||||
PATCHED_VAL = 0x0053ecf0 # Palette::PurgeResource
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
|
||||
PAGE_READWRITE = 0x04
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
|
||||
wt.DWORD, ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: patch_palette_v1.py <pid> [--revert]"); sys.exit(1)
|
||||
pid = int(sys.argv[1])
|
||||
revert = "--revert" in sys.argv
|
||||
|
||||
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
|
||||
False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
# Read current value
|
||||
cur = ctypes.c_uint(0); sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 4, ctypes.byref(sz)):
|
||||
print(f"read failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(3)
|
||||
print(f"PID {pid} slot @ 0x{TARGET_ADDR:08x} current: 0x{cur.value:08x}")
|
||||
|
||||
target = EXPECTED_VAL if revert else PATCHED_VAL
|
||||
expect_before = PATCHED_VAL if revert else EXPECTED_VAL
|
||||
|
||||
if cur.value == target:
|
||||
print(f" already {'reverted' if revert else 'patched'} — nothing to do")
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur.value != expect_before:
|
||||
print(f" UNEXPECTED current value (expected 0x{expect_before:08x}) — aborting to avoid corruption")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
|
||||
# Make page writable
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, TARGET_ADDR, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
|
||||
print(f"VirtualProtectEx failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
|
||||
print(f" old protect: 0x{old_prot.value:x}")
|
||||
|
||||
# Write new value
|
||||
new = ctypes.c_uint(target)
|
||||
if not WriteProcessMemory(h, TARGET_ADDR, ctypes.byref(new), 4, ctypes.byref(sz)):
|
||||
print(f"write failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
|
||||
|
||||
# Restore original protection
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, TARGET_ADDR, 4, old_prot.value, ctypes.byref(restored))
|
||||
|
||||
# Verify
|
||||
ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 4, ctypes.byref(sz))
|
||||
print(f" new value: 0x{cur.value:08x} ({'reverted' if revert else 'patched'} successfully)")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
tools/patch_palette_v3.py
Normal file
98
tools/patch_palette_v3.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""patch_palette_v3.py <pid> [--revert]
|
||||
|
||||
v3 patch: NOP the extra refcount increment in Palette::makeModifiedPalette
|
||||
(EoR FUN_0053efe0). This stops the +1 over-increment that leaves every
|
||||
new "modified palette" at refcount=2 instead of refcount=1.
|
||||
|
||||
Background:
|
||||
FUN_0053efe0 = Palette::makeModifiedPalette():
|
||||
new(0x48); Palette::Palette(this, 0x800); this->refcount++; return;
|
||||
The "this->refcount++" makes the returned palette have refcount=2.
|
||||
After caller's single Release, refcount=1, and the palette becomes
|
||||
unreachable (m_pMaintainer is NULL — no cache reference) but alive.
|
||||
|
||||
56,664 leaked palettes in dump_9156 are all this shape: rc=1,
|
||||
m_pMaintainer=0, m_numLinks=0.
|
||||
|
||||
Target bytes: 0x0053effe (3 bytes inside FUN_0053efe0)
|
||||
Original: ff 40 24 ; inc dword [eax+0x24] ; refcount++
|
||||
Patched: 90 90 90 ; nop nop nop
|
||||
|
||||
The 2013 build has the same +1 pattern at 0x0053e29e — leak likely
|
||||
exists there too, just less noticeable.
|
||||
|
||||
Risk: some caller may assume refcount=2 and release twice; after this
|
||||
patch, the second release would crash on already-freed memory. Test on
|
||||
one client and watch closely.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, sys
|
||||
|
||||
TARGET_ADDR = 0x0053effe
|
||||
ORIGINAL_BYTES = bytes([0xff, 0x40, 0x24])
|
||||
PATCHED_BYTES = bytes([0x90, 0x90, 0x90])
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
|
||||
wt.DWORD, ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def main():
|
||||
pid = int(sys.argv[1])
|
||||
revert = "--revert" in sys.argv
|
||||
|
||||
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
|
||||
False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = (ctypes.c_ubyte * 3)(); sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, TARGET_ADDR, cur, 3, ctypes.byref(sz)):
|
||||
print(f"read failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(3)
|
||||
cur_b = bytes(cur)
|
||||
print(f"PID {pid} bytes @ 0x{TARGET_ADDR:08x}: {' '.join(f'{b:02x}' for b in cur_b)}")
|
||||
|
||||
target = ORIGINAL_BYTES if revert else PATCHED_BYTES
|
||||
expect_before = PATCHED_BYTES if revert else ORIGINAL_BYTES
|
||||
|
||||
if cur_b == target:
|
||||
print(f" already {'reverted' if revert else 'patched'}"); CloseHandle(h); return
|
||||
if cur_b != expect_before:
|
||||
print(f" UNEXPECTED current bytes (wanted {' '.join(f'{b:02x}' for b in expect_before)}) — aborting")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, TARGET_ADDR, 3, PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
print(f"VirtualProtectEx failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
|
||||
new = (ctypes.c_ubyte * 3)(*target)
|
||||
if not WriteProcessMemory(h, TARGET_ADDR, new, 3, ctypes.byref(sz)):
|
||||
print(f"write failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, TARGET_ADDR, 3, old_prot.value, ctypes.byref(restored))
|
||||
|
||||
ReadProcessMemory(h, TARGET_ADDR, cur, 3, ctypes.byref(sz))
|
||||
print(f" new bytes: {' '.join(f'{b:02x}' for b in bytes(cur))} ({'reverted' if revert else 'patched'})")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
89
tools/patch_palette_v3b.py
Normal file
89
tools/patch_palette_v3b.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""patch_palette_v3b.py <pid> [--revert]
|
||||
|
||||
v3b: extended v3 — patches BOTH makeModifiedPalette overloads.
|
||||
|
||||
Site 1 (no-arg overload, FUN_0053efe0):
|
||||
addr 0x0053effe ff 40 24 -> 90 90 90 ; nop refcount++
|
||||
|
||||
Site 2 (2-arg overload, FUN_0053f120):
|
||||
addr 0x0053f19c ff 46 24 -> 90 90 90 ; nop refcount++
|
||||
|
||||
Both write a refcount++ on a freshly-constructed (refcount=1) palette,
|
||||
leaving it at refcount=2 — which is one short of being released back to
|
||||
zero by the typical cleanup chain (releasePalette + CSurface dtor).
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, sys
|
||||
|
||||
SITES = [
|
||||
(0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
|
||||
(0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
|
||||
]
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
|
||||
wt.DWORD, ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def patch_site(h, addr, orig, patched, revert):
|
||||
cur = (ctypes.c_ubyte * 3)(); sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, cur, 3, ctypes.byref(sz)):
|
||||
print(f" read failed @ 0x{addr:08x}: err={ctypes.get_last_error()}"); return False
|
||||
cur_b = bytes(cur)
|
||||
target = orig if revert else patched
|
||||
expect_before = patched if revert else orig
|
||||
print(f" @ 0x{addr:08x}: cur={' '.join(f'{b:02x}' for b in cur_b)}", end="")
|
||||
if cur_b == target:
|
||||
print(f" already {'reverted' if revert else 'patched'}")
|
||||
return True
|
||||
if cur_b != expect_before:
|
||||
print(f" UNEXPECTED (wanted {' '.join(f'{b:02x}' for b in expect_before)}) — skip")
|
||||
return False
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, 3, PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
print(f" VirtualProtectEx failed: err={ctypes.get_last_error()}"); return False
|
||||
new = (ctypes.c_ubyte * 3)(*target)
|
||||
if not WriteProcessMemory(h, addr, new, 3, ctypes.byref(sz)):
|
||||
print(f" write failed: err={ctypes.get_last_error()}"); return False
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, 3, old_prot.value, ctypes.byref(restored))
|
||||
print(f" -> {' '.join(f'{b:02x}' for b in target)} ({'reverted' if revert else 'patched'})")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
pid = int(sys.argv[1])
|
||||
revert = "--revert" in sys.argv
|
||||
|
||||
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
|
||||
False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
print(f"PID {pid}:")
|
||||
for addr, orig, patched in SITES:
|
||||
patch_site(h, addr, orig, patched, revert)
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
231
tools/patch_purge_v5_test.py
Normal file
231
tools/patch_purge_v5_test.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""patch_purge_v5_test.py <pid> [--revert]
|
||||
|
||||
EXPERIMENTAL: Override RenderSurface and RenderTexture's PurgeResource
|
||||
vtable slot (slot 2 = +0x08) with thunks that call the existing
|
||||
Destroy() method on each class, then return true so the purge loop
|
||||
properly marks the resource as cleaned up.
|
||||
|
||||
Background:
|
||||
GraphicsResource::PurgeResource is virtual (vtable slot 2). The
|
||||
base implementation is the no-op stub at 0x004154a0 (mov al, 1; ret).
|
||||
D3D-specialized subclasses (RenderTextureD3D, RenderSurfaceD3D,
|
||||
CSurface, ImgTex) properly override slot 2 to free their GPU
|
||||
resources. But the BASE RenderSurface and RenderTexture inherit
|
||||
the no-op stub. GraphicsResource::PurgeOldResources iterates
|
||||
resources, calls vtable[2], and if it returns true, marks them
|
||||
as "purged" with a flag (+0x08 on the resource) so they're never
|
||||
retried. The no-op returns true → mark as purged → permanent leak.
|
||||
|
||||
Verified by:
|
||||
* comparative dump diff (Time/Nyckel low-leak vs Larsson/Jerry
|
||||
high-leak): GraphicsResource vtable is the top-ranked residual
|
||||
leak owner with 367 instances in heavy-looter Larsson vs 0 in
|
||||
low-activity Time.
|
||||
* subclass scan: RenderSurface (vtable 0x0079a67c) and
|
||||
RenderTexture (vtable 0x0079c198) have slot 2 = 0x004154a0
|
||||
(no-op); six other GraphicsResource subclasses override.
|
||||
* decompile: PurgeOldResources at EoR 0x00446dc0 calls vtable[2]
|
||||
and sets resource->+0x02 = 1 on success.
|
||||
|
||||
Fix:
|
||||
Replace slot 2 in each leaking vtable with a thunk that calls
|
||||
RenderSurface::Destroy (EoR 0x00444540) / RenderTexture::Destroy
|
||||
(EoR 0x0044c4f0). These methods exist and do idempotent cleanup
|
||||
(delete heavy heap allocations, null pointers, reset state).
|
||||
They don't tear down the C++ object - perfect for PurgeResource.
|
||||
|
||||
Risks:
|
||||
* If a leaked RenderSurface is in some half-destroyed state where
|
||||
fields +0x64 / +0x114 are dangling (not NULL but point to freed
|
||||
memory), Destroy will double-free. Believed unlikely since
|
||||
Destroy is idempotent and checks for NULL before delete, and
|
||||
the purge loop's eligibility check (+0x6 != currentFrame) only
|
||||
runs purge on resources not in use this frame.
|
||||
* Same shape as v4 (thunk injection + vtable rewrite). v4 has run
|
||||
without crashes on 3 clients for hours.
|
||||
|
||||
Thunks (10 bytes each):
|
||||
Thunk A — RenderSurface PurgeResource:
|
||||
B8 40 45 44 00 mov eax, 0x00444540 ; RenderSurface::Destroy
|
||||
FF D0 call eax ; thiscall - this in ecx
|
||||
B0 01 mov al, 1 ; return true
|
||||
C3 ret
|
||||
|
||||
Thunk B — RenderTexture PurgeResource:
|
||||
B8 F0 C4 44 00 mov eax, 0x0044c4f0 ; RenderTexture::Destroy
|
||||
FF D0 call eax
|
||||
B0 01 mov al, 1
|
||||
C3 ret
|
||||
|
||||
Vtable slots patched:
|
||||
RenderSurface @ 0x0079a67c + 0x08 = 0x0079a684 → thunk A addr
|
||||
RenderTexture @ 0x0079c198 + 0x08 = 0x0079c1a0 → thunk B addr
|
||||
|
||||
Both currently point to 0x004154a0 (no-op stub).
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
|
||||
|
||||
# Targets
|
||||
NOOP_STUB_VA = 0x004154a0
|
||||
RENDERSURFACE_DESTROY_VA = 0x00444540
|
||||
RENDERTEXTURE_DESTROY_VA = 0x0044c4f0
|
||||
|
||||
RENDERSURFACE_VTABLE_VA = 0x0079a67c
|
||||
RENDERTEXTURE_VTABLE_VA = 0x0079c198
|
||||
|
||||
SLOT_PURGERESOURCE = 0x08
|
||||
|
||||
RS_SLOT_VA = RENDERSURFACE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079a684
|
||||
RT_SLOT_VA = RENDERTEXTURE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079c1a0
|
||||
|
||||
|
||||
def make_thunk(target_va: int) -> bytes:
|
||||
return bytes([
|
||||
0xB8, target_va & 0xff, (target_va >> 8) & 0xff,
|
||||
(target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va
|
||||
0xFF, 0xD0, # call eax
|
||||
0xB0, 0x01, # mov al, 1
|
||||
0xC3, # ret
|
||||
])
|
||||
|
||||
|
||||
THUNK_RS = make_thunk(RENDERSURFACE_DESTROY_VA)
|
||||
THUNK_RT = make_thunk(RENDERTEXTURE_DESTROY_VA)
|
||||
assert len(THUNK_RS) == 10
|
||||
assert len(THUNK_RT) == 10
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_READWRITE = 0x04
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
||||
OpenProcess.restype = wt.HANDLE
|
||||
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]
|
||||
CloseHandle.restype = wt.BOOL
|
||||
|
||||
VirtualAllocEx = k32.VirtualAllocEx
|
||||
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
|
||||
VirtualAllocEx.restype = wt.LPVOID
|
||||
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_dword(h, addr):
|
||||
out = ctypes.c_uint(0)
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, ctypes.byref(out), 4, ctypes.byref(sz)):
|
||||
raise OSError(f"ReadProcessMemory 0x{addr:08x} failed err={ctypes.get_last_error()}")
|
||||
return out.value
|
||||
|
||||
|
||||
def write_dword(h, addr, value):
|
||||
"""Write a DWORD into a (typically read-only) vtable region by
|
||||
flipping protection RW, writing, then restoring."""
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx RW 0x{addr:08x} failed err={ctypes.get_last_error()}")
|
||||
v = ctypes.c_uint(value)
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, ctypes.byref(v), 4, ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, 4, old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"WriteProcessMemory 0x{addr:08x} failed err={err}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true",
|
||||
help="restore the no-op stub at both vtable slots")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
|
||||
sys.exit(2)
|
||||
|
||||
rs_cur = read_dword(h, RS_SLOT_VA)
|
||||
rt_cur = read_dword(h, RT_SLOT_VA)
|
||||
print(f"PID {args.pid}")
|
||||
print(f" RenderSurface vtable slot @ 0x{RS_SLOT_VA:08x} = 0x{rs_cur:08x}")
|
||||
print(f" RenderTexture vtable slot @ 0x{RT_SLOT_VA:08x} = 0x{rt_cur:08x}")
|
||||
|
||||
if args.revert:
|
||||
if rs_cur == NOOP_STUB_VA and rt_cur == NOOP_STUB_VA:
|
||||
print(" both already no-op — nothing to revert")
|
||||
CloseHandle(h); return
|
||||
if rs_cur != NOOP_STUB_VA:
|
||||
write_dword(h, RS_SLOT_VA, NOOP_STUB_VA)
|
||||
print(f" RenderSurface slot reverted to 0x{NOOP_STUB_VA:08x}")
|
||||
if rt_cur != NOOP_STUB_VA:
|
||||
write_dword(h, RT_SLOT_VA, NOOP_STUB_VA)
|
||||
print(f" RenderTexture slot reverted to 0x{NOOP_STUB_VA:08x}")
|
||||
CloseHandle(h); return
|
||||
|
||||
if rs_cur != NOOP_STUB_VA or rt_cur != NOOP_STUB_VA:
|
||||
print(f" UNEXPECTED — one or both slots not the no-op stub "
|
||||
f"(wanted 0x{NOOP_STUB_VA:08x}). Refusing to patch.")
|
||||
CloseHandle(h); sys.exit(3)
|
||||
|
||||
page = VirtualAllocEx(h, None, 0x80, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
if not page:
|
||||
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
print(f" thunk page allocated @ 0x{page:08x}")
|
||||
|
||||
thunk_rs_addr = page
|
||||
thunk_rt_addr = page + 0x20 # padded for alignment + safety
|
||||
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not WriteProcessMemory(h, thunk_rs_addr, THUNK_RS, len(THUNK_RS), ctypes.byref(sz)):
|
||||
print(f"write THUNK_RS failed err={ctypes.get_last_error()}"); sys.exit(5)
|
||||
if not WriteProcessMemory(h, thunk_rt_addr, THUNK_RT, len(THUNK_RT), ctypes.byref(sz)):
|
||||
print(f"write THUNK_RT failed err={ctypes.get_last_error()}"); sys.exit(6)
|
||||
print(f" THUNK_RS @ 0x{thunk_rs_addr:08x}: {THUNK_RS.hex()}")
|
||||
print(f" THUNK_RT @ 0x{thunk_rt_addr:08x}: {THUNK_RT.hex()}")
|
||||
|
||||
write_dword(h, RS_SLOT_VA, thunk_rs_addr)
|
||||
write_dword(h, RT_SLOT_VA, thunk_rt_addr)
|
||||
|
||||
rs_after = read_dword(h, RS_SLOT_VA)
|
||||
rt_after = read_dword(h, RT_SLOT_VA)
|
||||
print(f" RenderSurface slot now 0x{rs_after:08x} {'OK' if rs_after == thunk_rs_addr else 'MISMATCH'}")
|
||||
print(f" RenderTexture slot now 0x{rt_after:08x} {'OK' if rt_after == thunk_rt_addr else 'MISMATCH'}")
|
||||
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
122
tools/patch_v10_test.py
Normal file
122
tools/patch_v10_test.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""patch_v10_test.py <pid> [--revert]
|
||||
|
||||
v10: NOP the visible=false branch of UIElement_ItemList::OnVisibilityChanged
|
||||
so it ALWAYS calls UpdateEmptySlots (instead of only when becoming visible).
|
||||
|
||||
Combined with v8-minimal (which makes UpdateEmptySlots run despite the
|
||||
visibility check inside it), this drains the WAITING-state UIItem pool
|
||||
on every panel close.
|
||||
|
||||
REQUIRES v8-minimal to be applied first (otherwise UpdateEmptySlots
|
||||
bails immediately on the IsVisible() check inside).
|
||||
|
||||
Site:
|
||||
0x004E49AD: 74 07 jz +0x07 → skip UpdateEmptySlots when vis==false
|
||||
Replace: 90 90 nop nop → fall through, always call UpdateEmptySlots
|
||||
|
||||
Just 2 bytes changed. The simplest patch yet.
|
||||
|
||||
Risk:
|
||||
- v10 alone (without v8): UpdateEmptySlots would be called on hide but
|
||||
immediately bail at IsVisible() — no-op. Safe degradation.
|
||||
- v10 + v8: existing trim logic drains pool on hide. Same code that
|
||||
safely runs on show transitions.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
|
||||
|
||||
SITE_VA = 0x004E49AD
|
||||
ORIG_BYTES = bytes([0x74, 0x07])
|
||||
NOP_BYTES = bytes([0x90, 0x90])
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = read_bytes(h, SITE_VA, 2)
|
||||
print(f"PID {args.pid}")
|
||||
print(f" site @ 0x{SITE_VA:08x} current: {cur.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur == ORIG_BYTES:
|
||||
print(f" already original — nothing to revert")
|
||||
elif cur == NOP_BYTES:
|
||||
write_bytes(h, SITE_VA, ORIG_BYTES)
|
||||
print(f" reverted; bytes now: {read_bytes(h, SITE_VA, 2).hex()}")
|
||||
else:
|
||||
print(f" UNEXPECTED bytes — refusing"); sys.exit(3)
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur == NOP_BYTES:
|
||||
print(f" already patched")
|
||||
elif cur == ORIG_BYTES:
|
||||
write_bytes(h, SITE_VA, NOP_BYTES)
|
||||
print(f" patched; bytes now: {read_bytes(h, SITE_VA, 2).hex()}")
|
||||
else:
|
||||
print(f" UNEXPECTED bytes — refusing. Expected: {ORIG_BYTES.hex()}")
|
||||
sys.exit(4)
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
123
tools/patch_v11_test.py
Normal file
123
tools/patch_v11_test.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""patch_v11_test.py <pid> [--revert]
|
||||
|
||||
v11: NULL-check / crash-guard patches for two observed crash sites.
|
||||
|
||||
Site 1 — delete_contents (Barris's crash at 0x0058712f)
|
||||
Function at 0x005870f0. Inner trim loop has a state inconsistency
|
||||
path that sets EDI=0 and falls through to MOV EAX,[EDI] → AV.
|
||||
Patch: change the `JMP +0x07` at 0x00587126 (`eb 07`) to
|
||||
`JMP +0x42` (`eb 42`) → jumps to function epilogue at 0x0058716a
|
||||
(pop edi; pop esi; ret). Bails on empty-bucket-pass instead of
|
||||
UAF.
|
||||
|
||||
Site 2 — ~GXTri3Mesh slot 0 (Kolossos's crash at 0x005e565d)
|
||||
Destructor walks 5 subobject slots via vtable. The dereference at
|
||||
0x005e565d (`MOV ECX,[EAX]`) crashes when slot pointer is freed.
|
||||
Patch: replace the 9-byte deref+call+clear sequence
|
||||
(`8b 08 50 ff 51 08 89 5e 08`) with
|
||||
`89 5e 08 90 90 90 90 90 90`. Clears the slot but skips the
|
||||
virtual call → trades subobject leak for no crash.
|
||||
|
||||
This is the v11 patcher. Only patches the ONE slot we've observed
|
||||
crashing (slot 0 / +0x08 on the mesh). Other slots may need same
|
||||
treatment if they crash later.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, sys
|
||||
|
||||
|
||||
SITES = [
|
||||
("site1 delete_contents JMP", 0x00587126,
|
||||
bytes([0xEB, 0x07]),
|
||||
bytes([0xEB, 0x42])),
|
||||
("site2 ~GXTri3Mesh slot 0", 0x005E565D,
|
||||
bytes([0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08]),
|
||||
bytes([0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
|
||||
]
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_VM_WRITE = 0x20
|
||||
PROCESS_VM_OPERATION = 0x8
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def apply_or_revert(h, label, va, orig, patched, revert):
|
||||
cur = read_bytes(h, va, len(orig))
|
||||
print(f" {label} @ 0x{va:08x}: current {cur.hex()}")
|
||||
if revert:
|
||||
if cur == orig:
|
||||
print(f" already original")
|
||||
elif cur == patched:
|
||||
write_bytes(h, va, orig)
|
||||
print(f" reverted; now: {read_bytes(h, va, len(orig)).hex()}")
|
||||
else:
|
||||
print(f" UNEXPECTED bytes — refusing"); sys.exit(3)
|
||||
return
|
||||
if cur == patched:
|
||||
print(f" already patched")
|
||||
elif cur == orig:
|
||||
write_bytes(h, va, patched)
|
||||
print(f" patched; now: {read_bytes(h, va, len(orig)).hex()}")
|
||||
else:
|
||||
print(f" UNEXPECTED bytes — refusing. Expected: {orig.hex()}")
|
||||
sys.exit(4)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
|
||||
PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
print(f"PID {args.pid}")
|
||||
for label, va, orig, patched in SITES:
|
||||
apply_or_revert(h, label, va, orig, patched, args.revert)
|
||||
print(f" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
178
tools/patch_v12_test.py
Normal file
178
tools/patch_v12_test.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""patch_v12_test.py <pid> [--revert]
|
||||
|
||||
v12: NULL/range validator for the unpacker at 0x00526a50.
|
||||
|
||||
This function (presumably AnimSequenceNode UnPack overload, or similar
|
||||
4-dword stream reader) is called via vtable slot at 0x007c92c8. It
|
||||
dereferences `*(ctx)` without validating the cursor pointer. Two
|
||||
confirmed crashes here (Shadow 20:28, Frank 23:22), both with bad
|
||||
cursor pointers from leak-corrupted contexts.
|
||||
|
||||
Mechanism (2 sites):
|
||||
1. Write 29-byte validator at 0x00526a45 (uses 11-byte NOP pad +
|
||||
overwrites first 18 bytes of original function).
|
||||
Validator: size check + EAX load + cursor non-NULL + cursor
|
||||
>= 0x00400000 → success path jumps to 0x00526a62 (body unchanged);
|
||||
failure path returns 0 (same as original size-check-fail).
|
||||
2. Redirect vtable slot at 0x007c92c8 from 0x00526a50 → 0x00526a45.
|
||||
|
||||
After patch, all dispatched calls hit the validator first.
|
||||
The body from 0x00526a62 onward is untouched.
|
||||
|
||||
Risk:
|
||||
- If a direct caller of 0x00526a50 exists (not via vtable), it'd
|
||||
land mid-validator at MOV EDX,[EAX] with EAX uninitialized.
|
||||
Ghidra found only the vtable xref, so unlikely.
|
||||
- Writing to a vtable in process memory is fine (we VirtualProtectEx).
|
||||
- 11 NOPs + 18 original-entry bytes = 29 bytes total replacement.
|
||||
No overlap into the body at 0x00526a62.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
|
||||
|
||||
|
||||
VALIDATOR_VA = 0x00526a45
|
||||
DISPATCH_VA = 0x007c92c8
|
||||
OLD_FUNC_VA = 0x00526a50
|
||||
|
||||
VALIDATOR_BYTES = bytes([
|
||||
# 0x00526a45 CMP DWORD [ESP+8], 0x10
|
||||
0x83, 0x7C, 0x24, 0x08, 0x10,
|
||||
# 0x00526a4a JB +0x11 → 0x00526a5d (fail)
|
||||
0x72, 0x11,
|
||||
# 0x00526a4c MOV EAX, [ESP+4] (EAX = ctx)
|
||||
0x8B, 0x44, 0x24, 0x04,
|
||||
# 0x00526a50 MOV EDX, [EAX] (EDX = cursor)
|
||||
0x8B, 0x10,
|
||||
# 0x00526a52 CMP EDX, 0x00400000 (cursor >= image base?)
|
||||
0x81, 0xFA, 0x00, 0x00, 0x40, 0x00,
|
||||
# 0x00526a58 JB +0x03 → 0x00526a5d (fail)
|
||||
0x72, 0x03,
|
||||
# 0x00526a5a JMP +0x06 → 0x00526a62 (body)
|
||||
0xEB, 0x06,
|
||||
# 0x00526a5c NOP (filler)
|
||||
0x90,
|
||||
# 0x00526a5d XOR EAX, EAX
|
||||
0x33, 0xC0,
|
||||
# 0x00526a5f RET 8
|
||||
0xC2, 0x08, 0x00,
|
||||
])
|
||||
assert len(VALIDATOR_BYTES) == 29
|
||||
|
||||
# Original bytes at validator site: 11 NOPs + 18 bytes of original function entry
|
||||
ORIG_VALIDATOR = bytes([0x90] * 11) + bytes([
|
||||
0x83, 0x7C, 0x24, 0x08, 0x10, # CMP [ESP+8], 0x10
|
||||
0x73, 0x05, # JAE +5
|
||||
0x33, 0xC0, # XOR EAX, EAX
|
||||
0xC2, 0x08, 0x00, # RET 8
|
||||
0x8B, 0x44, 0x24, 0x04, # MOV EAX, [ESP+4]
|
||||
0x8B, 0x10 # MOV EDX, [EAX]
|
||||
])
|
||||
assert len(ORIG_VALIDATOR) == 29
|
||||
|
||||
OLD_DISPATCH = struct.pack("<I", OLD_FUNC_VA)
|
||||
NEW_DISPATCH = struct.pack("<I", VALIDATOR_VA)
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_VM_WRITE = 0x20
|
||||
PROCESS_VM_OPERATION = 0x8
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_READWRITE = 0x04
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data, prot=PAGE_EXECUTE_READWRITE):
|
||||
old = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), prot, ctypes.byref(old)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
|
||||
PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
print(f"PID {args.pid}")
|
||||
|
||||
# Read both sites
|
||||
cur_validator = read_bytes(h, VALIDATOR_VA, 29)
|
||||
cur_dispatch = read_bytes(h, DISPATCH_VA, 4)
|
||||
print(f" validator @ 0x{VALIDATOR_VA:08x}: cur {cur_validator.hex()}")
|
||||
print(f" dispatch @ 0x{DISPATCH_VA:08x}: cur {cur_dispatch.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur_dispatch == OLD_DISPATCH:
|
||||
print(f" already original (dispatch)")
|
||||
elif cur_dispatch == NEW_DISPATCH:
|
||||
# 1. First restore dispatch (callers go back to original function)
|
||||
write_bytes(h, DISPATCH_VA, OLD_DISPATCH)
|
||||
# 2. Then restore validator bytes
|
||||
write_bytes(h, VALIDATOR_VA, ORIG_VALIDATOR)
|
||||
print(f" reverted both sites")
|
||||
else:
|
||||
print(f" UNEXPECTED dispatch — refusing"); sys.exit(3)
|
||||
CloseHandle(h); return
|
||||
|
||||
# Apply: validator first, then dispatch
|
||||
if cur_dispatch == NEW_DISPATCH:
|
||||
print(f" already patched")
|
||||
CloseHandle(h); return
|
||||
if cur_dispatch != OLD_DISPATCH:
|
||||
print(f" UNEXPECTED dispatch. Expected {OLD_DISPATCH.hex()}")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
if cur_validator != ORIG_VALIDATOR:
|
||||
print(f" UNEXPECTED validator-site bytes. Expected {ORIG_VALIDATOR.hex()}")
|
||||
CloseHandle(h); sys.exit(5)
|
||||
|
||||
print(f" writing validator ({len(VALIDATOR_BYTES)} bytes)")
|
||||
write_bytes(h, VALIDATOR_VA, VALIDATOR_BYTES)
|
||||
print(f" verify: {read_bytes(h, VALIDATOR_VA, 29).hex()}")
|
||||
print(f" writing dispatch redirect")
|
||||
write_bytes(h, DISPATCH_VA, NEW_DISPATCH, prot=PAGE_READWRITE)
|
||||
print(f" verify: {read_bytes(h, DISPATCH_VA, 4).hex()}")
|
||||
print(f" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
145
tools/patch_v13_test.py
Normal file
145
tools/patch_v13_test.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""patch_v13_test.py <pid> [--revert]
|
||||
|
||||
v13: Hook SmartBox::DoPickupEvent to call unparent_children on the
|
||||
unequipped CPhysicsObj's children before continuing.
|
||||
|
||||
This fixes the weapon-switch leak: each unequip currently calls
|
||||
unset_parent (clearing the weapon's link to player) but never iterates
|
||||
the weapon's OWN children (visual effects, sub-physobjs). Those keep
|
||||
parent=weapon and accumulate in weapon->children forever.
|
||||
|
||||
Mechanism:
|
||||
Replace the `CALL unset_parent` at 0x004522bf with a CALL to a tiny
|
||||
12-byte thunk that does:
|
||||
PUSH ECX
|
||||
CALL unset_parent (0x00513F70)
|
||||
POP ECX
|
||||
JMP unparent_children (0x00513FE0)
|
||||
Net effect: both unset_parent AND unparent_children fire, then
|
||||
control returns to DoPickupEvent's next instruction (leave_world).
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
|
||||
|
||||
|
||||
PATCH_SITE_VA = 0x004522BF
|
||||
ORIG_CALL_BYTES = bytes([0xE8, 0xAC, 0x1C, 0x0C, 0x00]) # CALL unset_parent
|
||||
UNSET_PARENT_VA = 0x00513F70
|
||||
UNPARENT_CHILDREN_VA = 0x00513FE0
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_VM_WRITE = 0x20
|
||||
PROCESS_VM_OPERATION = 0x8
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
VirtualAllocEx = k32.VirtualAllocEx
|
||||
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
|
||||
VirtualAllocEx.restype = wt.LPVOID
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def build_thunk(thunk_va):
|
||||
out = bytearray()
|
||||
out += bytes([0x51]) # push ecx
|
||||
# call unset_parent
|
||||
rel = UNSET_PARENT_VA - (thunk_va + len(out) + 5)
|
||||
out += bytes([0xE8]) + struct.pack("<i", rel)
|
||||
out += bytes([0x59]) # pop ecx
|
||||
# jmp unparent_children
|
||||
rel = UNPARENT_CHILDREN_VA - (thunk_va + len(out) + 5)
|
||||
out += bytes([0xE9]) + struct.pack("<i", rel)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
|
||||
PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f"PID {args.pid}")
|
||||
print(f" patch site @ 0x{PATCH_SITE_VA:08x}: {cur.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur == ORIG_CALL_BYTES:
|
||||
print(f" already original"); CloseHandle(h); return
|
||||
write_bytes(h, PATCH_SITE_VA, ORIG_CALL_BYTES)
|
||||
print(f" reverted; now: {read_bytes(h, PATCH_SITE_VA, 5).hex()}")
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur != ORIG_CALL_BYTES:
|
||||
if cur[0] == 0xE8:
|
||||
print(f" already has a CALL — likely already patched"); CloseHandle(h); sys.exit(3)
|
||||
print(f" UNEXPECTED — expected {ORIG_CALL_BYTES.hex()}"); CloseHandle(h); sys.exit(4)
|
||||
|
||||
thunk_page = VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
if not thunk_page:
|
||||
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}"); sys.exit(5)
|
||||
print(f" thunk page @ 0x{thunk_page:08x}")
|
||||
thunk = build_thunk(thunk_page)
|
||||
print(f" thunk bytes: {thunk.hex()}")
|
||||
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
|
||||
print(f"write thunk failed err={ctypes.get_last_error()}"); sys.exit(6)
|
||||
after_thunk = read_bytes(h, thunk_page, len(thunk))
|
||||
if after_thunk != thunk:
|
||||
print(f" thunk MISMATCH"); sys.exit(7)
|
||||
|
||||
rel = thunk_page - (PATCH_SITE_VA + 5)
|
||||
new_call = bytes([0xE8]) + struct.pack("<i", rel)
|
||||
write_bytes(h, PATCH_SITE_VA, new_call)
|
||||
after = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f" patch site now: {after.hex()}")
|
||||
if after != new_call:
|
||||
print(f" PATCH MISMATCH"); sys.exit(8)
|
||||
print(f" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
330
tools/patch_v14_cenvcell_clipplane.py
Normal file
330
tools/patch_v14_cenvcell_clipplane.py
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"""patch_v14_cenvcell_clipplane.py <pid> [--revert] [--force]
|
||||
|
||||
v14: Plug the CEnvCell::Destroy clip_planes leak.
|
||||
|
||||
LEAK
|
||||
CEnvCell::Destroy (EoR @ 0x0052e5f0) zeroes the inner ClipPlaneList's
|
||||
cplane_num field but never frees:
|
||||
* the inner ClipPlaneList (the *(*clip_planes) allocation), or
|
||||
* the outer DArray<ClipPlaneList*> buffer (the clip_planes alloc).
|
||||
Every CEnvCell that reaches Destroy() leaks both.
|
||||
|
||||
PATCH
|
||||
Replace the 18-byte leak block at 0x0052e661..0x0052e672 with a
|
||||
5-byte JMP to a VirtualAllocEx'd thunk that does the real cleanup,
|
||||
zeroes the field, then resumes at 0x0052e673.
|
||||
|
||||
Layout of the leak block (verified against larsson_highleak.dmp):
|
||||
0052e661 8b 86 dc 00 00 00 mov eax, [esi+0xDC] ; clip_planes
|
||||
0052e667 3b c3 cmp eax, ebx ; if (clip_planes != 0)
|
||||
0052e669 74 08 je 0052e673
|
||||
0052e66b 8b 00 mov eax, [eax] ; inner = *clip_planes
|
||||
0052e66d 3b c3 cmp eax, ebx ; if (inner != 0)
|
||||
0052e66f 74 02 je 0052e673
|
||||
0052e671 89 18 mov [eax], ebx ; inner->cplane_num = 0
|
||||
|
||||
THUNK pseudo-asm (pushad-bracketed; ESI is 'this', EBX is 0):
|
||||
pushad
|
||||
mov edi, [esi+0xDC]
|
||||
test edi, edi
|
||||
jz done
|
||||
mov ecx, [edi] ; inner
|
||||
test ecx, ecx
|
||||
jz free_outer
|
||||
push ecx
|
||||
call ClipPlaneList::~ClipPlaneList ; thiscall: ecx=inner
|
||||
pop ecx
|
||||
push ecx
|
||||
call operator delete ; cdecl
|
||||
add esp, 4
|
||||
free_outer:
|
||||
push edi
|
||||
call operator delete[] ; cdecl
|
||||
add esp, 4
|
||||
mov [esi+0xDC], ebx ; NULL the field
|
||||
done:
|
||||
popad
|
||||
jmp resume ; 0x0052e673
|
||||
|
||||
SUPPORT ADDRESSES (EoR-verified 2026-05-19 via Ghidra MCP + live bytes)
|
||||
ClipPlaneList::~ClipPlaneList = 0x0053C760
|
||||
Layout: `add ecx, 4; jmp DArray<ClipPlane>::~DArray<ClipPlane>`
|
||||
(3-byte thiscall wrapper; cplane_list inner DArray is at offset +4).
|
||||
Bytes at VA: 83 c1 04 e9 88 ff ff ff
|
||||
~DArray<ClipPlane> = 0x0053C6F0 (target of the above jmp)
|
||||
operator delete = 0x005DF15E (IAT thunk: ff 25 7c 32 79 00)
|
||||
operator delete[] = 0x005DF164 (IAT thunk: ff 25 34 32 79 00)
|
||||
|
||||
2013 PDB drift: PDB symbols at 0x0053ba00 / 0x005de02e / 0x005de034
|
||||
resolve to completely different EoR code (~0x800 / ~0xF00 RVA drift).
|
||||
DO NOT trust 2013-PDB addresses for EoR — use the constants above.
|
||||
|
||||
Default mode is --dry-run. Live application is intentionally a manual
|
||||
flag so this can't be run by mistake.
|
||||
"""
|
||||
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
|
||||
|
||||
|
||||
# --- patch site (EoR-verified against larsson_highleak.dmp) ----------
|
||||
PATCH_SITE_VA = 0x0052E661
|
||||
RESUME_VA = 0x0052E673
|
||||
ORIG_BYTES = bytes([
|
||||
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, # mov eax, [esi+0xDC]
|
||||
0x3B, 0xC3, # cmp eax, ebx
|
||||
0x74, 0x08, # je +8
|
||||
0x8B, 0x00, # mov eax, [eax]
|
||||
0x3B, 0xC3, # cmp eax, ebx
|
||||
0x74, 0x02, # je +2
|
||||
0x89, 0x18, # mov [eax], ebx
|
||||
])
|
||||
assert len(ORIG_BYTES) == 18
|
||||
|
||||
# --- support addresses (EoR-verified 2026-05-19) ---------------------
|
||||
# Verified via Ghidra MCP function-name lookup + live byte inspection
|
||||
# of PID 2324 (one of 15 running EoR clients). See module docstring
|
||||
# for the byte-pattern evidence.
|
||||
#
|
||||
# NOTE: the 2013 PDB addresses (0x0053BA00 / 0x005DE02E / 0x005DE034)
|
||||
# do NOT map to the same functions in EoR — the binary has drifted by
|
||||
# roughly 0x800 / 0xF00 bytes at these RVAs. Do not regress to PDB
|
||||
# symbols without re-verifying live.
|
||||
CLIPPLANELIST_DTOR_VA = 0x0053C760 # ClipPlaneList::~ClipPlaneList
|
||||
# (3-byte thiscall thunk:
|
||||
# add ecx, 4; jmp ~DArray<ClipPlane>)
|
||||
OPERATOR_DELETE_VA = 0x005DF15E # IAT thunk: ff 25 7c 32 79 00
|
||||
OPERATOR_DELETE_ARR_VA = 0x005DF164 # IAT thunk: ff 25 34 32 79 00
|
||||
|
||||
# --- Win32 plumbing ---------------------------------------------------
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
VirtualAllocEx = k32.VirtualAllocEx
|
||||
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
|
||||
VirtualAllocEx.restype = wt.LPVOID
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def build_thunk(thunk_va):
|
||||
"""Assemble the cleanup thunk. Returns raw bytes."""
|
||||
out = bytearray()
|
||||
def emit(b): out.extend(b)
|
||||
def rel32_call(target):
|
||||
rel = target - (thunk_va + len(out) + 5)
|
||||
emit(bytes([0xE8]) + struct.pack("<i", rel))
|
||||
def rel32_jmp(target):
|
||||
rel = target - (thunk_va + len(out) + 5)
|
||||
emit(bytes([0xE9]) + struct.pack("<i", rel))
|
||||
|
||||
emit(bytes([0x60])) # pushad
|
||||
emit(bytes([0x8B, 0xBE, 0xDC, 0x00, 0x00, 0x00])) # mov edi, [esi+0xDC]
|
||||
emit(bytes([0x85, 0xFF])) # test edi, edi
|
||||
# je done — compute placeholder; patch after
|
||||
je_done_at = len(out); emit(bytes([0x74, 0x00]))
|
||||
emit(bytes([0x8B, 0x0F])) # mov ecx, [edi] ; inner
|
||||
emit(bytes([0x85, 0xC9])) # test ecx, ecx
|
||||
je_freeouter_at = len(out); emit(bytes([0x74, 0x00]))
|
||||
emit(bytes([0x51])) # push ecx
|
||||
rel32_call(CLIPPLANELIST_DTOR_VA) # ~ClipPlaneList (thiscall)
|
||||
emit(bytes([0x59])) # pop ecx (restore inner for delete)
|
||||
emit(bytes([0x51])) # push ecx
|
||||
rel32_call(OPERATOR_DELETE_VA) # operator delete(inner)
|
||||
emit(bytes([0x83, 0xC4, 0x04])) # add esp, 4
|
||||
# patch je_freeouter to here
|
||||
free_outer_off = len(out)
|
||||
out[je_freeouter_at + 1] = (free_outer_off - (je_freeouter_at + 2)) & 0xFF
|
||||
|
||||
emit(bytes([0x57])) # push edi
|
||||
rel32_call(OPERATOR_DELETE_ARR_VA) # operator delete[](clip_planes)
|
||||
emit(bytes([0x83, 0xC4, 0x04])) # add esp, 4
|
||||
emit(bytes([0x89, 0x9E, 0xDC, 0x00, 0x00, 0x00])) # mov [esi+0xDC], ebx (ebx==0)
|
||||
|
||||
# patch je_done to here
|
||||
done_off = len(out)
|
||||
out[je_done_at + 1] = (done_off - (je_done_at + 2)) & 0xFF
|
||||
|
||||
emit(bytes([0x61])) # popad
|
||||
rel32_jmp(RESUME_VA) # back to 0x0052e673
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def sanity_check_support(h):
|
||||
"""Sanity-check that the support addresses LOOK like the EoR-verified
|
||||
targets. Refuses to apply if any check fails (caller can pass --force)."""
|
||||
problems = []
|
||||
|
||||
# ~ClipPlaneList: a 3-byte thiscall thunk in EoR:
|
||||
# 83 c1 04 add ecx, 4
|
||||
# e9 88 ff ff ff jmp ~DArray<ClipPlane> (= 0x0053C6F0 from 0x0053C763+5)
|
||||
# We're tolerant about the rel32 (compilers can re-emit) but the
|
||||
# `add ecx, 4; jmp rel32` shape is a hard signature.
|
||||
dtor = read_bytes(h, CLIPPLANELIST_DTOR_VA, 8)
|
||||
if dtor[:4] != bytes([0x83, 0xC1, 0x04, 0xE9]):
|
||||
problems.append(
|
||||
f"CLIPPLANELIST_DTOR_VA=0x{CLIPPLANELIST_DTOR_VA:08x} "
|
||||
f"starts {dtor.hex()} — expected 83 c1 04 e9 (add ecx,4; jmp ...) "
|
||||
f"i.e. the EoR ~ClipPlaneList thunk."
|
||||
)
|
||||
else:
|
||||
# Verify the relative jump lands at ~DArray<ClipPlane> (0x0053C6F0).
|
||||
rel = struct.unpack("<i", dtor[4:8])[0]
|
||||
target = CLIPPLANELIST_DTOR_VA + 8 + rel
|
||||
if target != 0x0053C6F0:
|
||||
problems.append(
|
||||
f"CLIPPLANELIST_DTOR_VA jmp target = 0x{target:08x}, "
|
||||
f"expected 0x0053C6F0 (~DArray<ClipPlane>)"
|
||||
)
|
||||
|
||||
# operator delete: IAT thunk `ff 25 <abs ptr>`.
|
||||
od = read_bytes(h, OPERATOR_DELETE_VA, 6)
|
||||
if od[:2] != bytes([0xFF, 0x25]):
|
||||
problems.append(
|
||||
f"OPERATOR_DELETE_VA=0x{OPERATOR_DELETE_VA:08x} starts {od.hex()} "
|
||||
f"— expected ff 25 ... (IAT jump thunk)"
|
||||
)
|
||||
|
||||
# operator delete[]: IAT thunk `ff 25 <abs ptr>`.
|
||||
odarr = read_bytes(h, OPERATOR_DELETE_ARR_VA, 6)
|
||||
if odarr[:2] != bytes([0xFF, 0x25]):
|
||||
problems.append(
|
||||
f"OPERATOR_DELETE_ARR_VA=0x{OPERATOR_DELETE_ARR_VA:08x} starts "
|
||||
f"{odarr.hex()} — expected ff 25 ... (IAT jump thunk)"
|
||||
)
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
ap.add_argument("--force", action="store_true",
|
||||
help="Apply even if support-address sanity check fails")
|
||||
ap.add_argument("--apply", action="store_true",
|
||||
help="Actually write the patch. Without this flag the "
|
||||
"script runs in dry-run mode (default) and never "
|
||||
"modifies the target process.")
|
||||
args = ap.parse_args()
|
||||
# Default mode is dry-run; --apply must be explicit. --revert bypasses.
|
||||
args.dry_run = not (args.apply or args.revert)
|
||||
|
||||
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
|
||||
PROCESS_QUERY_INFORMATION, False, args.pid)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
|
||||
print(f"PID {args.pid}")
|
||||
print(f" patch site @ 0x{PATCH_SITE_VA:08x} ({len(ORIG_BYTES)} B): {cur.hex()}")
|
||||
print(f" expected original : {ORIG_BYTES.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur == ORIG_BYTES:
|
||||
print(f" already original"); CloseHandle(h); return
|
||||
# Restore the original 18 bytes
|
||||
write_bytes(h, PATCH_SITE_VA, ORIG_BYTES)
|
||||
after = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
|
||||
print(f" reverted; bytes now: {after.hex()}")
|
||||
if after != ORIG_BYTES:
|
||||
print(f" REVERT MISMATCH"); CloseHandle(h); sys.exit(7)
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur != ORIG_BYTES:
|
||||
if cur[0] == 0xE9:
|
||||
print(f" looks already patched (starts E9 ...); use --revert");
|
||||
CloseHandle(h); sys.exit(3)
|
||||
print(f" UNEXPECTED original bytes"); CloseHandle(h); sys.exit(4)
|
||||
|
||||
problems = sanity_check_support(h)
|
||||
if problems:
|
||||
print(f" support-address sanity check FAILED:")
|
||||
for p in problems:
|
||||
print(f" - {p}")
|
||||
if not args.force:
|
||||
print(f" refusing to apply without --force")
|
||||
CloseHandle(h); sys.exit(5)
|
||||
print(f" --force given; proceeding anyway (RISKY)")
|
||||
|
||||
if args.dry_run:
|
||||
# Render thunk against a notional thunk_va just to print the bytes
|
||||
thunk_va = 0x10000000 # placeholder for length reporting
|
||||
thunk = build_thunk(thunk_va)
|
||||
print(f" dry-run: thunk would be {len(thunk)} bytes "
|
||||
f"(rel32 targets vary with the allocated page).")
|
||||
print(f" thunk (at notional 0x{thunk_va:08x}): {thunk.hex()}")
|
||||
rel = thunk_va - (PATCH_SITE_VA + 5)
|
||||
repl = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * (len(ORIG_BYTES) - 5))
|
||||
print(f" patch-site replacement ({len(repl)} B): {repl.hex()}")
|
||||
print(f" re-run with --apply to actually write.")
|
||||
CloseHandle(h); return
|
||||
|
||||
thunk_page = VirtualAllocEx(h, None, 0x80, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
if not thunk_page:
|
||||
print(f" VirtualAllocEx failed err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
|
||||
print(f" thunk page @ 0x{thunk_page:08x}")
|
||||
|
||||
thunk = build_thunk(thunk_page)
|
||||
print(f" thunk ({len(thunk)} B): {thunk.hex()}")
|
||||
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
|
||||
print(f" write thunk failed err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
|
||||
after_thunk = read_bytes(h, thunk_page, len(thunk))
|
||||
if after_thunk != thunk:
|
||||
print(f" thunk MISMATCH after write"); CloseHandle(h); sys.exit(7)
|
||||
|
||||
# Build replacement: 5-byte JMP + NOP pad to 18 bytes
|
||||
rel = thunk_page - (PATCH_SITE_VA + 5)
|
||||
repl = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * (len(ORIG_BYTES) - 5))
|
||||
assert len(repl) == len(ORIG_BYTES)
|
||||
print(f" writing patch ({len(repl)} B): {repl.hex()}")
|
||||
write_bytes(h, PATCH_SITE_VA, repl)
|
||||
after = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
|
||||
if after != repl:
|
||||
print(f" PATCH MISMATCH"); CloseHandle(h); sys.exit(8)
|
||||
print(f" OK — clip_planes leak plugged at 0x{PATCH_SITE_VA:08x}")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
237
tools/patch_v15_position_alloc_trace.py
Normal file
237
tools/patch_v15_position_alloc_trace.py
Normal 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())
|
||||
143
tools/patch_v6_test.py
Normal file
143
tools/patch_v6_test.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""patch_v6_test.py <pid> [--revert]
|
||||
|
||||
EXPERIMENTAL v6: NOP the IsVisible guard in
|
||||
UIElement_ItemList::UpdateEmptySlots at EoR 0x004e4390.
|
||||
|
||||
Mechanism:
|
||||
The function starts:
|
||||
sub esp, 0x10
|
||||
push esi
|
||||
mov esi, ecx ; this = ecx
|
||||
call IsVisible ; → al
|
||||
test al, al
|
||||
jz end_of_function ; <-- 6 bytes at 0x004e439d, NOP these
|
||||
...
|
||||
|
||||
Replacing the 6-byte `0F 84 F3 01 00 00` with six NOPs makes the
|
||||
function continue regardless of visibility. The trim loop inside
|
||||
then runs and deletes WAITING-state items.
|
||||
|
||||
Why this fixes the leak:
|
||||
Every container open/close triggers ItemList_Flush, which calls
|
||||
Clear_UIItem + SetState(WAITING) on every cell, then calls
|
||||
UpdateEmptySlots. UpdateEmptySlots currently bails when invisible
|
||||
(which is exactly when Flush is called on close). NOPing the
|
||||
visibility check lets the trim loop run, calling InternalDeleteItem
|
||||
on each WAITING cell.
|
||||
|
||||
Risks:
|
||||
After visibility-check NOP, the function still has a second guard
|
||||
(GetAttribute_Int(0x10000015) == -1). If GetAttribute misbehaves
|
||||
on invisible widgets, behavior could be unexpected. For inventory
|
||||
lists this should be fine (the attribute is set at ctor).
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
|
||||
|
||||
PATCH_SITE_VA = 0x004e439d
|
||||
ORIGINAL_BYTES = bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]) # jz +0x1f3
|
||||
PATCHED_BYTES = bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90]) # 6x nop
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_READWRITE = 0x04
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true",
|
||||
help="restore original JZ instruction")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
|
||||
sys.exit(2)
|
||||
|
||||
cur = read_bytes(h, PATCH_SITE_VA, 6)
|
||||
print(f"PID {args.pid}")
|
||||
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur == ORIGINAL_BYTES:
|
||||
print(" already original — nothing to revert")
|
||||
CloseHandle(h); return
|
||||
if cur != PATCHED_BYTES:
|
||||
print(f" UNEXPECTED — current bytes don't match either original or patched")
|
||||
print(f" expected original {ORIGINAL_BYTES.hex()} or patched {PATCHED_BYTES.hex()}")
|
||||
CloseHandle(h); sys.exit(3)
|
||||
write_bytes(h, PATCH_SITE_VA, ORIGINAL_BYTES)
|
||||
after = read_bytes(h, PATCH_SITE_VA, 6)
|
||||
print(f" reverted; bytes now: {after.hex()}")
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur == PATCHED_BYTES:
|
||||
print(" already patched — nothing to do")
|
||||
CloseHandle(h); return
|
||||
if cur != ORIGINAL_BYTES:
|
||||
print(f" UNEXPECTED — current bytes {cur.hex()} don't match expected original {ORIGINAL_BYTES.hex()}")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
|
||||
write_bytes(h, PATCH_SITE_VA, PATCHED_BYTES)
|
||||
after = read_bytes(h, PATCH_SITE_VA, 6)
|
||||
print(f" patched; bytes now: {after.hex()}")
|
||||
if after != PATCHED_BYTES:
|
||||
print(" MISMATCH — write didn't take")
|
||||
CloseHandle(h); sys.exit(5)
|
||||
print(" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
149
tools/patch_v7_test.py
Normal file
149
tools/patch_v7_test.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""patch_v7_test.py <pid> [--revert]
|
||||
|
||||
EXPERIMENTAL v7: NOP BOTH guards in UIElement_ItemList::UpdateEmptySlots
|
||||
to force the trim logic to run regardless of:
|
||||
1. visibility (jz at 0x004e439d — same NOP as v6)
|
||||
2. auto-fit mode (jne at 0x004e43bd — NEW for v7)
|
||||
|
||||
Function prologue (EoR):
|
||||
0x004e4390: sub esp, 0x10
|
||||
0x004e4393: push esi
|
||||
0x004e4394: mov esi, ecx ; this = ecx
|
||||
0x004e4396: call IsVisible
|
||||
0x004e439b: test al, al
|
||||
0x004e439d: jz +0x1f3 ; <-- NOP (v6/v7 site 1)
|
||||
0x004e43a3: push ebx
|
||||
0x004e43a4: push ebp
|
||||
0x004e43a5: push edi
|
||||
0x004e43a6: lea eax, [esp+0x14]
|
||||
0x004e43aa: push eax
|
||||
0x004e43ab: push 0x10000015
|
||||
0x004e43b0: mov ecx, esi
|
||||
0x004e43b2: call GetAttribute_Int
|
||||
0x004e43b7: mov eax, [esp+0x14]
|
||||
0x004e43bb: or edi, -1 ; edi = 0xFFFFFFFF (= -1)
|
||||
0x004e43be: cmp eax, edi
|
||||
0x004e43bd: jne +0x1cd ; <-- NOP (v7 site 2)
|
||||
...
|
||||
|
||||
After v7 both guards are eliminated. The function runs unconditionally.
|
||||
The trim loop inside then runs and deletes WAITING-state items at the
|
||||
end of the array.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
|
||||
|
||||
SITE_VIS_VA = 0x004e439d
|
||||
SITE_VIS_ORIG = bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]) # jz +0x1f3
|
||||
SITE_VIS_NOP = bytes([0x90] * 6)
|
||||
|
||||
SITE_AF_VA = 0x004e43c0
|
||||
SITE_AF_ORIG = bytes([0x0f, 0x85, 0xcd, 0x01, 0x00, 0x00]) # jne +0x1cd
|
||||
SITE_AF_NOP = bytes([0x90] * 6)
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def apply_or_revert(h, site_va, orig, patched, label, revert):
|
||||
cur = read_bytes(h, site_va, 6)
|
||||
print(f" {label} @ 0x{site_va:08x}: current {cur.hex()}")
|
||||
if revert:
|
||||
if cur == orig:
|
||||
print(f" already original")
|
||||
return
|
||||
if cur != patched:
|
||||
print(f" UNEXPECTED — neither orig nor patched. refusing.")
|
||||
sys.exit(3)
|
||||
write_bytes(h, site_va, orig)
|
||||
after = read_bytes(h, site_va, 6)
|
||||
print(f" reverted; bytes now: {after.hex()}")
|
||||
return
|
||||
|
||||
if cur == patched:
|
||||
print(f" already patched")
|
||||
return
|
||||
if cur != orig:
|
||||
print(f" UNEXPECTED — bytes don't match expected original. refusing.")
|
||||
sys.exit(4)
|
||||
write_bytes(h, site_va, patched)
|
||||
after = read_bytes(h, site_va, 6)
|
||||
print(f" patched; bytes now: {after.hex()}")
|
||||
if after != patched:
|
||||
print(f" MISMATCH — write didn't take")
|
||||
sys.exit(5)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true",
|
||||
help="restore original instructions at both sites")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
|
||||
sys.exit(2)
|
||||
|
||||
print(f"PID {args.pid}")
|
||||
apply_or_revert(h, SITE_VIS_VA, SITE_VIS_ORIG, SITE_VIS_NOP,
|
||||
"visibility guard (jz)", args.revert)
|
||||
apply_or_revert(h, SITE_AF_VA, SITE_AF_ORIG, SITE_AF_NOP,
|
||||
"auto-fit guard (jne) ", args.revert)
|
||||
print(" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
tools/patch_v8_test.py
Normal file
156
tools/patch_v8_test.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""patch_v8_test.py <pid> [--revert]
|
||||
|
||||
EXPERIMENTAL v8: comprehensive fix for UIElement_UIItem leak.
|
||||
|
||||
Combines v7's two outer NOPs (so UpdateEmptySlots runs always)
|
||||
with a 1-byte change to the inner trim loop's break-3 (so the loop
|
||||
skips non-WAITING items instead of exiting at the first one).
|
||||
|
||||
Sites:
|
||||
Site 1 — 0x004e439d (v6/v7): NOP visibility guard
|
||||
Original: 0F 84 F3 01 00 00 jz +0x1f3
|
||||
Patched: 90 90 90 90 90 90
|
||||
|
||||
Site 2 — 0x004e43c0 (v7): NOP auto-fit guard
|
||||
Original: 0F 85 CD 01 00 00 jne +0x1cd
|
||||
Patched: 90 90 90 90 90 90
|
||||
|
||||
Site 3 — 0x004e4496 (v8 new): turn "break" into "continue"
|
||||
Original: 75 0D jne +0x0d → exit_loop
|
||||
Patched: 75 08 jne +0x08 → inc_counter (continue)
|
||||
|
||||
After site 3, when an item's state != WAITING, the loop skips the
|
||||
InternalDeleteItem call and advances the counter instead of exiting.
|
||||
Combined with sites 1 and 2, UpdateEmptySlots now runs always and
|
||||
iterates the entire array, calling InternalDeleteItem on every WAITING
|
||||
UIItem.
|
||||
|
||||
Safety considerations:
|
||||
- Break 1 (NULL item) and break 2 (not UIItem type) are KEPT — they
|
||||
are real safety guards.
|
||||
- The counter (ebx) increments for skipped items too, so the loop
|
||||
may exit earlier than ideal if many items are non-WAITING. But
|
||||
with width=0 (invisible) the delete_count is ~count+1 so most
|
||||
items should still be visited.
|
||||
- InternalDeleteItem defers actual deletion to a queue, so even if
|
||||
we mistakenly delete an active item, the engine should handle it
|
||||
on the next frame.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
|
||||
|
||||
SITES = [
|
||||
("visibility guard (jz) ", 0x004e439d,
|
||||
bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]),
|
||||
bytes([0x90] * 6)),
|
||||
("auto-fit guard (jne) ", 0x004e43c0,
|
||||
bytes([0x0f, 0x85, 0xcd, 0x01, 0x00, 0x00]),
|
||||
bytes([0x90] * 6)),
|
||||
("trim break-3 (jne) ", 0x004e4496,
|
||||
bytes([0x75, 0x0d]),
|
||||
bytes([0x75, 0x08])),
|
||||
]
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def apply_or_revert(h, label, site_va, orig, patched, revert):
|
||||
cur = read_bytes(h, site_va, len(orig))
|
||||
print(f" {label} @ 0x{site_va:08x}: current {cur.hex()}")
|
||||
if revert:
|
||||
if cur == orig:
|
||||
print(f" already original")
|
||||
return
|
||||
if cur != patched:
|
||||
print(f" UNEXPECTED — refusing")
|
||||
sys.exit(3)
|
||||
write_bytes(h, site_va, orig)
|
||||
after = read_bytes(h, site_va, len(orig))
|
||||
print(f" reverted; now: {after.hex()}")
|
||||
return
|
||||
|
||||
if cur == patched:
|
||||
print(f" already patched")
|
||||
return
|
||||
if cur != orig:
|
||||
print(f" UNEXPECTED — bytes don't match expected original. refusing.")
|
||||
sys.exit(4)
|
||||
write_bytes(h, site_va, patched)
|
||||
after = read_bytes(h, site_va, len(orig))
|
||||
print(f" patched; now: {after.hex()}")
|
||||
if after != patched:
|
||||
print(f" MISMATCH — write didn't take")
|
||||
sys.exit(5)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
|
||||
sys.exit(2)
|
||||
|
||||
print(f"PID {args.pid}")
|
||||
for label, va, orig, patched in SITES:
|
||||
apply_or_revert(h, label, va, orig, patched, args.revert)
|
||||
print(" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
274
tools/patch_v8_thunk.py
Normal file
274
tools/patch_v8_thunk.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""patch_v8_thunk.py <pid> [--revert]
|
||||
|
||||
EXPERIMENTAL v8-thunk: actively drain UIElement_UIItem pool by hooking
|
||||
the tail-call JMP at end of UIElement_ItemList::ItemList_Flush.
|
||||
|
||||
Mechanism:
|
||||
ItemList_Flush ends with `JMP UIElement_ItemList::UpdateEmptySlots`
|
||||
at EoR 0x004e4a87 (5 bytes: E9 04 F9 FF FF).
|
||||
Replace with `JMP <thunk>`. Thunk walks the array backward, calls
|
||||
InternalDeleteItem on every WAITING UIItem, then tail-calls
|
||||
UpdateEmptySlots so resize behavior is preserved.
|
||||
|
||||
This is the v8-minimal followup. Where v8-minimal (3 byte changes)
|
||||
prevented NEW leaks, v8-thunk actively drains pre-existing leaks too.
|
||||
|
||||
Thunk (86 bytes, position-independent absolute calls):
|
||||
push ebp; push edi; push esi; push ebx
|
||||
mov ebx, ecx ; ebx = this
|
||||
mov esi, [ebx+0x610] ; count
|
||||
dec esi ; idx = count-1
|
||||
loop_top:
|
||||
test esi, esi
|
||||
js loop_done
|
||||
push esi
|
||||
mov ecx, ebx
|
||||
call UIElement_ListBox::GetItem (0x0046dc50)
|
||||
test eax, eax
|
||||
jz skip_item
|
||||
mov edi, eax
|
||||
mov eax, [edi] ; vtable
|
||||
push 0x10000032
|
||||
mov ecx, edi
|
||||
call [eax+0x94] ; vtable[37] type check
|
||||
test eax, eax
|
||||
jz skip_item
|
||||
mov ecx, edi
|
||||
call UIItem_GetState (0x004e1e20)
|
||||
cmp eax, 0x1000001c
|
||||
jne skip_item
|
||||
push edi
|
||||
mov ecx, ebx
|
||||
call InternalDeleteItem (0x004e41c0)
|
||||
skip_item:
|
||||
dec esi
|
||||
jmp loop_top
|
||||
loop_done:
|
||||
mov ecx, ebx
|
||||
pop ebx; pop esi; pop edi; pop ebp
|
||||
jmp UpdateEmptySlots (0x004e4390)
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
PATCH_SITE_VA = 0x004e4a87
|
||||
ORIG_JMP_BYTES = bytes([0xE9, 0x04, 0xF9, 0xFF, 0xFF]) # JMP UpdateEmptySlots (relative)
|
||||
|
||||
GETITEM_VA = 0x0046dc50
|
||||
GETSTATE_VA = 0x004e1e20
|
||||
INTDELETE_VA = 0x004e41c0
|
||||
UPDATEEMPTYSLOTS_VA = 0x004e4390
|
||||
|
||||
|
||||
def build_thunk(thunk_base: int) -> bytes:
|
||||
"""Build the 86-byte thunk for placement at `thunk_base`."""
|
||||
out = bytearray()
|
||||
|
||||
# Prologue
|
||||
out += bytes([0x55]) # push ebp
|
||||
out += bytes([0x57]) # push edi
|
||||
out += bytes([0x56]) # push esi
|
||||
out += bytes([0x53]) # push ebx
|
||||
out += bytes([0x8B, 0xD9]) # mov ebx, ecx
|
||||
out += bytes([0x8B, 0xB3, 0x10, 0x06, 0x00, 0x00]) # mov esi, [ebx+0x610]
|
||||
out += bytes([0x4E]) # dec esi
|
||||
|
||||
loop_top_off = len(out) # 13
|
||||
out += bytes([0x85, 0xF6]) # test esi, esi
|
||||
# js loop_done — placeholder, fill rel8 at end
|
||||
js_loopdone_off = len(out)
|
||||
out += bytes([0x78, 0x00]) # js +0 (patch)
|
||||
|
||||
out += bytes([0x56]) # push esi
|
||||
out += bytes([0x8B, 0xCB]) # mov ecx, ebx
|
||||
# call GetItem (E8 rel32)
|
||||
call_getitem_off = len(out)
|
||||
out += bytes([0xE8, 0, 0, 0, 0])
|
||||
|
||||
out += bytes([0x85, 0xC0]) # test eax, eax
|
||||
jz_skip1_off = len(out)
|
||||
out += bytes([0x74, 0x00]) # jz skip_item
|
||||
|
||||
out += bytes([0x8B, 0xF8]) # mov edi, eax
|
||||
out += bytes([0x8B, 0x07]) # mov eax, [edi]
|
||||
out += bytes([0x68, 0x32, 0x00, 0x00, 0x10]) # push 0x10000032
|
||||
out += bytes([0x8B, 0xCF]) # mov ecx, edi
|
||||
out += bytes([0xFF, 0x90, 0x94, 0x00, 0x00, 0x00]) # call dword [eax+0x94]
|
||||
out += bytes([0x85, 0xC0]) # test eax, eax
|
||||
jz_skip2_off = len(out)
|
||||
out += bytes([0x74, 0x00]) # jz skip_item
|
||||
|
||||
out += bytes([0x8B, 0xCF]) # mov ecx, edi
|
||||
# call GetState
|
||||
call_getstate_off = len(out)
|
||||
out += bytes([0xE8, 0, 0, 0, 0])
|
||||
|
||||
out += bytes([0x3D, 0x1C, 0x00, 0x00, 0x10]) # cmp eax, 0x1000001c
|
||||
jne_skip_off = len(out)
|
||||
out += bytes([0x75, 0x00]) # jne skip_item
|
||||
|
||||
out += bytes([0x57]) # push edi
|
||||
out += bytes([0x8B, 0xCB]) # mov ecx, ebx
|
||||
# call InternalDeleteItem
|
||||
call_intdel_off = len(out)
|
||||
out += bytes([0xE8, 0, 0, 0, 0])
|
||||
|
||||
skip_item_off = len(out)
|
||||
out += bytes([0x4E]) # dec esi
|
||||
jmp_top_off = len(out)
|
||||
out += bytes([0xEB, 0x00]) # jmp loop_top
|
||||
|
||||
loop_done_off = len(out)
|
||||
out += bytes([0x8B, 0xCB]) # mov ecx, ebx
|
||||
out += bytes([0x5B]) # pop ebx
|
||||
out += bytes([0x5E]) # pop esi
|
||||
out += bytes([0x5F]) # pop edi
|
||||
out += bytes([0x5D]) # pop ebp
|
||||
# jmp UpdateEmptySlots
|
||||
jmp_upd_off = len(out)
|
||||
out += bytes([0xE9, 0, 0, 0, 0])
|
||||
|
||||
# Now patch the relative offsets
|
||||
def patch_rel8(at, target):
|
||||
rel = target - (at + 2)
|
||||
assert -128 <= rel <= 127, f"rel8 overflow: {rel}"
|
||||
out[at + 1] = rel & 0xFF
|
||||
|
||||
def patch_rel32(at, target_va):
|
||||
# at is the offset of the E8/E9 byte; rel32 is at at+1..at+4
|
||||
site = thunk_base + at + 5
|
||||
rel = target_va - site
|
||||
out[at + 1:at + 5] = struct.pack("<i", rel)
|
||||
|
||||
patch_rel8(js_loopdone_off, loop_done_off)
|
||||
patch_rel8(jz_skip1_off, skip_item_off)
|
||||
patch_rel8(jz_skip2_off, skip_item_off)
|
||||
patch_rel8(jne_skip_off, skip_item_off)
|
||||
patch_rel8(jmp_top_off, loop_top_off)
|
||||
|
||||
patch_rel32(call_getitem_off, GETITEM_VA)
|
||||
patch_rel32(call_getstate_off, GETSTATE_VA)
|
||||
patch_rel32(call_intdel_off, INTDELETE_VA)
|
||||
patch_rel32(jmp_upd_off, UPDATEEMPTYSLOTS_VA)
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
VirtualAllocEx = k32.VirtualAllocEx
|
||||
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
|
||||
VirtualAllocEx.restype = wt.LPVOID
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f"PID {args.pid}")
|
||||
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur == ORIG_JMP_BYTES:
|
||||
print(f" already original — nothing to revert")
|
||||
CloseHandle(h); return
|
||||
# Restore original JMP UpdateEmptySlots
|
||||
write_bytes(h, PATCH_SITE_VA, ORIG_JMP_BYTES)
|
||||
after = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f" reverted; bytes now: {after.hex()}")
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur != ORIG_JMP_BYTES:
|
||||
if cur[0] == 0xE9:
|
||||
print(f" already has a JMP somewhere — maybe already patched. Refusing to re-patch.")
|
||||
else:
|
||||
print(f" UNEXPECTED — bytes don't match expected JMP. Refusing.")
|
||||
CloseHandle(h); sys.exit(3)
|
||||
|
||||
# Allocate thunk page
|
||||
thunk_page = VirtualAllocEx(h, None, 0x100, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
if not thunk_page:
|
||||
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}"); sys.exit(4)
|
||||
print(f" thunk page @ 0x{thunk_page:08x}")
|
||||
|
||||
thunk = build_thunk(thunk_page)
|
||||
print(f" thunk size: {len(thunk)} bytes")
|
||||
print(f" thunk hex: {thunk.hex()}")
|
||||
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
|
||||
print(f"write thunk failed err={ctypes.get_last_error()}"); sys.exit(5)
|
||||
|
||||
# Build the JMP to thunk at the patch site
|
||||
rel = thunk_page - (PATCH_SITE_VA + 5)
|
||||
new_jmp = bytes([0xE9]) + struct.pack("<i", rel)
|
||||
write_bytes(h, PATCH_SITE_VA, new_jmp)
|
||||
|
||||
after = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f" patch site now: {after.hex()} (expected {new_jmp.hex()})")
|
||||
if after != new_jmp:
|
||||
print(f" MISMATCH"); sys.exit(6)
|
||||
print(" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
288
tools/patch_v8_thunk_v2.py
Normal file
288
tools/patch_v8_thunk_v2.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""patch_v8_thunk_v2.py <pid> [--revert]
|
||||
|
||||
v8-thunk-v2: SAFE drain of UIElement_UIItem leaked pool.
|
||||
|
||||
v1 (the broken one) hooked Flush — drain ran during normal panel
|
||||
refresh and ate items needed for display.
|
||||
|
||||
v2 hooks OnVisibilityChanged (vis=false branch) instead — drain only
|
||||
when the panel becomes hidden, NOT during Flush.
|
||||
|
||||
Mechanism:
|
||||
At 0x004e499e, OnVisibilityChanged has 24 bytes implementing:
|
||||
if ((this->+0x554 >> 0x11 & 1) && vis != 0) UpdateEmptySlots()
|
||||
epilogue at 0x004e49b6: pop esi; pop ebx; ret 4
|
||||
|
||||
We replace those 24 bytes with `JMP <thunk>` (5 bytes) + 19 NOPs.
|
||||
|
||||
Thunk reproduces the original logic AND adds a drain path:
|
||||
if (!(this->+0x554 >> 0x11 & 1)) return
|
||||
if (vis != 0) UpdateEmptySlots() ; stock visible path
|
||||
else ; vis == false: drain (NEW)
|
||||
for up to 8 iterations:
|
||||
idx = this->+0x610 - 1
|
||||
if idx < 0: break
|
||||
item_array = GetItem(this, idx)
|
||||
if item_array == NULL: break ; (no more items)
|
||||
real_item = item_array->vtable[37](0x10000032) ; type check
|
||||
if real_item == 0: break ; (not a UIItem at end)
|
||||
state = real_item->UIItem_GetState()
|
||||
if state != WAITING: break ; (active item at end, stop)
|
||||
InternalDeleteItem(this, real_item)
|
||||
|
||||
Safety:
|
||||
- Only drains on vis=false (panel actually going hidden)
|
||||
- Cap of 8 items per hide event (prevents catastrophic burst)
|
||||
- Stops at first non-WAITING item at end (mimics UpdateEmptySlots's
|
||||
own trim behavior — never deletes active items)
|
||||
- Preserves the original visible-true behavior exactly
|
||||
|
||||
Notable correctness:
|
||||
- InternalDeleteItem is called with the result of vtable[37], NOT
|
||||
the raw item from the array (mirrors UpdateEmptySlots's pattern)
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
PATCH_SITE_VA = 0x004e499e
|
||||
PATCH_LEN = 24
|
||||
# Original 24 bytes (will verify before patching):
|
||||
ORIG_BYTES = bytes([
|
||||
0x8B, 0x86, 0x54, 0x05, 0x00, 0x00, # mov eax, [esi+0x554]
|
||||
0xC1, 0xE8, 0x11, # shr eax, 0x11
|
||||
0xA8, 0x01, # test al, 1
|
||||
0x74, 0x0B, # jz +0x0B (to 0x004e49b6)
|
||||
0x84, 0xDB, # test bl, bl
|
||||
0x74, 0x07, # jz +0x07
|
||||
0x8B, 0xCE, # mov ecx, esi
|
||||
0xE8, 0xDA, 0xF9, 0xFF, 0xFF, # call UpdateEmptySlots
|
||||
])
|
||||
assert len(ORIG_BYTES) == 24
|
||||
|
||||
GETITEM_VA = 0x0046dc50
|
||||
GETSTATE_VA = 0x004e1e20
|
||||
INTDELETE_VA = 0x004e41c0
|
||||
UPDATEEMPTYSLOTS_VA = 0x004e4390
|
||||
|
||||
|
||||
def build_thunk(base: int) -> bytes:
|
||||
"""Build the v8-thunk-v2 at absolute address `base`."""
|
||||
out = bytearray()
|
||||
refs = {} # symbolic name -> (offset_of_rel_byte, target_va, size_of_call)
|
||||
|
||||
# ------ prologue ------
|
||||
out += bytes([0x57]) # push edi (save caller's edi)
|
||||
out += bytes([0x8B, 0x86, 0x54, 0x05, 0x00, 0x00]) # mov eax, [esi+0x554]
|
||||
out += bytes([0xC1, 0xE8, 0x11]) # shr eax, 0x11
|
||||
out += bytes([0xA8, 0x01]) # test al, 1
|
||||
jz_epi_1 = len(out)
|
||||
out += bytes([0x74, 0x00]) # jz .epi (patch)
|
||||
out += bytes([0x84, 0xDB]) # test bl, bl
|
||||
jnz_visible = len(out)
|
||||
out += bytes([0x75, 0x00]) # jnz .visible (patch)
|
||||
|
||||
out += bytes([0xBF, 0x08, 0x00, 0x00, 0x00]) # mov edi, 8 (cap)
|
||||
|
||||
# ------ loop ------
|
||||
loop_top = len(out)
|
||||
out += bytes([0x8B, 0x86, 0x10, 0x06, 0x00, 0x00]) # mov eax, [esi+0x610]
|
||||
out += bytes([0x48]) # dec eax
|
||||
js_epi_1 = len(out)
|
||||
out += bytes([0x78, 0x00]) # js .epi (patch)
|
||||
|
||||
out += bytes([0x50]) # push eax (idx)
|
||||
out += bytes([0x8B, 0xCE]) # mov ecx, esi
|
||||
call_getitem = len(out)
|
||||
out += bytes([0xE8, 0, 0, 0, 0]) # call GetItem
|
||||
out += bytes([0x85, 0xC0]) # test eax, eax
|
||||
jz_epi_2 = len(out)
|
||||
out += bytes([0x74, 0x00]) # jz .epi
|
||||
|
||||
out += bytes([0x68, 0x32, 0x00, 0x00, 0x10]) # push 0x10000032
|
||||
out += bytes([0x8B, 0xC8]) # mov ecx, eax
|
||||
out += bytes([0x8B, 0x10]) # mov edx, [eax]
|
||||
out += bytes([0xFF, 0x92, 0x94, 0x00, 0x00, 0x00]) # call [edx+0x94]
|
||||
out += bytes([0x85, 0xC0]) # test eax, eax
|
||||
jz_epi_3 = len(out)
|
||||
out += bytes([0x74, 0x00]) # jz .epi
|
||||
|
||||
out += bytes([0x50]) # push eax (save real_item)
|
||||
out += bytes([0x8B, 0xC8]) # mov ecx, eax
|
||||
call_getstate = len(out)
|
||||
out += bytes([0xE8, 0, 0, 0, 0]) # call GetState
|
||||
out += bytes([0x59]) # pop ecx (ecx = real_item)
|
||||
out += bytes([0x3D, 0x1C, 0x00, 0x00, 0x10]) # cmp eax, 0x1000001c
|
||||
jne_epi = len(out)
|
||||
out += bytes([0x75, 0x00]) # jne .epi
|
||||
|
||||
out += bytes([0x51]) # push ecx (arg = real_item)
|
||||
out += bytes([0x8B, 0xCE]) # mov ecx, esi
|
||||
call_intdel = len(out)
|
||||
out += bytes([0xE8, 0, 0, 0, 0]) # call InternalDeleteItem
|
||||
|
||||
out += bytes([0x4F]) # dec edi (cap--)
|
||||
jnz_loop = len(out)
|
||||
out += bytes([0x75, 0x00]) # jnz loop_top (patch)
|
||||
jmp_epi = len(out)
|
||||
out += bytes([0xEB, 0x00]) # jmp .epi
|
||||
|
||||
# ------ visible path ------
|
||||
visible_label = len(out)
|
||||
out += bytes([0x8B, 0xCE]) # mov ecx, esi
|
||||
call_updemp = len(out)
|
||||
out += bytes([0xE8, 0, 0, 0, 0]) # call UpdateEmptySlots
|
||||
|
||||
# ------ epilogue ------
|
||||
epi_label = len(out)
|
||||
out += bytes([0x5F]) # pop edi
|
||||
out += bytes([0x5E]) # pop esi
|
||||
out += bytes([0x5B]) # pop ebx
|
||||
out += bytes([0xC2, 0x04, 0x00]) # ret 4
|
||||
|
||||
# ----- Resolve relative jumps and calls -----
|
||||
def patch_rel8(at, target_off):
|
||||
rel = target_off - (at + 2)
|
||||
assert -128 <= rel <= 127, f"rel8 overflow {rel}"
|
||||
out[at + 1] = rel & 0xFF
|
||||
|
||||
def patch_rel32_call(at, target_va):
|
||||
# E8 at offset `at` ... rel32 at at+1..at+4. Next-instr addr = base+at+5.
|
||||
rel = target_va - (base + at + 5)
|
||||
out[at + 1:at + 5] = struct.pack("<i", rel)
|
||||
|
||||
patch_rel8(jz_epi_1, epi_label)
|
||||
patch_rel8(jnz_visible, visible_label)
|
||||
patch_rel8(js_epi_1, epi_label)
|
||||
patch_rel8(jz_epi_2, epi_label)
|
||||
patch_rel8(jz_epi_3, epi_label)
|
||||
patch_rel8(jne_epi, epi_label)
|
||||
patch_rel8(jnz_loop, loop_top)
|
||||
patch_rel8(jmp_epi, epi_label)
|
||||
|
||||
patch_rel32_call(call_getitem, GETITEM_VA)
|
||||
patch_rel32_call(call_getstate, GETSTATE_VA)
|
||||
patch_rel32_call(call_intdel, INTDELETE_VA)
|
||||
patch_rel32_call(call_updemp, UPDATEEMPTYSLOTS_VA)
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
VirtualAllocEx = k32.VirtualAllocEx
|
||||
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
|
||||
VirtualAllocEx.restype = wt.LPVOID
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = read_bytes(h, PATCH_SITE_VA, PATCH_LEN)
|
||||
print(f"PID {args.pid}")
|
||||
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur == ORIG_BYTES:
|
||||
print(f" already original — nothing to revert")
|
||||
CloseHandle(h); return
|
||||
write_bytes(h, PATCH_SITE_VA, ORIG_BYTES)
|
||||
after = read_bytes(h, PATCH_SITE_VA, PATCH_LEN)
|
||||
print(f" reverted; bytes now: {after.hex()}")
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur != ORIG_BYTES:
|
||||
print(f" UNEXPECTED — bytes don't match expected original.")
|
||||
print(f" Expected: {ORIG_BYTES.hex()}")
|
||||
CloseHandle(h); sys.exit(3)
|
||||
|
||||
# Allocate thunk
|
||||
thunk_page = VirtualAllocEx(h, None, 0x200, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
if not thunk_page:
|
||||
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}"); sys.exit(4)
|
||||
print(f" thunk page @ 0x{thunk_page:08x}")
|
||||
|
||||
thunk = build_thunk(thunk_page)
|
||||
print(f" thunk size: {len(thunk)} bytes")
|
||||
print(f" thunk hex:")
|
||||
for i in range(0, len(thunk), 16):
|
||||
row = thunk[i:i+16].hex(' ')
|
||||
print(f" +0x{i:02x}: {row}")
|
||||
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
|
||||
print(f"write thunk failed err={ctypes.get_last_error()}"); sys.exit(5)
|
||||
|
||||
# Build replacement: JMP thunk + 19 NOPs
|
||||
rel = thunk_page - (PATCH_SITE_VA + 5)
|
||||
replacement = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * 19)
|
||||
assert len(replacement) == PATCH_LEN
|
||||
write_bytes(h, PATCH_SITE_VA, replacement)
|
||||
|
||||
after = read_bytes(h, PATCH_SITE_VA, PATCH_LEN)
|
||||
print(f" patch site now: {after.hex()}")
|
||||
if after != replacement:
|
||||
print(f" MISMATCH"); sys.exit(6)
|
||||
print(" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
tools/patch_v9_test.py
Normal file
173
tools/patch_v9_test.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""patch_v9_test.py <pid> [--revert]
|
||||
|
||||
v9: hook CPhysicsObj::Destroy to call unparent_children(this) first.
|
||||
|
||||
Mechanism:
|
||||
Insert a 5-byte JMP at CPhysicsObj::Destroy entry (0x005145D0) that
|
||||
redirects to a 17-byte thunk in the .text cave at 0x00792CE0.
|
||||
The thunk:
|
||||
1. Saves this (ecx)
|
||||
2. Calls CPhysicsObj::unparent_children(this) at 0x00513FE0
|
||||
3. Restores this
|
||||
4. Re-executes the 5 displaced bytes (push ebx; push esi; mov esi,ecx; push edi)
|
||||
5. JMPs back to 0x005145D5 (continuation of Destroy)
|
||||
|
||||
This fixes the orphan-children leak: parents being destroyed without
|
||||
calling unparent_children leave children with parent pointers to freed
|
||||
memory, which causes UAF crashes in CObjectMaint cleanup chains.
|
||||
|
||||
Risk:
|
||||
- unparent_children is idempotent (safe to call multiple times)
|
||||
- Both patch site and thunk are in .text
|
||||
- Single chokepoint: only ~CPhysicsObj reaches Destroy
|
||||
- Stack alignment preserved (push ecx + pop ecx)
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
PATCH_SITE_VA = 0x005145D0
|
||||
THUNK_VA = 0x00792CE0
|
||||
UNPARENT_VA = 0x00513FE0
|
||||
RESUME_VA = 0x005145D5
|
||||
|
||||
ORIG_BYTES = bytes([0x53, 0x56, 0x8B, 0xF1, 0x57]) # push ebx; push esi; mov esi,ecx; push edi
|
||||
# Replacement is computed at runtime (E9 + rel32 to thunk)
|
||||
|
||||
|
||||
def build_thunk(thunk_base: int) -> bytes:
|
||||
"""17 bytes: save ecx, call unparent_children, restore, displaced, jmp back."""
|
||||
out = bytearray()
|
||||
out += bytes([0x51]) # push ecx
|
||||
# call rel32 unparent_children
|
||||
rel_call = UNPARENT_VA - (thunk_base + len(out) + 5)
|
||||
out += bytes([0xE8]) + struct.pack("<i", rel_call)
|
||||
out += bytes([0x59]) # pop ecx
|
||||
# Displaced prologue bytes (5 bytes)
|
||||
out += ORIG_BYTES # push ebx; push esi; mov esi,ecx; push edi
|
||||
# jmp rel32 back to RESUME_VA
|
||||
rel_jmp = RESUME_VA - (thunk_base + len(out) + 5)
|
||||
out += bytes([0xE9]) + struct.pack("<i", rel_jmp)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_VM_WRITE = 0x0020
|
||||
PROCESS_VM_OPERATION = 0x0008
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
|
||||
|
||||
k32 = ctypes.windll.kernel32
|
||||
OpenProcess = k32.OpenProcess
|
||||
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
|
||||
CloseHandle = k32.CloseHandle
|
||||
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
|
||||
WriteProcessMemory = k32.WriteProcessMemory
|
||||
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
WriteProcessMemory.restype = wt.BOOL
|
||||
ReadProcessMemory = k32.ReadProcessMemory
|
||||
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
ReadProcessMemory.restype = wt.BOOL
|
||||
VirtualProtectEx = k32.VirtualProtectEx
|
||||
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
VirtualProtectEx.restype = wt.BOOL
|
||||
|
||||
|
||||
def read_bytes(h, addr, n):
|
||||
buf = (ctypes.c_ubyte * n)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
|
||||
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
|
||||
def write_bytes(h, addr, data):
|
||||
old_prot = wt.DWORD(0)
|
||||
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
|
||||
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
|
||||
sz = ctypes.c_size_t(0)
|
||||
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
|
||||
err = ctypes.get_last_error() if not ok else 0
|
||||
restored = wt.DWORD(0)
|
||||
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
|
||||
if not ok:
|
||||
raise OSError(f"write 0x{addr:08x} err={err}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("pid", type=int)
|
||||
ap.add_argument("--revert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
h = OpenProcess(
|
||||
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
False, args.pid,
|
||||
)
|
||||
if not h:
|
||||
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
|
||||
cur = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f"PID {args.pid}")
|
||||
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
|
||||
|
||||
if args.revert:
|
||||
if cur == ORIG_BYTES:
|
||||
print(f" already original")
|
||||
else:
|
||||
# Restore the original 5 bytes
|
||||
write_bytes(h, PATCH_SITE_VA, ORIG_BYTES)
|
||||
after = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f" reverted; bytes now: {after.hex()}")
|
||||
CloseHandle(h); return
|
||||
|
||||
if cur != ORIG_BYTES:
|
||||
print(f" UNEXPECTED — bytes don't match expected original {ORIG_BYTES.hex()}")
|
||||
CloseHandle(h); sys.exit(3)
|
||||
|
||||
# Verify cave is clean (zero-filled or at least not in-use)
|
||||
cave_cur = read_bytes(h, THUNK_VA, 32)
|
||||
print(f" cave @ 0x{THUNK_VA:08x} current (32B): {cave_cur.hex()}")
|
||||
if any(b != 0 and b != 0xCC for b in cave_cur):
|
||||
# Warn but don't refuse — caves are sometimes filled with INT3 (0xCC) padding
|
||||
print(f" WARNING — cave is not all zeros/0xCC. Inspect before proceeding.")
|
||||
# For safety, still refuse if first byte is something live
|
||||
if cave_cur[0] != 0 and cave_cur[0] != 0xCC:
|
||||
print(f" REFUSING — cave appears occupied")
|
||||
CloseHandle(h); sys.exit(4)
|
||||
|
||||
# Build and write thunk
|
||||
thunk = build_thunk(THUNK_VA)
|
||||
assert len(thunk) == 17, f"thunk size {len(thunk)}"
|
||||
print(f" writing thunk ({len(thunk)} bytes) to 0x{THUNK_VA:08x}: {thunk.hex()}")
|
||||
write_bytes(h, THUNK_VA, thunk)
|
||||
# Verify thunk
|
||||
after_thunk = read_bytes(h, THUNK_VA, len(thunk))
|
||||
if after_thunk != thunk:
|
||||
print(f" THUNK MISMATCH after write: {after_thunk.hex()}")
|
||||
CloseHandle(h); sys.exit(5)
|
||||
print(f" thunk verified")
|
||||
|
||||
# Write JMP at patch site
|
||||
rel = THUNK_VA - (PATCH_SITE_VA + 5)
|
||||
jmp_bytes = bytes([0xE9]) + struct.pack("<i", rel)
|
||||
print(f" writing JMP at 0x{PATCH_SITE_VA:08x}: {jmp_bytes.hex()}")
|
||||
write_bytes(h, PATCH_SITE_VA, jmp_bytes)
|
||||
after = read_bytes(h, PATCH_SITE_VA, 5)
|
||||
print(f" patch site now: {after.hex()}")
|
||||
if after != jmp_bytes:
|
||||
print(f" PATCH MISMATCH"); sys.exit(6)
|
||||
|
||||
print(f" OK")
|
||||
CloseHandle(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
458
tools/pdb_extract.py
Normal file
458
tools/pdb_extract.py
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
"""Pure-Python MSF 7.00 PDB extractor for acclient.pdb.
|
||||
|
||||
Reads the Microsoft Program Database, extracts public function symbols
|
||||
(S_PUB32, kind 0x110E) and named struct/class type records (LF_CLASS,
|
||||
LF_STRUCTURE) from the TPI stream, and writes two JSON sidecars to
|
||||
docs/research/named-retail/:
|
||||
|
||||
symbols.json — every named public function with its image VA
|
||||
types.json — every named struct/class with size
|
||||
|
||||
This is a foundation for the named-retail workflow. After running, every
|
||||
future session can grep the JSON for instant address↔name lookups.
|
||||
|
||||
Run with:
|
||||
py tools/pdb-extract/pdb_extract.py refs/acclient.pdb
|
||||
|
||||
References:
|
||||
https://llvm.org/docs/PDB/MsfFile.html — MSF container layout
|
||||
https://llvm.org/docs/PDB/PublicStream.html — symbol record format
|
||||
https://llvm.org/docs/PDB/TpiStream.html — type-info stream layout
|
||||
https://llvm.org/docs/PDB/DbiStream.html — DBI header + sections
|
||||
|
||||
No external dependencies — uses stdlib `struct` + `json` only.
|
||||
|
||||
Wire constants (PDB7 / MSF 7.00):
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── MSF / PDB7 constants ───────────────────────────────────────────────────
|
||||
|
||||
MSF_MAGIC = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\0\0\0"
|
||||
SUPERBLOCK_SIZE = 56 # u32 ×6 + magic; MSF 7.00 layout
|
||||
|
||||
# Stream indices (fixed by spec)
|
||||
STREAM_PDB_INFO = 1
|
||||
STREAM_TPI = 2
|
||||
STREAM_DBI = 3
|
||||
STREAM_IPI = 4
|
||||
|
||||
# Symbol record kinds (subset)
|
||||
S_PUB32 = 0x110E # Public symbol (32-bit)
|
||||
|
||||
# Type record kinds (subset)
|
||||
LF_CLASS = 0x1504
|
||||
LF_STRUCTURE = 0x1505
|
||||
LF_UNION = 0x1506
|
||||
LF_ENUM = 0x1507
|
||||
|
||||
# DBI machine type (image-base + section table source)
|
||||
SECTION_HEADERS_DEFAULT_BASE = 0x00400000 # acclient.exe image base
|
||||
|
||||
# CV public-symbol flag bits
|
||||
PUBSYM_FLAG_CODE = 0x00000002
|
||||
|
||||
|
||||
# ── MSF reader ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Msf:
|
||||
"""In-memory wrapper over a PDB file. Loads the superblock + stream
|
||||
directory, exposes per-stream byte buffers reconstructed from page
|
||||
chains."""
|
||||
|
||||
def __init__(self, path):
|
||||
with open(path, "rb") as f:
|
||||
self._data = f.read()
|
||||
|
||||
if not self._data.startswith(MSF_MAGIC):
|
||||
raise ValueError(f"not an MSF 7.00 file: {path}")
|
||||
|
||||
# Superblock fields (after the 32-byte magic).
|
||||
(self.block_size,
|
||||
self.free_block_map,
|
||||
self.num_blocks,
|
||||
self.num_dir_bytes,
|
||||
_reserved,
|
||||
self.block_map_addr) = struct.unpack_from("<6I", self._data, 32)
|
||||
|
||||
if self.block_size not in (512, 1024, 2048, 4096):
|
||||
raise ValueError(f"unexpected block size: {self.block_size}")
|
||||
|
||||
# Stream directory: read the directory-block-map first (a list
|
||||
# of page indices that themselves spell out the directory's
|
||||
# page list). Then read the directory pages.
|
||||
dir_pages_needed = _ceil_div(self.num_dir_bytes, self.block_size)
|
||||
block_map_pages_needed = _ceil_div(dir_pages_needed * 4, self.block_size)
|
||||
# The block_map_addr is the page index of the FIRST page in the
|
||||
# block-map. The block-map's pages are stored sequentially.
|
||||
# Wait, actually it's a single page index pointing to one page
|
||||
# full of u32 page indices. If there are >block_size/4 pages in
|
||||
# the directory, this overflows; for typical small PDBs it
|
||||
# doesn't. acclient.pdb's 119 KB directory at 4 KB pages = 30
|
||||
# pages -> 30 u32s = 120 bytes, fits in one page. OK.
|
||||
block_map = self._read_page(self.block_map_addr)
|
||||
dir_page_indices = struct.unpack_from(
|
||||
f"<{dir_pages_needed}I", block_map, 0)
|
||||
|
||||
dir_data = bytearray()
|
||||
for pi in dir_page_indices:
|
||||
dir_data.extend(self._read_page(pi))
|
||||
dir_data = bytes(dir_data[:self.num_dir_bytes])
|
||||
|
||||
# Directory layout:
|
||||
# u32 NumStreams
|
||||
# u32 StreamSizes[NumStreams]
|
||||
# for i in 0..NumStreams: u32 StreamPageIndices[ceil(size_i / block_size)]
|
||||
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
|
||||
sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
|
||||
# 0xFFFFFFFF is the "deleted stream" sentinel — treat as size 0.
|
||||
sizes = tuple(0 if s == 0xFFFFFFFF else s for s in sizes)
|
||||
|
||||
offset = 4 + 4 * num_streams
|
||||
streams = []
|
||||
for size in sizes:
|
||||
pages_needed = _ceil_div(size, self.block_size)
|
||||
indices = struct.unpack_from(
|
||||
f"<{pages_needed}I", dir_data, offset)
|
||||
offset += 4 * pages_needed
|
||||
streams.append((size, indices))
|
||||
self.streams = streams
|
||||
|
||||
def _read_page(self, page_index):
|
||||
start = page_index * self.block_size
|
||||
return self._data[start:start + self.block_size]
|
||||
|
||||
def stream(self, idx):
|
||||
"""Return the raw bytes of stream `idx` (concatenated pages,
|
||||
truncated to declared size)."""
|
||||
size, pages = self.streams[idx]
|
||||
buf = bytearray()
|
||||
for p in pages:
|
||||
buf.extend(self._read_page(p))
|
||||
return bytes(buf[:size])
|
||||
|
||||
|
||||
def _ceil_div(a, b):
|
||||
return (a + b - 1) // b
|
||||
|
||||
|
||||
# ── DBI parser (just enough for: section headers + symbol stream index) ────
|
||||
|
||||
def _parse_dbi(dbi_bytes):
|
||||
"""Pull the section-header-stream index + symbol-record-stream index
|
||||
out of the DBI header. Returns (sym_stream_idx, section_hdr_stream_idx).
|
||||
"""
|
||||
# DBI header (first 64 bytes)
|
||||
if len(dbi_bytes) < 64:
|
||||
raise ValueError("DBI stream too short")
|
||||
(version_sig, version_hdr, age,
|
||||
gsi_stream, build_no,
|
||||
psgsi_stream, pdb_dll_ver,
|
||||
sym_record_stream, pdb_dll_rbld,
|
||||
mod_info_size, section_contrib_size, section_map_size,
|
||||
source_info_size, type_server_map_size, mfc_type_server_idx,
|
||||
opt_dbg_hdr_size, ec_substream_size,
|
||||
flags, machine, padding) = struct.unpack_from(
|
||||
"<iIIHHHHHHiiiiiIiiHHI", dbi_bytes, 0)
|
||||
|
||||
# The optional debug header sub-stream is at the end of the DBI.
|
||||
# Layout offset = 64 + sum of all preceding sub-stream sizes.
|
||||
base = 64
|
||||
offsets = {
|
||||
"mod_info": base,
|
||||
"section_contrib": base + mod_info_size,
|
||||
"section_map": base + mod_info_size + section_contrib_size,
|
||||
"source_info": base + mod_info_size + section_contrib_size + section_map_size,
|
||||
"type_server_map": base + mod_info_size + section_contrib_size + section_map_size + source_info_size,
|
||||
"ec_substream": base + mod_info_size + section_contrib_size + section_map_size + source_info_size + type_server_map_size,
|
||||
"opt_dbg_header": base + mod_info_size + section_contrib_size + section_map_size + source_info_size + type_server_map_size + ec_substream_size,
|
||||
}
|
||||
|
||||
# The optional debug header is an array of u16 stream indices; the
|
||||
# one at index 5 is the section-headers stream (per LLVM docs).
|
||||
opt_off = offsets["opt_dbg_header"]
|
||||
opt_count = opt_dbg_hdr_size // 2
|
||||
opt_streams = struct.unpack_from(
|
||||
f"<{opt_count}H", dbi_bytes, opt_off)
|
||||
# Index 5 = original section headers stream.
|
||||
section_hdr_stream = opt_streams[5] if opt_count > 5 else 0xFFFF
|
||||
|
||||
return sym_record_stream, section_hdr_stream
|
||||
|
||||
|
||||
# ── Public symbols extractor (S_PUB32 from sym-record stream) ──────────────
|
||||
|
||||
def _demangle(mangled):
|
||||
"""Best-effort demangle of MSVC C++ symbol names to Class::Method form.
|
||||
|
||||
PDB public symbols use the MSVC ABI mangling. A full demangler would
|
||||
parse arg types + calling conventions; we only need the readable
|
||||
qualified name for grep workflows.
|
||||
|
||||
Examples:
|
||||
"?EnchantAttribute@CACQualities@@IBEHKAAK@Z" -> "CACQualities::EnchantAttribute"
|
||||
"??0CEnchantmentRegistry@@QAE@XZ" -> "CEnchantmentRegistry::CEnchantmentRegistry"
|
||||
"??1Foo@@QAE@XZ" -> "Foo::~Foo"
|
||||
"_someThing" -> "_someThing" (C-style, kept as-is)
|
||||
"?GlobalFunc@@..." -> "GlobalFunc" (no class)
|
||||
"""
|
||||
if not mangled or not mangled.startswith("?"):
|
||||
return mangled # C-linkage symbol, return as-is
|
||||
# Strip the leading '?' (or '??' for ctors/dtors)
|
||||
if mangled.startswith("??0"): # constructor
|
||||
rest = mangled[3:]
|
||||
ctor_dtor = "ctor"
|
||||
elif mangled.startswith("??1"): # destructor
|
||||
rest = mangled[3:]
|
||||
ctor_dtor = "dtor"
|
||||
elif mangled.startswith("??_"): # vtable / vbtable / etc — leave as-is
|
||||
return mangled
|
||||
else:
|
||||
rest = mangled[1:]
|
||||
ctor_dtor = None
|
||||
|
||||
# `Name@Class@Outer@@<sig>` -> split on '@@' to drop the signature suffix
|
||||
sep = rest.find("@@")
|
||||
if sep < 0:
|
||||
return mangled # not a recognised pattern
|
||||
qualified = rest[:sep]
|
||||
parts = qualified.split("@")
|
||||
parts = [p for p in parts if p]
|
||||
if not parts:
|
||||
return mangled
|
||||
|
||||
if ctor_dtor:
|
||||
# parts[0] is the class name (the only part); ctor name = class name.
|
||||
cls = parts[0]
|
||||
outer = "::".join(reversed(parts[1:]))
|
||||
full = f"{outer}::{cls}" if outer else cls
|
||||
if ctor_dtor == "dtor":
|
||||
return f"{full}::~{cls}"
|
||||
return f"{full}::{cls}"
|
||||
|
||||
# Method: parts[0] is the function name, parts[1:] are nested classes
|
||||
# (innermost first -> reverse for outer::inner::method order).
|
||||
method = parts[0]
|
||||
classes = list(reversed(parts[1:]))
|
||||
if classes:
|
||||
return "::".join(classes) + "::" + method
|
||||
return method
|
||||
|
||||
|
||||
def _extract_pub32(sym_bytes, section_bases):
|
||||
"""Iterate S_PUB32 records, compute image VA, return list of dicts.
|
||||
|
||||
sym_bytes: raw sym-record-stream bytes.
|
||||
section_bases: list of section base VAs (1-indexed via segment field).
|
||||
"""
|
||||
pos = 0
|
||||
end = len(sym_bytes)
|
||||
out = []
|
||||
while pos + 4 <= end:
|
||||
rec_len, kind = struct.unpack_from("<HH", sym_bytes, pos)
|
||||
if rec_len == 0:
|
||||
break
|
||||
rec_end = pos + 2 + rec_len # rec_len excludes its own u16
|
||||
if kind == S_PUB32:
|
||||
# body: u32 flags, u32 offset, u16 seg, char name[]
|
||||
flags, offset, seg = struct.unpack_from("<IIH", sym_bytes, pos + 4)
|
||||
name_start = pos + 14
|
||||
zero = sym_bytes.index(b"\0", name_start, rec_end)
|
||||
mangled = sym_bytes[name_start:zero].decode("ascii", errors="replace")
|
||||
if (flags & PUBSYM_FLAG_CODE) and seg >= 1 and (seg - 1) < len(section_bases):
|
||||
va = section_bases[seg - 1] + offset
|
||||
out.append({
|
||||
"address": f"0x{va:08X}",
|
||||
"name": _demangle(mangled),
|
||||
"mangled": mangled,
|
||||
"flags": flags,
|
||||
})
|
||||
pos = rec_end
|
||||
# Records align to 4 bytes
|
||||
if pos % 4:
|
||||
pos += 4 - (pos % 4)
|
||||
return out
|
||||
|
||||
|
||||
# ── Section-header stream parser (40 bytes per IMAGE_SECTION_HEADER) ───────
|
||||
|
||||
def _parse_section_headers(sec_bytes, image_base=SECTION_HEADERS_DEFAULT_BASE):
|
||||
"""Each entry is a 40-byte IMAGE_SECTION_HEADER. Returns list of
|
||||
section-VA bases (so symbol[seg-1] + offset = VA)."""
|
||||
bases = []
|
||||
SECTION_SIZE = 40
|
||||
offset = 0
|
||||
while offset + SECTION_SIZE <= len(sec_bytes):
|
||||
# Layout: char Name[8], u32 VirtSize, u32 VirtAddress, ...
|
||||
virt_size, virt_addr = struct.unpack_from("<II", sec_bytes, offset + 8)
|
||||
if virt_addr == 0 and offset > 0:
|
||||
# Padding / empty trailing entry — stop.
|
||||
break
|
||||
bases.append(image_base + virt_addr)
|
||||
offset += SECTION_SIZE
|
||||
return bases
|
||||
|
||||
|
||||
# ── TPI parser — minimal pass to extract LF_CLASS/STRUCTURE names + sizes ──
|
||||
|
||||
def _extract_named_types(tpi_bytes):
|
||||
"""Walk the TPI stream's type record array and yield named class /
|
||||
struct / union / enum records with their declared size. Skips
|
||||
forward-declared (incomplete) records."""
|
||||
if len(tpi_bytes) < 56:
|
||||
return []
|
||||
# TPI header (56 bytes)
|
||||
(version, header_size,
|
||||
ti_min, ti_max,
|
||||
gpi_size,
|
||||
gpi_substream_offset,
|
||||
hash_aux_idx,
|
||||
hash_key_size, num_hash_buckets,
|
||||
hash_value_off, hash_value_len,
|
||||
ti_off_off, ti_off_len,
|
||||
hash_adj_off, hash_adj_len) = struct.unpack_from(
|
||||
"<IIIIIIHHIIIIIII", tpi_bytes, 0)
|
||||
pos = header_size # records start right after the header
|
||||
end = pos + gpi_size
|
||||
if end > len(tpi_bytes):
|
||||
end = len(tpi_bytes)
|
||||
|
||||
out = []
|
||||
while pos + 4 <= end:
|
||||
rec_len, kind = struct.unpack_from("<HH", tpi_bytes, pos)
|
||||
if rec_len == 0:
|
||||
break
|
||||
rec_end = pos + 2 + rec_len
|
||||
if kind in (LF_CLASS, LF_STRUCTURE):
|
||||
# body layout (LLVM type-records.h):
|
||||
# u16 count, u16 props, u32 fieldList, u32 derived,
|
||||
# u32 vshape, then varint16 size, then null-term string.
|
||||
try:
|
||||
count, props, field_list, derived, vshape = struct.unpack_from(
|
||||
"<HHIII", tpi_bytes, pos + 4)
|
||||
# varint16 size: <0x8000 = u16 inline; ≥0x8000 = follow-on u16/u32.
|
||||
size_pos = pos + 4 + 16
|
||||
size_word = tpi_bytes[size_pos] | (tpi_bytes[size_pos + 1] << 8)
|
||||
if size_word < 0x8000:
|
||||
size_val = size_word
|
||||
name_start = size_pos + 2
|
||||
else:
|
||||
# Numeric leaves. 0x8002=u16, 0x8003=u32, etc. Skip.
|
||||
if size_word == 0x8002:
|
||||
size_val = struct.unpack_from("<H", tpi_bytes, size_pos + 2)[0]
|
||||
name_start = size_pos + 4
|
||||
elif size_word == 0x8003:
|
||||
size_val = struct.unpack_from("<I", tpi_bytes, size_pos + 2)[0]
|
||||
name_start = size_pos + 6
|
||||
else:
|
||||
# Unknown numeric leaf — skip.
|
||||
pos = rec_end
|
||||
if pos % 4: pos += 4 - (pos % 4)
|
||||
continue
|
||||
# Name is null-terminated ASCII.
|
||||
if name_start < rec_end:
|
||||
zero = tpi_bytes.find(b"\0", name_start, rec_end)
|
||||
if zero > name_start:
|
||||
name = tpi_bytes[name_start:zero].decode("ascii", errors="replace")
|
||||
# Skip forward-decls: bit 7 of `props` (0x80).
|
||||
is_fwdref = (props & 0x80) != 0
|
||||
if not is_fwdref and name and not name.startswith("<unnamed"):
|
||||
out.append({
|
||||
"name": name,
|
||||
"size": size_val,
|
||||
"kind": "class" if kind == LF_CLASS else "struct",
|
||||
})
|
||||
except (struct.error, IndexError, ValueError):
|
||||
pass
|
||||
|
||||
pos = rec_end
|
||||
if pos % 4:
|
||||
pos += 4 - (pos % 4)
|
||||
return out
|
||||
|
||||
|
||||
# ── Driver ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_repo_root():
|
||||
"""tools/pdb-extract/ -> repo root is two directories up."""
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _main():
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: py pdb_extract.py <path-to-acclient.pdb>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
pdb_path = Path(sys.argv[1])
|
||||
if not pdb_path.exists():
|
||||
print(f"not found: {pdb_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
print(f"loading {pdb_path} ({pdb_path.stat().st_size / 1024 / 1024:.1f} MB)...")
|
||||
msf = Msf(str(pdb_path))
|
||||
print(f" block size: {msf.block_size}, streams: {len(msf.streams)}")
|
||||
|
||||
# 1. DBI -> find sym-record + section-header stream indices
|
||||
dbi_bytes = msf.stream(STREAM_DBI)
|
||||
sym_stream_idx, sec_hdr_stream_idx = _parse_dbi(dbi_bytes)
|
||||
print(f" sym stream: {sym_stream_idx}, section-hdr stream: {sec_hdr_stream_idx}")
|
||||
|
||||
# 2. Section headers -> segment bases
|
||||
sec_bytes = msf.stream(sec_hdr_stream_idx)
|
||||
section_bases = _parse_section_headers(sec_bytes)
|
||||
print(f" sections: {len(section_bases)} (text base = 0x{section_bases[0]:08X})")
|
||||
|
||||
# 3. Symbol records -> S_PUB32 entries with image VAs
|
||||
sym_bytes = msf.stream(sym_stream_idx)
|
||||
print(f" sym record stream: {len(sym_bytes) / 1024:.1f} KB")
|
||||
symbols = _extract_pub32(sym_bytes, section_bases)
|
||||
print(f" extracted {len(symbols)} public function symbols")
|
||||
|
||||
# 4. TPI -> named types
|
||||
tpi_bytes = msf.stream(STREAM_TPI)
|
||||
print(f" TPI stream: {len(tpi_bytes) / 1024:.1f} KB")
|
||||
types = _extract_named_types(tpi_bytes)
|
||||
# Dedup by name (templates/forward-decl spam can produce duplicates)
|
||||
seen = set()
|
||||
unique_types = []
|
||||
for t in types:
|
||||
if t["name"] not in seen:
|
||||
seen.add(t["name"])
|
||||
unique_types.append(t)
|
||||
print(f" extracted {len(unique_types)} unique named types ({len(types)} total records)")
|
||||
|
||||
# 5. Write outputs
|
||||
repo = _resolve_repo_root()
|
||||
out_dir = repo / "docs" / "research" / "named-retail"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sym_out = out_dir / "symbols.json"
|
||||
type_out = out_dir / "types.json"
|
||||
|
||||
with open(sym_out, "w", encoding="utf-8") as f:
|
||||
# Keep address + demangled name + raw mangled name (for callers
|
||||
# that need the C++ ABI form). Strip flags as not useful for grep.
|
||||
compact = [
|
||||
{"address": s["address"], "name": s["name"], "mangled": s["mangled"]}
|
||||
for s in symbols
|
||||
]
|
||||
json.dump(compact, f, indent=2)
|
||||
with open(type_out, "w", encoding="utf-8") as f:
|
||||
json.dump(unique_types, f, indent=2)
|
||||
|
||||
print(f"\nwrote {sym_out} ({sym_out.stat().st_size / 1024:.1f} KB)")
|
||||
print(f"wrote {type_out} ({type_out.stat().st_size / 1024:.1f} KB)")
|
||||
# Spot check: CEnchantmentRegistry::EnchantAttribute should be at 0x594570 per discovery agent.
|
||||
target = "CEnchantmentRegistry::EnchantAttribute"
|
||||
for s in symbols:
|
||||
if s["name"] == target:
|
||||
print(f"\nspot check: {target} -> {s['address']} (expected 0x00594570)")
|
||||
break
|
||||
else:
|
||||
print(f"\nspot check: {target} NOT FOUND in symbols (PDB lookup mismatch?)")
|
||||
|
||||
|
||||
_main()
|
||||
33
tools/peek_addr.py
Normal file
33
tools/peek_addr.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""peek_addr.py <pid> <va> [n=16]
|
||||
Read n bytes at va from a live process. Print as hex + try to interpret prologue."""
|
||||
import ctypes, ctypes.wintypes as wt, sys
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
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
|
||||
|
||||
pid = int(sys.argv[1]); va = int(sys.argv[2], 0); n = int(sys.argv[3]) if len(sys.argv) > 3 else 16
|
||||
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print(f"OpenProcess err={ctypes.get_last_error()}"); sys.exit(2)
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)):
|
||||
print(f"ReadProcessMemory @ 0x{va:08x} err={ctypes.get_last_error()}"); sys.exit(3)
|
||||
data = bytes(buf[:sz.value])
|
||||
print(f"@ 0x{va:08x}: {data.hex(' ')}")
|
||||
# rough heuristics
|
||||
hints = []
|
||||
if data[:1] == b'\x55': hints.append("push ebp (typical prologue)")
|
||||
if data[:1] == b'\x56': hints.append("push esi (thiscall this->esi prologue)")
|
||||
if data[:1] == b'\x53': hints.append("push ebx (prologue start)")
|
||||
if data[:1] == b'\x8b': hints.append("mov reg, ... (could be prologue or middle)")
|
||||
if data[:1] == b'\x83': hints.append("sub/add esp, imm8 (stack alloc, prologue)")
|
||||
if data[:3] == b'\xb0\x01\xc3': hints.append("mov al,1; ret (no-op stub)")
|
||||
if data[:3] == b'\xb0\x00\xc3': hints.append("mov al,0; ret")
|
||||
if data[:1] == b'\xc3': hints.append("bare ret")
|
||||
if data[:2] == b'\x90\x90': hints.append("NOP pad (real code likely after)")
|
||||
if hints: print(" -> " + "; ".join(hints))
|
||||
k.CloseHandle(h)
|
||||
59
tools/peek_first_region.py
Normal file
59
tools/peek_first_region.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""peek_first_region.py <pid> <exact_size_bytes>
|
||||
Find the first private-RW region of exactly <size>, dump its first 256 bytes
|
||||
plus a sample at offset 4096 and at midpoint."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1]); target_size = int(sys.argv[2])
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
found = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)) and found < 3:
|
||||
base = mbi.BaseAddress or 0
|
||||
sz = mbi.RegionSize
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000 and (mbi.Protect & 0xFF) in (0x04, 0x40)
|
||||
and sz == target_size):
|
||||
print(f"\n=== region @ 0x{base:08x} size={sz} ===")
|
||||
for off in [0, 256, 4096, sz // 2, sz - 64]:
|
||||
data = rd(h, base + off, 64)
|
||||
if data:
|
||||
print(f" +0x{off:06x}: {data[:32].hex(' ')}")
|
||||
print(f" {data[32:64].hex(' ')}")
|
||||
# Interpret first 32 bytes as DWORDs and floats
|
||||
data = rd(h, base, 32)
|
||||
if data:
|
||||
print(f" as DWORDs: ", end='')
|
||||
for i in range(0, 32, 4):
|
||||
print(f"0x{struct.unpack_from('<I', data, i)[0]:08x}", end=' ')
|
||||
print()
|
||||
print(f" as floats: ", end='')
|
||||
for i in range(0, 32, 4):
|
||||
print(f"{struct.unpack_from('<f', data, i)[0]:.3g}", end=' ')
|
||||
print()
|
||||
found += 1
|
||||
next_addr = base + sz
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
k.CloseHandle(h)
|
||||
135
tools/physobj_owner_diff.py
Normal file
135
tools/physobj_owner_diff.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""physobj_owner_diff.py <low_dmp> <high_dmp>
|
||||
|
||||
Run owner-vtable scan on both dumps and report HIGH - LOW (instances delta).
|
||||
Only counts real vtable hits (slot 0 must be a function in .text).
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
CPHYSOBJ_VT = 0x007c78e0
|
||||
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def scan(dump_path):
|
||||
md = MinidumpFile.parse(dump_path)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
text_ranges = []
|
||||
image_ranges = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue
|
||||
if ty == 0x1000000:
|
||||
image_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
|
||||
if pr in (0x20, 0x10, 0x40, 0x02): # execute
|
||||
text_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
|
||||
image_ranges.sort()
|
||||
text_ranges.sort()
|
||||
|
||||
def in_text(addr):
|
||||
for lo, hi in text_ranges:
|
||||
if lo <= addr < hi:
|
||||
return True
|
||||
return False
|
||||
|
||||
def in_image(addr):
|
||||
for lo, hi in image_ranges:
|
||||
if lo <= addr < hi:
|
||||
return True
|
||||
return False
|
||||
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue
|
||||
if ty == 0x1000000: continue
|
||||
if pr not in (0x04, 0x40): continue
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
# Helper to read DWORD from anywhere
|
||||
def read_dword(va):
|
||||
try:
|
||||
reader.move(va)
|
||||
data = reader.read(4)
|
||||
if len(data) == 4:
|
||||
return struct.unpack('<I', data)[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
region_bufs = []
|
||||
physobj_addrs = set()
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
region_bufs.append((base, buf))
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v == CPHYSOBJ_VT:
|
||||
physobj_addrs.add(base + off)
|
||||
|
||||
LOOKBACK = 0x200
|
||||
vtable_unique_owners = Counter() # vtable -> distinct owner-instance addresses
|
||||
seen_owners = set() # to dedupe per (vtable, owner_base)
|
||||
|
||||
for base, buf in region_bufs:
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v not in physobj_addrs:
|
||||
continue
|
||||
hit_va = base + off
|
||||
if hit_va == v:
|
||||
continue
|
||||
start = max(0, off - LOOKBACK)
|
||||
for back in range(off - 4, start - 4, -4):
|
||||
if back < 0: break
|
||||
vv = struct.unpack_from("<I", buf, back)[0]
|
||||
if not in_image(vv):
|
||||
continue
|
||||
# validate: slot 0 must point to executable code
|
||||
slot0 = read_dword(vv)
|
||||
if slot0 is None or not in_text(slot0):
|
||||
continue
|
||||
owner_base = base + back
|
||||
key = (vv, owner_base)
|
||||
if key in seen_owners:
|
||||
break
|
||||
seen_owners.add(key)
|
||||
vtable_unique_owners[vv] += 1
|
||||
break
|
||||
|
||||
return physobj_addrs, vtable_unique_owners
|
||||
|
||||
|
||||
def main():
|
||||
low_path, high_path = sys.argv[1], sys.argv[2]
|
||||
print("=== LOW dump scan ===")
|
||||
low_p, low_v = scan(low_path)
|
||||
print(f"low: physobjs={len(low_p)}, unique-owner-instances tracked across {len(low_v)} vtables")
|
||||
print("=== HIGH dump scan ===")
|
||||
high_p, high_v = scan(high_path)
|
||||
print(f"high: physobjs={len(high_p)}, unique-owner-instances tracked across {len(high_v)} vtables")
|
||||
|
||||
all_vt = set(low_v.keys()) | set(high_v.keys())
|
||||
deltas = [(vt, high_v[vt] - low_v[vt], low_v[vt], high_v[vt]) for vt in all_vt]
|
||||
deltas.sort(key=lambda x: -x[1])
|
||||
|
||||
print(f"\n=== Owners growing the most (high - low) — top 30 ===")
|
||||
print(f"{'vtable':>10} {'delta':>8} {'low':>6} {'high':>6}")
|
||||
for vt, d, lo, hi in deltas[:30]:
|
||||
print(f" 0x{vt:08x} {d:>8} {lo:>6} {hi:>6}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
117
tools/physobj_owner_inspect.py
Normal file
117
tools/physobj_owner_inspect.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""physobj_owner_inspect.py <dump.dmp> <owner_vtable>
|
||||
|
||||
For a specific owner vtable, find all instances and report:
|
||||
- distinct instance count
|
||||
- typical instance footprint (count of physobj pointers per instance, max field offset)
|
||||
- histogram of physobj-field offsets within the owner
|
||||
- 5 sample instances dumped (first 0x100 bytes)
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter, defaultdict
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
CPHYSOBJ_VT = 0x007c78e0
|
||||
|
||||
|
||||
def _ei(v):
|
||||
if v is None:
|
||||
return 0
|
||||
if hasattr(v, 'value'):
|
||||
return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def main():
|
||||
dump_path = sys.argv[1]
|
||||
target_vt = int(sys.argv[2], 0)
|
||||
md = MinidumpFile.parse(dump_path)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
# First pass: find physobj instances
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000:
|
||||
continue
|
||||
if ty == 0x1000000:
|
||||
continue
|
||||
if pr not in (0x04, 0x40):
|
||||
continue
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
region_bufs = []
|
||||
physobj_addrs = set()
|
||||
owner_addrs = [] # va where DWORD == target_vt
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
region_bufs.append((base, buf))
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v == CPHYSOBJ_VT:
|
||||
physobj_addrs.add(base + off)
|
||||
elif v == target_vt:
|
||||
owner_addrs.append((base + off, base, off, buf))
|
||||
|
||||
print(f"physobj instances: {len(physobj_addrs)}")
|
||||
print(f"owner instances (vt 0x{target_vt:08x}): {len(owner_addrs)}")
|
||||
|
||||
if not owner_addrs:
|
||||
return
|
||||
|
||||
# For each owner instance, scan its memory range forward and find embedded physobj ptrs
|
||||
# Use heap-entry detection: assume instance footprint up to next vt or up to 0x800 bytes.
|
||||
SCAN_AHEAD = 0x400
|
||||
pfields = Counter() # offset within owner -> count
|
||||
physobjs_per_owner = []
|
||||
samples = []
|
||||
|
||||
for ova, base, off, buf in owner_addrs[:50000]:
|
||||
end = min(len(buf), off + SCAN_AHEAD)
|
||||
nphysobj = 0
|
||||
max_field = 0
|
||||
for fo in range(0, end - off, 4):
|
||||
v = struct.unpack_from("<I", buf, off + fo)[0]
|
||||
if v in physobj_addrs:
|
||||
pfields[fo] += 1
|
||||
nphysobj += 1
|
||||
if fo > max_field:
|
||||
max_field = fo
|
||||
physobjs_per_owner.append((ova, nphysobj, max_field))
|
||||
if len(samples) < 5 and nphysobj > 0:
|
||||
samples.append((ova, buf[off:off + 0x100]))
|
||||
|
||||
print(f"\nphysobj-ptr field offsets within owner (top 20):")
|
||||
for fo, cnt in pfields.most_common(20):
|
||||
print(f" +0x{fo:03x} count={cnt}")
|
||||
|
||||
# Distribution of physobj counts per owner
|
||||
counts = Counter(n for _, n, _ in physobjs_per_owner)
|
||||
print(f"\nphysobjs-per-owner distribution (top 10):")
|
||||
for n, cnt in counts.most_common(10):
|
||||
print(f" {n} physobjs in {cnt} owners")
|
||||
|
||||
nz = [n for _, n, _ in physobjs_per_owner if n > 0]
|
||||
if nz:
|
||||
avg = sum(nz) / len(nz)
|
||||
print(f"\nowners with >=1 physobj: {len(nz)}, avg {avg:.2f} physobj/owner")
|
||||
total = sum(nz)
|
||||
print(f"total physobj-ptr edges from these owners: {total}")
|
||||
print(f"unique physobj instances pointed to (upper bound): {min(total, len(physobj_addrs))}")
|
||||
|
||||
print(f"\nsample owner dumps (first 0x100 bytes):")
|
||||
for ova, data in samples[:3]:
|
||||
print(f" owner @ 0x{ova:08x}:")
|
||||
for i in range(0, len(data), 16):
|
||||
chunk = data[i:i + 16]
|
||||
print(f" +0x{i:03x}: {chunk.hex(' ')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
160
tools/physobj_owner_scan.py
Normal file
160
tools/physobj_owner_scan.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""physobj_owner_scan.py <dump.dmp>
|
||||
|
||||
Identify owners of leaked CPhysicsObj instances (EoR vtable 0x007c78e0).
|
||||
|
||||
Method:
|
||||
1. Enumerate all CPhysicsObj instances by scanning RW memory for DWORD = 0x007c78e0
|
||||
at offset 0 of any heap-aligned 4-byte slot. Each hit's address is a CPhysicsObj*.
|
||||
2. For each instance, scan ALL committed RW memory for DWORDs equal to that
|
||||
CPhysicsObj's base address (these are owners holding strong refs).
|
||||
3. For each owner-pointer hit, walk BACKWARDS up to 0x400 bytes within the same
|
||||
region looking for a DWORD that points into image memory (the owner's vtable).
|
||||
4. Histogram (vtable, field_offset) — these are the leak holders.
|
||||
|
||||
Output: ranked owner vtables and ranked (vtable, field_offset) pairs.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter, defaultdict
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
CPHYSOBJ_VT = 0x007c78e0
|
||||
|
||||
|
||||
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 = []
|
||||
for m in md.modules.modules:
|
||||
mods.append((m.baseaddress, m.size, m.name))
|
||||
|
||||
def mod_of(addr):
|
||||
for b, s, n in mods:
|
||||
if b <= addr < b + s:
|
||||
return n.split("\\")[-1]
|
||||
return None
|
||||
|
||||
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))
|
||||
image_ranges.sort()
|
||||
|
||||
def is_image(addr):
|
||||
for lo, hi in image_ranges:
|
||||
if lo <= addr < hi:
|
||||
return True
|
||||
if addr < lo:
|
||||
return False
|
||||
return False
|
||||
|
||||
# Gather all RW private/mapped regions and cache buffers
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000:
|
||||
continue
|
||||
if ty == 0x1000000:
|
||||
continue
|
||||
if pr not in (0x04, 0x40):
|
||||
continue
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
total_bytes = sum(s for _, s in scan_regions)
|
||||
print(f"scanning {len(scan_regions)} regions ({total_bytes / (1024 * 1024):.1f} MB)")
|
||||
|
||||
region_bufs = [] # (base, buf)
|
||||
physobj_addrs = []
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
region_bufs.append((base, buf))
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v == CPHYSOBJ_VT:
|
||||
physobj_addrs.append(base + off)
|
||||
|
||||
print(f"found {len(physobj_addrs)} CPhysicsObj instances")
|
||||
if not physobj_addrs:
|
||||
return
|
||||
|
||||
physobj_set = set(physobj_addrs)
|
||||
|
||||
# Now scan ALL regions for DWORDs whose value is in physobj_set
|
||||
# For each hit, walk back 0x400 bytes to find a vtable.
|
||||
LOOKBACK = 0x400
|
||||
vtable_hits = Counter()
|
||||
vt_off_hits = Counter()
|
||||
examples = defaultdict(list)
|
||||
field_offsets_per_vtable = defaultdict(Counter)
|
||||
no_vtable = 0
|
||||
raw_hits = 0
|
||||
self_skipped = 0
|
||||
|
||||
for base, buf in region_bufs:
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v not in physobj_set:
|
||||
continue
|
||||
hit_va = base + off
|
||||
if hit_va == v:
|
||||
# vtable slot of the physobj itself; ignore
|
||||
self_skipped += 1
|
||||
continue
|
||||
raw_hits += 1
|
||||
start = max(0, off - LOOKBACK)
|
||||
found = False
|
||||
for back in range(off - 4, start - 4, -4):
|
||||
if back < 0:
|
||||
break
|
||||
vv = struct.unpack_from("<I", buf, back)[0]
|
||||
if vv < 0x00400000 or vv > 0x10000000:
|
||||
continue
|
||||
if is_image(vv):
|
||||
field_off = off - back
|
||||
vtable_hits[vv] += 1
|
||||
vt_off_hits[(vv, field_off)] += 1
|
||||
field_offsets_per_vtable[vv][field_off] += 1
|
||||
if len(examples[(vv, field_off)]) < 3:
|
||||
examples[(vv, field_off)].append((hit_va, v))
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
no_vtable += 1
|
||||
|
||||
print(f"raw owner-ptr hits: {raw_hits}")
|
||||
print(f"self-vtable slot skipped: {self_skipped}")
|
||||
print(f"hits with no preceding vtable in lookback: {no_vtable}")
|
||||
print()
|
||||
print(f"=== Top owner vtables (regardless of offset) ===")
|
||||
for vt, cnt in vtable_hits.most_common(20):
|
||||
owner = mod_of(vt) or "?"
|
||||
top_offs = field_offsets_per_vtable[vt].most_common(4)
|
||||
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
|
||||
print(f" vt=0x{vt:08x} hits={cnt:<6} ({owner}) {offs_str}")
|
||||
|
||||
print()
|
||||
print(f"=== Top (owner_vtable, field_offset) pairs ===")
|
||||
for (vt, off), cnt in vt_off_hits.most_common(30):
|
||||
owner = mod_of(vt) or "?"
|
||||
ex = examples[(vt, off)][0]
|
||||
print(f" vt=0x{vt:08x} +0x{off:03x} hits={cnt:<6} ({owner}) e.g. owner@0x{(ex[0] - off):08x} ptr@0x{ex[0]:08x} -> physobj@0x{ex[1]:08x}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
119
tools/physobj_owner_tight.py
Normal file
119
tools/physobj_owner_tight.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""physobj_owner_tight.py <dump.dmp> <owner_vtable> [object_size]
|
||||
|
||||
Same as physobj_owner_inspect but with tight per-instance window: stops at
|
||||
the next image-pointer (vtable boundary).
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
CPHYSOBJ_VT = 0x007c78e0
|
||||
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def main():
|
||||
dump_path = sys.argv[1]
|
||||
target_vt = int(sys.argv[2], 0)
|
||||
obj_size = int(sys.argv[3], 0) if len(sys.argv) > 3 else None
|
||||
md = MinidumpFile.parse(dump_path)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
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))
|
||||
image_ranges.sort()
|
||||
|
||||
def is_image(addr):
|
||||
for lo, hi in image_ranges:
|
||||
if lo <= addr < hi:
|
||||
return True
|
||||
if addr < lo:
|
||||
return False
|
||||
return False
|
||||
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue
|
||||
if ty == 0x1000000: continue
|
||||
if pr not in (0x04, 0x40): continue
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
region_bufs = []
|
||||
physobj_addrs = set()
|
||||
owner_locations = [] # (va, base, off, buf)
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
region_bufs.append((base, buf))
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v == CPHYSOBJ_VT:
|
||||
physobj_addrs.add(base + off)
|
||||
elif v == target_vt:
|
||||
owner_locations.append((base + off, base, off, buf))
|
||||
|
||||
print(f"physobj instances: {len(physobj_addrs)}")
|
||||
print(f"owner instances (vt 0x{target_vt:08x}): {len(owner_locations)}")
|
||||
|
||||
if not owner_locations:
|
||||
return
|
||||
|
||||
MAX_WINDOW = obj_size if obj_size else 0x400
|
||||
pfields = Counter()
|
||||
nonzero_per_owner = []
|
||||
|
||||
for ova, base, off, buf in owner_locations:
|
||||
# Determine tight window: stop at next image pointer or fixed obj_size
|
||||
if obj_size:
|
||||
stop = off + obj_size
|
||||
else:
|
||||
stop = off + 4
|
||||
limit = min(len(buf), off + MAX_WINDOW)
|
||||
while stop < limit:
|
||||
v = struct.unpack_from("<I", buf, stop)[0]
|
||||
if is_image(v):
|
||||
break
|
||||
stop += 4
|
||||
nphys = 0
|
||||
for fo in range(0, stop - off, 4):
|
||||
v = struct.unpack_from("<I", buf, off + fo)[0]
|
||||
if v in physobj_addrs:
|
||||
pfields[fo] += 1
|
||||
nphys += 1
|
||||
nonzero_per_owner.append((ova, nphys, stop - off))
|
||||
|
||||
sizes = Counter(s for _, _, s in nonzero_per_owner)
|
||||
print(f"\ninstance-size distribution (top 10 by count):")
|
||||
for sz, cnt in sizes.most_common(10):
|
||||
print(f" size~0x{sz:03x} count={cnt}")
|
||||
|
||||
print(f"\nphysobj-ptr field offsets within owner (top 20):")
|
||||
for fo, cnt in pfields.most_common(20):
|
||||
print(f" +0x{fo:03x} count={cnt}")
|
||||
|
||||
nz = [n for _, n, _ in nonzero_per_owner if n > 0]
|
||||
if nz:
|
||||
print(f"\nowners with >=1 physobj: {len(nz)} / {len(owner_locations)}")
|
||||
print(f"avg physobj/owner: {sum(nz)/len(nz):.2f}")
|
||||
print(f"total physobj-edges: {sum(nz)}")
|
||||
cnts = Counter(n for _, n, _ in nonzero_per_owner)
|
||||
print(f"\nphysobjs-per-owner distribution:")
|
||||
for n, cnt in sorted(cnts.most_common(15)):
|
||||
print(f" {n} physobjs: {cnt} owners")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
125
tools/position_array_inspect.py
Normal file
125
tools/position_array_inspect.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""position_array_inspect.py <pid> -- for arrays of Positions (96.6%
|
||||
of all hits), find the bytes immediately BEFORE the array start and
|
||||
immediately AFTER the array end. This is where the array allocator's
|
||||
header would live, or where the containing DArray/SmartArray descriptor
|
||||
points.
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
SIZEOF_POSITION = 72
|
||||
ACMIN, ACMAX = 0x00400000, 0x00900000
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def scan(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
|
||||
|
||||
# For each "array head" (Position with no Position 72 bytes before),
|
||||
# collect: (array_len, bytes 16 BEFORE first position).
|
||||
# For each "array tail" (Position with no Position 72 bytes after),
|
||||
# collect: bytes 16 AFTER end of position.
|
||||
array_lens = Counter()
|
||||
head_minus_4 = Counter()
|
||||
head_minus_8 = Counter()
|
||||
head_minus_12 = Counter()
|
||||
head_minus_16 = Counter()
|
||||
array_size_bytes = Counter()
|
||||
total_arrays = 0
|
||||
|
||||
mbi = MBI(); addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
pos_offs = []
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
||||
pos_offs.append(off)
|
||||
pos_set = set(pos_offs)
|
||||
# find array heads
|
||||
for off in pos_offs:
|
||||
if (off - SIZEOF_POSITION) in pos_set:
|
||||
continue # not a head
|
||||
# Walk forward to find the tail
|
||||
tail = off
|
||||
length = 1
|
||||
while (tail + SIZEOF_POSITION) in pos_set:
|
||||
tail += SIZEOF_POSITION
|
||||
length += 1
|
||||
if length < 2:
|
||||
continue # not really an array
|
||||
total_arrays += 1
|
||||
array_lens[length] += 1
|
||||
array_size_bytes[length * SIZEOF_POSITION] += 1
|
||||
# Bytes BEFORE head
|
||||
if off >= 16:
|
||||
m4 = struct.unpack_from("<I", data, off - 4)[0]
|
||||
m8 = struct.unpack_from("<I", data, off - 8)[0]
|
||||
m12 = struct.unpack_from("<I", data, off - 12)[0]
|
||||
m16 = struct.unpack_from("<I", data, off - 16)[0]
|
||||
head_minus_4[m4] += 1
|
||||
head_minus_8[m8] += 1
|
||||
head_minus_12[m12] += 1
|
||||
head_minus_16[m16] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
print(f"PID {pid}: {total_arrays} Position arrays found")
|
||||
print()
|
||||
print("Top array lengths (positions per array):")
|
||||
for length, n in array_lens.most_common(15):
|
||||
print(f" len={length:>5} count={n:>6} bytes/array={length*SIZEOF_POSITION:>7}")
|
||||
print()
|
||||
print("Top values at offset -4 of array head (likely refcount/size/flags):")
|
||||
for v, n in head_minus_4.most_common(8):
|
||||
print(f" {v:#010x} count={n}")
|
||||
print()
|
||||
print("Top values at offset -8 of array head:")
|
||||
for v, n in head_minus_8.most_common(8):
|
||||
print(f" {v:#010x} count={n}")
|
||||
print()
|
||||
print("Top values at offset -12 of array head:")
|
||||
for v, n in head_minus_12.most_common(8):
|
||||
print(f" {v:#010x} count={n}")
|
||||
print()
|
||||
print("Top values at offset -16 of array head:")
|
||||
for v, n in head_minus_16.most_common(8):
|
||||
print(f" {v:#010x} count={n}")
|
||||
|
||||
k.CloseHandle(h)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(scan(int(sys.argv[1])))
|
||||
154
tools/position_heap_solo_scan.py
Normal file
154
tools/position_heap_solo_scan.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""position_heap_solo_scan.py <pid>
|
||||
|
||||
Variant of position_host_scan that filters to ONLY Positions which
|
||||
look like standalone heap allocations (vs embedded into a bigger
|
||||
struct).
|
||||
|
||||
Heuristic for "standalone heap allocation":
|
||||
- Bytes right before the Position vtable have a typical heap-block
|
||||
header pattern (small DWORD = allocation size, then a possibly-NULL
|
||||
block-id, then padding/flags).
|
||||
- OR there is a ref-counted wrapper vtable at offset -8 (the
|
||||
PositionPropertyValue pattern).
|
||||
- The +96..+128 region after the Position is either NULL/heap-free
|
||||
(suggesting nothing follows the Position in the same block).
|
||||
|
||||
Goal: classify Positions into:
|
||||
(A) embedded in a larger object — host vtable found at offset -4
|
||||
through -16 (the Position is just a field of e.g. a PhysicsObj
|
||||
or NetBlob).
|
||||
(B) inside a ref-counted property wrapper — vtable at -8
|
||||
(PositionPropertyValue layout).
|
||||
(C) heap-solo at offset 0 — allocated as `new Position(...)`
|
||||
directly, no enclosing object.
|
||||
(D) part of an array — another Position vtable 96 bytes away.
|
||||
|
||||
The leaking class is whichever bucket dominates the delta between
|
||||
heavy-looter and idle character.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import struct
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
ACCLIENT_MIN = 0x00400000
|
||||
ACCLIENT_MAX = 0x00900000
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def classify(data, off):
|
||||
"""Return (bucket, evidence_value). bucket in {'array','ppv',
|
||||
'embedded','solo','unknown'}."""
|
||||
# array: Position vtable 96 bytes earlier or later
|
||||
if off >= 96:
|
||||
prev = struct.unpack_from("<I", data, off - 96)[0]
|
||||
if prev == POSITION_VT:
|
||||
return ('array_prev', prev)
|
||||
if off + 96 + 4 <= len(data):
|
||||
nxt = struct.unpack_from("<I", data, off + 96)[0]
|
||||
if nxt == POSITION_VT:
|
||||
return ('array_next', nxt)
|
||||
# ppv: vtable at off-8, refcount-shaped dword at off-4
|
||||
if off >= 8:
|
||||
m8 = struct.unpack_from("<I", data, off - 8)[0]
|
||||
m4 = struct.unpack_from("<I", data, off - 4)[0]
|
||||
if ACCLIENT_MIN <= m8 <= ACCLIENT_MAX and 0 < m4 < 0x100000:
|
||||
return ('ppv', m8)
|
||||
# embedded: any acclient code-range vtable at offsets -32..-4
|
||||
if off >= 32:
|
||||
for d in range(-32, 0, 4):
|
||||
v = struct.unpack_from("<I", data, off + d)[0]
|
||||
if ACCLIENT_MIN <= v <= ACCLIENT_MAX:
|
||||
return ('embedded', v)
|
||||
# solo: nothing identifying
|
||||
return ('solo', 0)
|
||||
|
||||
|
||||
def scan(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
|
||||
|
||||
bucket_counts = Counter()
|
||||
embedded_vt_counts = Counter()
|
||||
ppv_vt_counts = Counter()
|
||||
array_off_counts = Counter()
|
||||
|
||||
total = 0
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf,
|
||||
mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
||||
total += 1
|
||||
b, ev = classify(data, off)
|
||||
bucket_counts[b] += 1
|
||||
if b == 'embedded':
|
||||
embedded_vt_counts[ev] += 1
|
||||
elif b == 'ppv':
|
||||
ppv_vt_counts[ev] += 1
|
||||
elif b.startswith('array'):
|
||||
array_off_counts[b] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
print(f"PID {pid}: {total} Position instances scanned")
|
||||
print()
|
||||
print("Bucket distribution:")
|
||||
for b, n in bucket_counts.most_common():
|
||||
pct = 100.0 * n / max(1, total)
|
||||
print(f" {b:>12}: {n:>7} ({pct:5.1f}%)")
|
||||
print()
|
||||
print("Top 'embedded' host vtables (Position is a member of class):")
|
||||
for v, n in embedded_vt_counts.most_common(15):
|
||||
print(f" 0x{v:08x} count={n:>6}")
|
||||
print()
|
||||
print("Top 'ppv' wrapper vtables (Position inside ref-counted holder):")
|
||||
for v, n in ppv_vt_counts.most_common(10):
|
||||
print(f" 0x{v:08x} count={n:>6}")
|
||||
print()
|
||||
print("Array adjacency:")
|
||||
for b, n in array_off_counts.most_common():
|
||||
print(f" {b}: {n}")
|
||||
|
||||
k.CloseHandle(h)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(scan(int(sys.argv[1])))
|
||||
140
tools/position_host_scan.py
Normal file
140
tools/position_host_scan.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""position_host_scan.py <pid>
|
||||
|
||||
For every Position instance found in a live PID:
|
||||
- Read 64 bytes before and 128 bytes after the vtable pointer.
|
||||
- Look at offsets -32, -28, -24, -20, -16, -12, -8, -4 (might be
|
||||
vtables of host containers / property wrappers / list nodes).
|
||||
- Look at offsets +96 (the byte right after Position ends), +100,
|
||||
+104, +108 (vtables of "next instance in the same container").
|
||||
- For any DWORD in those slots that falls in acclient.exe code/.rdata
|
||||
range (0x00400000..0x007FFFFF), bucket it.
|
||||
- Print top vtables seen at each offset.
|
||||
|
||||
This is the diagnostic that breaks open which OWNER class is
|
||||
heap-allocating Positions but never freeing them.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import struct
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
ACCLIENT_MIN = 0x00400000
|
||||
ACCLIENT_MAX = 0x00900000 # generous upper bound for .text/.rdata
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
|
||||
ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
# Offsets relative to Position vtable address. Positive = into Position;
|
||||
# negative = backwards into containing header.
|
||||
OFFSETS = [-32, -28, -24, -20, -16, -12, -8, -4,
|
||||
+96, +100, +104, +108, +112, +116, +120, +124]
|
||||
|
||||
|
||||
def scan(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
|
||||
|
||||
# Histogram offset -> Counter(value)
|
||||
hist = defaultdict(Counter)
|
||||
total = 0
|
||||
|
||||
# Track Position-at-N density: how many Positions hit at exact
|
||||
# stride 96 from a prior Position (suggests array/list of Positions).
|
||||
stride_hits = Counter()
|
||||
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf,
|
||||
mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
# Find all Position vtable hits in this region first
|
||||
pos_offs_in_region = []
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
||||
pos_offs_in_region.append(off)
|
||||
for off in pos_offs_in_region:
|
||||
total += 1
|
||||
# context
|
||||
for delta in OFFSETS:
|
||||
target = off + delta
|
||||
if 0 <= target <= len(data) - 4:
|
||||
v = struct.unpack_from("<I", data, target)[0]
|
||||
# only record if it looks like a code pointer (vtable)
|
||||
if ACCLIENT_MIN <= v <= ACCLIENT_MAX:
|
||||
hist[delta][v] += 1
|
||||
else:
|
||||
# also track NULL specifically
|
||||
if v == 0:
|
||||
hist[delta][0] += 1
|
||||
# Stride: if Position at off and another at off+96, +192 etc.
|
||||
pos_set = set(pos_offs_in_region)
|
||||
for off in pos_offs_in_region:
|
||||
for k_stride in (96, 192, 288):
|
||||
if off + k_stride in pos_set:
|
||||
stride_hits[k_stride] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
print(f"PID {pid}: {total} Position instances scanned")
|
||||
print()
|
||||
print("Top non-zero vtables at each offset relative to Position vt:")
|
||||
for delta in OFFSETS:
|
||||
c = hist[delta]
|
||||
non_null = [(v, n) for v, n in c.items() if v != 0]
|
||||
non_null.sort(key=lambda x: -x[1])
|
||||
null_n = c.get(0, 0)
|
||||
if not non_null and null_n == 0:
|
||||
continue
|
||||
print(f"\n offset {delta:+4d}: (null={null_n}/{total} = "
|
||||
f"{100.0*null_n/max(1,total):.1f}%)")
|
||||
for v, n in non_null[:6]:
|
||||
pct = 100.0 * n / total
|
||||
print(f" 0x{v:08x} count={n:>6} ({pct:.1f}%)")
|
||||
|
||||
print()
|
||||
print("Position-at-stride hits (suggests array/list of Positions):")
|
||||
for s, n in stride_hits.most_common():
|
||||
print(f" stride {s}B: {n} adjacent Position pairs")
|
||||
|
||||
k.CloseHandle(h)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(scan(int(sys.argv[1])))
|
||||
108
tools/position_host_v2.py
Normal file
108
tools/position_host_v2.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""position_host_v2.py <pid> -- Position is 72 bytes (not 96).
|
||||
For each Position vt hit:
|
||||
- Detect array adjacency at stride 72.
|
||||
- Look at offsets -56..-4 for host vtables.
|
||||
- Bucket: (array-element, ppv-wrapper, host-embedded, solo).
|
||||
"""
|
||||
import ctypes, ctypes.wintypes as wt, struct, sys
|
||||
from collections import Counter
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
SIZEOF_POSITION = 72 # vt + objcell_id + Frame(64)
|
||||
ACMIN, ACMAX = 0x00400000, 0x00900000
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def classify(data, off, pos_set):
|
||||
# array adjacency at stride 72
|
||||
if off >= SIZEOF_POSITION and (off - SIZEOF_POSITION) in pos_set:
|
||||
return ('array_inner', None)
|
||||
if (off + SIZEOF_POSITION) in pos_set:
|
||||
return ('array_head', None)
|
||||
# Look for any acclient code-range vtable in -56..-4
|
||||
candidate_offs = list(range(-56, 0, 4))
|
||||
found = None
|
||||
for d in candidate_offs:
|
||||
if off + d < 0: continue
|
||||
v = struct.unpack_from("<I", data, off + d)[0]
|
||||
if ACMIN <= v <= ACMAX:
|
||||
# check vtable plausibility — slot 0 should point to a code-page
|
||||
# we can't read .text from this script easily — keep just the vtable
|
||||
found = (d, v)
|
||||
break
|
||||
if found:
|
||||
return ('embedded', found)
|
||||
return ('solo', None)
|
||||
|
||||
|
||||
def scan(pid):
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
|
||||
|
||||
buckets = Counter()
|
||||
host_at_off = Counter() # (off, vtable) -> count
|
||||
total = 0
|
||||
|
||||
mbi = MBI(); addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
pos_offs = []
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
||||
pos_offs.append(off)
|
||||
pos_set = set(pos_offs)
|
||||
for off in pos_offs:
|
||||
total += 1
|
||||
bucket, ev = classify(data, off, pos_set)
|
||||
buckets[bucket] += 1
|
||||
if bucket == 'embedded':
|
||||
host_at_off[ev] += 1
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000:
|
||||
break
|
||||
|
||||
print(f"PID {pid}: {total} Position instances")
|
||||
for b, n in buckets.most_common():
|
||||
pct = 100.0 * n / total
|
||||
print(f" {b:>15}: {n:>7} ({pct:5.1f}%)")
|
||||
print()
|
||||
print("Top (offset, host-vtable) combos for 'embedded' bucket:")
|
||||
for (off, vt), n in host_at_off.most_common(20):
|
||||
pct = 100.0 * n / total
|
||||
print(f" off={off:+4d} vt=0x{vt:08x} count={n:>6} ({pct:.2f}%)")
|
||||
|
||||
k.CloseHandle(h)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(scan(int(sys.argv[1])))
|
||||
100
tools/position_sample_dump.py
Normal file
100
tools/position_sample_dump.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""position_sample_dump.py <pid> [count=10]
|
||||
|
||||
For a few Position hits, print the surrounding bytes raw so we can
|
||||
visually inspect the layout. Goal: see if there's a heap-block
|
||||
header pattern we missed.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import struct
|
||||
import sys
|
||||
|
||||
POSITION_VT = 0x00797910
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [('BaseAddress', ctypes.c_void_p),
|
||||
('AllocationBase', ctypes.c_void_p),
|
||||
('AllocationProtect', wt.DWORD),
|
||||
('PartitionId', wt.WORD),
|
||||
('RegionSize', ctypes.c_size_t),
|
||||
('State', wt.DWORD),
|
||||
('Protect', wt.DWORD),
|
||||
('Type', wt.DWORD)]
|
||||
|
||||
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
|
||||
def hexdump(addr, data, prefix=""):
|
||||
for i in range(0, len(data), 16):
|
||||
chunk = data[i:i+16]
|
||||
h = " ".join(f"{b:02x}" for b in chunk)
|
||||
a = "".join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
|
||||
dws = " ".join(f"{struct.unpack_from('<I', chunk, j)[0]:08x}"
|
||||
for j in range(0, min(16, len(chunk) - (len(chunk) % 4)), 4))
|
||||
print(f"{prefix}0x{addr+i:08x}: {h:<47} | {dws} | {a}")
|
||||
|
||||
|
||||
def main():
|
||||
pid = int(sys.argv[1])
|
||||
want = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
|
||||
|
||||
shown = 0
|
||||
mbi = MBI()
|
||||
addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)) and shown < want:
|
||||
pr = mbi.Protect & 0xff
|
||||
if (mbi.State == 0x1000 and mbi.Type == 0x20000
|
||||
and pr in (0x04, 0x40)):
|
||||
buf = (ctypes.c_ubyte * mbi.RegionSize)()
|
||||
sz = ctypes.c_size_t(0)
|
||||
if k.ReadProcessMemory(h, mbi.BaseAddress, buf,
|
||||
mbi.RegionSize, ctypes.byref(sz)):
|
||||
data = bytes(buf[:sz.value])
|
||||
end = (len(data) // 4) * 4
|
||||
# find positions in this region
|
||||
pos_offs = []
|
||||
for off in range(0, end, 4):
|
||||
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
|
||||
pos_offs.append(off)
|
||||
# sample first few
|
||||
for off in pos_offs[:3]:
|
||||
if shown >= want:
|
||||
break
|
||||
shown += 1
|
||||
va_pos = mbi.BaseAddress + off
|
||||
region_kb = mbi.RegionSize // 1024
|
||||
print(f"\n=== Position #{shown} @ VA 0x{va_pos:08x} "
|
||||
f"(region 0x{mbi.BaseAddress:08x} {region_kb}KB, "
|
||||
f"in-region offset 0x{off:x}, "
|
||||
f"positions-in-region={len(pos_offs)}) ===")
|
||||
# dump 64 bytes BEFORE position vt
|
||||
lo = max(0, off - 64)
|
||||
hexdump(mbi.BaseAddress + lo, data[lo:off], prefix=" before ")
|
||||
print(" POSITION:")
|
||||
hexdump(mbi.BaseAddress + off, data[off:off + 128], prefix=" pos+ctx ")
|
||||
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
|
||||
if addr >= 0x80000000 or shown >= want:
|
||||
break
|
||||
|
||||
k.CloseHandle(h)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
102
tools/probe_260k_allocation_structure.py
Normal file
102
tools/probe_260k_allocation_structure.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""probe_260k_allocation_structure.py <pid>
|
||||
For each 260KB private RW region, dump VirtualQuery details (AllocationBase,
|
||||
Protect, AllocationProtect, RegionSize) plus VirtualQuery for the pages
|
||||
immediately AFTER the region — to detect if it's inside a larger reservation."""
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
from collections import Counter
|
||||
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
k.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def vq(h, addr):
|
||||
mbi = MBI()
|
||||
if k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
return mbi
|
||||
return None
|
||||
|
||||
def prot_name(p):
|
||||
names = {0x01: "NA", 0x02: "RO", 0x04: "RW", 0x08: "WC", 0x10: "EX",
|
||||
0x20: "ER", 0x40: "ERW", 0x80: "ERWC"}
|
||||
return names.get(p & 0xFF, f"0x{p:x}")
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(0x410, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
candidates = []
|
||||
addr = 0
|
||||
while True:
|
||||
mbi = vq(h, addr)
|
||||
if not mbi: break
|
||||
base = mbi.BaseAddress or 0
|
||||
if mbi.State == 0x1000 and mbi.RegionSize == 266240 and (mbi.Type & 0x20000):
|
||||
if (mbi.Protect & 0xFF) in (0x04, 0x40):
|
||||
candidates.append(mbi.BaseAddress)
|
||||
next_addr = base + mbi.RegionSize
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
print(f"Found {len(candidates)} candidate 260KB regions")
|
||||
|
||||
# Statistics: base == alloc_base?
|
||||
self_alloc = 0
|
||||
in_larger = 0
|
||||
neighbor_stats = Counter()
|
||||
for b in candidates:
|
||||
mbi = vq(h, b)
|
||||
if not mbi: continue
|
||||
base = mbi.BaseAddress
|
||||
ab = mbi.AllocationBase
|
||||
if base == ab:
|
||||
self_alloc += 1
|
||||
else:
|
||||
in_larger += 1
|
||||
# Check neighbor immediately after the 260KB region
|
||||
after_addr = base + 266240
|
||||
nbr = vq(h, after_addr)
|
||||
if nbr:
|
||||
same_alloc = (nbr.AllocationBase == ab)
|
||||
nbr_state = "COMMIT" if nbr.State == 0x1000 else ("RESERVE" if nbr.State == 0x2000 else "FREE")
|
||||
key = f"same_alloc={same_alloc} state={nbr_state}"
|
||||
neighbor_stats[key] += 1
|
||||
|
||||
print(f"\nself_alloc (base == AllocationBase): {self_alloc}")
|
||||
print(f"in_larger (base != AllocationBase): {in_larger}")
|
||||
print(f"\nNeighbor (page immediately after 260KB region):")
|
||||
for key, n in sorted(neighbor_stats.items(), key=lambda x: -x[1]):
|
||||
print(f" {key}: {n}")
|
||||
|
||||
# Dump first 8 in detail
|
||||
print(f"\nDetailed dump of first 8 regions:")
|
||||
for b in candidates[:8]:
|
||||
mbi = vq(h, b)
|
||||
if not mbi: continue
|
||||
print(f"\n Region @0x{b:08x}:")
|
||||
print(f" BaseAddress = 0x{mbi.BaseAddress:08x}")
|
||||
print(f" AllocationBase = 0x{mbi.AllocationBase:08x} delta={(mbi.BaseAddress - mbi.AllocationBase):d}")
|
||||
print(f" RegionSize = {mbi.RegionSize}")
|
||||
print(f" Protect = {prot_name(mbi.Protect)}")
|
||||
print(f" AllocProtect = {prot_name(mbi.AllocationProtect)}")
|
||||
print(f" State = 0x{mbi.State:x}")
|
||||
print(f" Type = 0x{mbi.Type:x}")
|
||||
# Look at AllocationBase's full size
|
||||
if mbi.BaseAddress != mbi.AllocationBase:
|
||||
ab_mbi = vq(h, mbi.AllocationBase)
|
||||
if ab_mbi:
|
||||
print(f" --- AllocationBase region: ---")
|
||||
print(f" base=0x{ab_mbi.BaseAddress:08x} size={ab_mbi.RegionSize} prot={prot_name(ab_mbi.Protect)} type=0x{ab_mbi.Type:x}")
|
||||
# Look at neighbor after region
|
||||
nbr = vq(h, b + 266240)
|
||||
if nbr:
|
||||
same = (nbr.AllocationBase == mbi.AllocationBase)
|
||||
print(f" --- Neighbor after: 0x{nbr.BaseAddress:08x} size={nbr.RegionSize} prot={prot_name(nbr.Protect)} state=0x{nbr.State:x} same_alloc={same}")
|
||||
|
||||
k.CloseHandle(h)
|
||||
175
tools/probe_260k_holders.py
Normal file
175
tools/probe_260k_holders.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""probe_260k_holders.py <pid>
|
||||
Walk all 260KB private-RW regions in target process. For each, scan first
|
||||
few hundred bytes (the "header") and check:
|
||||
- First DWORD: is it a pointer into d3d9.dll's image range? (= a vtable
|
||||
pointer for a d3d9-managed object)
|
||||
- First 32 DWORDs: any pointers back into the process's heap?
|
||||
- Find any pointer in the process's heap that points to this region's base.
|
||||
|
||||
Goal: determine whether the 260KB blocks are
|
||||
(a) live d3d9-managed objects (have a d3d9 vtable at offset 0)
|
||||
(b) raw backing buffers with no AC-side holder pointers
|
||||
(c) AC-held buffers with at least one pointer from AC's heap"""
|
||||
|
||||
import ctypes, ctypes.wintypes as wt, sys, struct
|
||||
|
||||
PROCESS_VM_READ = 0x10
|
||||
PROCESS_QUERY_INFORMATION = 0x400
|
||||
k = ctypes.windll.kernel32
|
||||
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
|
||||
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.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
||||
k.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
|
||||
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
def rd(h, va, n):
|
||||
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
|
||||
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
|
||||
return bytes(buf[:sz.value])
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h: print("OpenProcess fail"); sys.exit(2)
|
||||
|
||||
# Build a list of all committed RW regions and their (base, size, type).
|
||||
regions = []
|
||||
mbi = MBI(); addr = 0
|
||||
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
base = mbi.BaseAddress or 0
|
||||
sz = mbi.RegionSize
|
||||
if mbi.State == 0x1000 and (mbi.Protect & 0xFF) in (0x04, 0x40):
|
||||
regions.append((base, sz, mbi.Type, mbi.Protect))
|
||||
next_addr = base + sz
|
||||
if next_addr <= addr: break
|
||||
addr = next_addr
|
||||
if addr >= 0x80000000: break
|
||||
|
||||
# Find image ranges for d3d9.dll via PSAPI
|
||||
psapi = ctypes.windll.psapi
|
||||
psapi.EnumProcessModulesEx.argtypes = [wt.HANDLE, ctypes.POINTER(wt.HMODULE),
|
||||
wt.DWORD, ctypes.POINTER(wt.DWORD), wt.DWORD]
|
||||
psapi.EnumProcessModulesEx.restype = wt.BOOL
|
||||
psapi.GetModuleFileNameExA.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.c_char_p, wt.DWORD]
|
||||
psapi.GetModuleFileNameExA.restype = wt.DWORD
|
||||
|
||||
class MODULEINFO(ctypes.Structure):
|
||||
_fields_ = [("lpBaseOfDll", ctypes.c_void_p),
|
||||
("SizeOfImage", wt.DWORD),
|
||||
("EntryPoint", ctypes.c_void_p)]
|
||||
psapi.GetModuleInformation.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.POINTER(MODULEINFO), wt.DWORD]
|
||||
psapi.GetModuleInformation.restype = wt.BOOL
|
||||
|
||||
# Need a handle with QUERY + READ for psapi. Re-open with extra rights.
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
h2 = k.OpenProcess(0x410, False, pid) # PROCESS_VM_READ | PROCESS_QUERY_INFORMATION
|
||||
if not h2: h2 = h
|
||||
|
||||
d3d9_lo, d3d9_hi = 0, 0
|
||||
ac_image_lo, ac_image_hi = 0, 0
|
||||
needed = wt.DWORD(0)
|
||||
hmods = (wt.HMODULE * 1024)()
|
||||
if psapi.EnumProcessModulesEx(h2, hmods, ctypes.sizeof(hmods), ctypes.byref(needed), 0x03):
|
||||
n = needed.value // ctypes.sizeof(wt.HMODULE)
|
||||
name = ctypes.create_string_buffer(260)
|
||||
info = MODULEINFO()
|
||||
for i in range(n):
|
||||
psapi.GetModuleFileNameExA(h2, hmods[i], name, 260)
|
||||
nm = name.value.decode(errors='replace').lower()
|
||||
if not (psapi.GetModuleInformation(h2, hmods[i], ctypes.byref(info), ctypes.sizeof(info))):
|
||||
continue
|
||||
base = info.lpBaseOfDll or 0
|
||||
sz = info.SizeOfImage
|
||||
if 'd3d9' in nm:
|
||||
d3d9_lo = base; d3d9_hi = base + sz
|
||||
elif nm.endswith('\\acclient.exe') or nm.endswith('/acclient.exe'):
|
||||
ac_image_lo = base; ac_image_hi = base + sz
|
||||
else:
|
||||
print(f"EnumProcessModulesEx failed err={ctypes.GetLastError()}", file=sys.stderr)
|
||||
|
||||
print(f"d3d9.dll range: 0x{d3d9_lo:08x} - 0x{d3d9_hi:08x}")
|
||||
print(f"acclient image: 0x{ac_image_lo:08x} - 0x{ac_image_hi:08x}")
|
||||
|
||||
# Find the 260KB-sized regions (size = 266240 bytes exactly, or "near 260KB").
|
||||
TARGET_SIZE = 266240 # 256K + 4K
|
||||
candidates = [(b, s) for (b, s, t, p) in regions if s == TARGET_SIZE and (t & 0x20000)]
|
||||
print(f"\nFound {len(candidates)} regions of exactly {TARGET_SIZE} bytes (260KB) "
|
||||
f"in private RW.\n")
|
||||
|
||||
# For up to N candidates, classify
|
||||
N = min(20, len(candidates))
|
||||
print(f"Sampling first {N} for content + holder counts:")
|
||||
|
||||
# Pre-flatten all heap RW regions to a list of (base, data) for the holder-scan.
|
||||
# That's expensive. Limit total bytes to 200 MB so we don't OOM.
|
||||
print(" - loading heap regions for holder-scan (capped at 200 MB)...")
|
||||
total_loaded = 0
|
||||
heap_blob = []
|
||||
MAX_BYTES = 200 * 1024 * 1024
|
||||
for (b, s, t, p) in regions:
|
||||
if total_loaded + s > MAX_BYTES: break
|
||||
if s > 64 * 1024 * 1024: continue # skip huge regions
|
||||
if not (t & 0x20000): continue
|
||||
data = rd(h, b, s)
|
||||
if data is not None:
|
||||
heap_blob.append((b, data))
|
||||
total_loaded += s
|
||||
print(f" loaded {total_loaded/1024/1024:.1f} MB across {len(heap_blob)} regions")
|
||||
|
||||
def count_holders(target_va, max_count=5):
|
||||
"""Return list of (holder_va, surrounding_hex) where target_va appears
|
||||
as a DWORD-aligned pointer in heap memory."""
|
||||
tb = struct.pack('<I', target_va)
|
||||
hits = []
|
||||
for (base, data) in heap_blob:
|
||||
off = 0
|
||||
while True:
|
||||
off = data.find(tb, off)
|
||||
if off < 0: break
|
||||
if (off & 3) == 0:
|
||||
hits.append((base + off, data[max(0, off-8):off+8].hex(' ')))
|
||||
if len(hits) >= max_count: return hits
|
||||
off += 4
|
||||
return hits
|
||||
|
||||
vtable_d3d9 = 0
|
||||
vtable_other = 0
|
||||
no_vtable_at_0 = 0
|
||||
holder_counts = []
|
||||
|
||||
for (b, s) in candidates[:N]:
|
||||
head = rd(h, b, 32)
|
||||
if head is None:
|
||||
print(f" 0x{b:08x}: rd fail")
|
||||
continue
|
||||
first = struct.unpack('<I', head[:4])[0]
|
||||
is_d3d9_vt = d3d9_lo <= first < d3d9_hi
|
||||
is_ac_vt = ac_image_lo <= first < ac_image_hi
|
||||
if is_d3d9_vt: vtable_d3d9 += 1
|
||||
elif is_ac_vt or first != 0: vtable_other += 1
|
||||
else: no_vtable_at_0 += 1
|
||||
|
||||
holders = count_holders(b, max_count=3)
|
||||
holder_counts.append(len(holders))
|
||||
cat = "d3d9-vtable" if is_d3d9_vt else ("ac-vtable" if is_ac_vt else (
|
||||
"non-zero" if first != 0 else "zero"))
|
||||
print(f" 0x{b:08x}: first_dword=0x{first:08x} ({cat}) "
|
||||
f"holders={len(holders)}", end='')
|
||||
for hv, ctx in holders[:1]:
|
||||
print(f" ex@0x{hv:08x}", end='')
|
||||
print()
|
||||
|
||||
print(f"\nSummary of {N} sampled 260KB regions:")
|
||||
print(f" with d3d9-vtable at offset 0: {vtable_d3d9}")
|
||||
print(f" with other non-zero at offset 0: {vtable_other}")
|
||||
print(f" with zero at offset 0: {no_vtable_at_0}")
|
||||
total_holders = sum(holder_counts)
|
||||
print(f" total pointers TO these regions from heap: {total_holders}")
|
||||
print(f" regions with >=1 holder: {sum(1 for c in holder_counts if c > 0)}")
|
||||
print(f" regions with 0 holders: {sum(1 for c in holder_counts if c == 0)}")
|
||||
|
||||
k.CloseHandle(h)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue