Initial commit — leak-hunt project complete

Five bugs identified and patched in retail Asheron's Call client:
- v3b: palette refcount over-increment (3-byte NOP at two sites)
- v5: RenderSurface PurgeResource no-op stub (vtable slot 2 thunk)
- v11: two dangling-pointer crash guards (NULL-check + reorder)
- v14: CEnvCell::Destroy ClipPlaneList leak (18-byte JMP to cleanup thunk)
- v22: unpacker stale-pointer SEH guard (whole-function __try/__except)

All five ship in leakfix.dll (117 KB, SHA d282f23c…) which is loaded
by acclient.exe at process start via PE import table patching by
tools/install_leakfix.py.

Controlled 15-client fleet soak: unpatched control died at 26h with
palette exhaustion; all 14 patched clients survived past that point
and reached ≥5-day uptime.

Residual ~15 MB/h growth traced to d3d9.dll's internal slab allocator
(260KB surface backing buffers retained after Release). See REPORT.md
§10 for the full investigation; conclusion is that it's unfixable from
outside d3d9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
acbot 2026-05-23 21:05:17 +02:00
commit 57b5e43d0e
199 changed files with 1648333 additions and 0 deletions

View file

@ -0,0 +1,195 @@
// patches.cpp — apply v3b, v5, v11, v12, v14 inline to our own process
#include "patches.h"
#include "logging.h"
#include "thunks.h"
#include "ac_addrs.h"
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <cstdio>
using namespace leakfix;
namespace {
// Copy `data` to absolute address `addr`, flipping page protection.
bool write_memory(uintptr_t addr, const void* data, size_t len) {
DWORD old = 0;
if (!VirtualProtect((void*)addr, len, PAGE_EXECUTE_READWRITE, &old)) {
logf(" VirtualProtect(0x%08x, %u) failed err=%lu", addr, (unsigned)len, GetLastError());
return false;
}
std::memcpy((void*)addr, data, len);
DWORD restored = 0;
VirtualProtect((void*)addr, len, old, &restored);
FlushInstructionCache(GetCurrentProcess(), (void*)addr, len);
return true;
}
bool bytes_equal(uintptr_t addr, const void* expected, size_t len) {
return std::memcmp((void*)addr, expected, len) == 0;
}
void hexdump_short(uintptr_t addr, size_t n, char* out, size_t out_sz) {
const uint8_t* p = (const uint8_t*)addr;
size_t used = 0;
for (size_t i = 0; i < n && used + 3 < out_sz; ++i) {
used += (size_t)std::snprintf(out + used, out_sz - used, "%02x", p[i]);
}
}
// Write a 5-byte JMP rel32 at `at` targeting `target`. Pad remaining bytes
// up to `total_replace` with 0x90 NOPs.
bool write_jmp_rel32(uintptr_t at, uintptr_t target, size_t total_replace) {
uint8_t buf[64];
if (total_replace > sizeof(buf)) return false;
int32_t rel = (int32_t)(target - (at + 5));
buf[0] = 0xE9;
std::memcpy(buf + 1, &rel, 4);
std::memset(buf + 5, 0x90, total_replace - 5);
return write_memory(at, buf, total_replace);
}
} // anon namespace
namespace leakfix {
// ===== v3b =====
bool apply_v3b() {
const uint8_t nops[3] = { 0x90, 0x90, 0x90 };
const uint8_t orig1[3] = { 0xff, 0x40, 0x24 }; // inc dword [eax+0x24]
const uint8_t orig2[3] = { 0xff, 0x46, 0x24 }; // inc dword [esi+0x24]
if (bytes_equal(ac::V3B_SITE_1, nops, 3) && bytes_equal(ac::V3B_SITE_2, nops, 3)) {
logf("v3b: already applied");
return true;
}
if (!bytes_equal(ac::V3B_SITE_1, orig1, 3)) {
char h[16]; hexdump_short(ac::V3B_SITE_1, 3, h, sizeof(h));
logf("v3b: site1 unexpected bytes %s — refusing", h);
return false;
}
if (!bytes_equal(ac::V3B_SITE_2, orig2, 3)) {
char h[16]; hexdump_short(ac::V3B_SITE_2, 3, h, sizeof(h));
logf("v3b: site2 unexpected bytes %s — refusing", h);
return false;
}
write_memory(ac::V3B_SITE_1, nops, 3);
write_memory(ac::V3B_SITE_2, nops, 3);
logf("v3b: applied (NOPs at 0x%08x + 0x%08x)", ac::V3B_SITE_1, ac::V3B_SITE_2);
return true;
}
// ===== v5 =====
bool apply_v5() {
uintptr_t rs_cur = *(uintptr_t*)ac::V5_RS_VTABLE_SLOT_2;
uintptr_t rt_cur = *(uintptr_t*)ac::V5_RT_VTABLE_SLOT_2;
uintptr_t rs_new = (uintptr_t)&purge_rendersurface_thunk;
uintptr_t rt_new = (uintptr_t)&purge_rendertexture_thunk;
bool rs_done = (rs_cur != ac::V5_NOOP_STUB_VA);
bool rt_done = (rt_cur != ac::V5_NOOP_STUB_VA);
if (!rs_done) {
if (rs_cur != ac::V5_NOOP_STUB_VA) {
logf("v5: RS slot already redirected (0x%08x); not overwriting", rs_cur);
} else {
write_memory(ac::V5_RS_VTABLE_SLOT_2, &rs_new, 4);
logf("v5: RS vtable slot -> 0x%08x", rs_new);
}
} else {
logf("v5: RS slot already non-default (0x%08x) — skipping", rs_cur);
}
if (!rt_done) {
write_memory(ac::V5_RT_VTABLE_SLOT_2, &rt_new, 4);
logf("v5: RT vtable slot -> 0x%08x", rt_new);
} else {
logf("v5: RT slot already non-default (0x%08x) — skipping", rt_cur);
}
return true;
}
// ===== v11 =====
bool apply_v11() {
// Site 1: 2-byte rewrite of a JMP target
const uint8_t s1_orig[2] = { 0xEB, 0x07 };
const uint8_t s1_patched[2] = { 0xEB, 0x42 };
// Site 2: 9-byte rewrite for ~GXTri3Mesh NULL-check
const uint8_t s2_orig[9] = { 0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08 };
const uint8_t s2_patched[9] = { 0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
if (bytes_equal(ac::V11_SITE_1_VA, s1_patched, 2)) {
logf("v11: site1 already patched");
} else if (bytes_equal(ac::V11_SITE_1_VA, s1_orig, 2)) {
write_memory(ac::V11_SITE_1_VA, s1_patched, 2);
logf("v11: site1 patched");
} else {
char h[8]; hexdump_short(ac::V11_SITE_1_VA, 2, h, sizeof(h));
logf("v11: site1 unexpected %s — skipping", h);
}
if (bytes_equal(ac::V11_SITE_2_VA, s2_patched, 9)) {
logf("v11: site2 already patched");
} else if (bytes_equal(ac::V11_SITE_2_VA, s2_orig, 9)) {
write_memory(ac::V11_SITE_2_VA, s2_patched, 9);
logf("v11: site2 patched");
} else {
char h[24]; hexdump_short(ac::V11_SITE_2_VA, 9, h, sizeof(h));
logf("v11: site2 unexpected %s — skipping", h);
}
return true;
}
// ===== v12 RETIRED =====
// v12 was designed against post-Decal in-memory bytes that don't match
// the on-disk binary. When the leakfix.dll loads at PE-import time (before
// Decal init), it sees the truly-original bytes and v12 would refuse.
// When the Python patcher ran later against a running PID, it saw
// Decal-modified bytes that happened to match its expected pattern and
// applied a duplicate range check — adding no protection beyond what
// Decal already provides. Neither variant prevented the Shadow/Frank
// stale-heap-pointer crashes. v12 removed.
// ===== v14 =====
bool apply_v14() {
static const uint8_t ORIG[18] = {
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, // mov eax, [esi+0xDC]
0x3B, 0xC3, // cmp eax, ebx
0x74, 0x08, // jz +8
0x8B, 0x00, // mov eax, [eax]
0x3B, 0xC3, // cmp eax, ebx
0x74, 0x02, // jz +2
0x89, 0x18, // mov [eax], ebx <- the broken "fix"
};
// If already patched, the first byte is 0xE9 (our JMP).
uint8_t cur = *(uint8_t*)ac::V14_PATCH_SITE_VA;
if (cur == 0xE9) {
logf("v14: already applied");
return true;
}
if (!bytes_equal(ac::V14_PATCH_SITE_VA, ORIG, 18)) {
char h[40]; hexdump_short(ac::V14_PATCH_SITE_VA, 18, h, sizeof(h));
logf("v14: site unexpected bytes %s — refusing", h);
return false;
}
uintptr_t thunk_va = (uintptr_t)&v14_clipplane_cleanup_thunk;
if (!write_jmp_rel32(ac::V14_PATCH_SITE_VA, thunk_va, 18)) return false;
logf("v14: applied (JMP rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
return true;
}
bool apply_all_patches() {
bool ok = true;
ok &= apply_v3b();
ok &= apply_v11();
ok &= apply_v5();
ok &= apply_v14();
logf("all-patches result: %s", ok ? "OK" : "PARTIAL");
return ok;
}
} // namespace leakfix