Fix cross-thread use-after-free on WorldPresentation instance teardown

The intermittent map-load crash is heap corruption, not a null pointer.
It surfaces as a store through NULL in sub_8298CEF8 (intrusive list
unlink) reached from sub_82990768 +0x1D8, the guest small-block pool
allocator. Decoded with --protect_zero=true:

  sub_82990768  reads the chunk's free-block list head [chunk+8] and
                gets 0xFFFFFFFF, treats it as a free block, and rounds
                it down to a chunk header:
                ((0xFFFFFFFF+0x7FFF) & 0xFFFF8000) - 0x8000 = 0xFFFF8000
  sub_8298CEF8  node->next/prev at that address read as 0, so
                "next->prev = prev" stores through NULL

So the null store is three steps downstream. The defect is that
0xFFFFFFFF was in the free list at all.

Root cause, caught in the act by a store watch over the pool arena:

  WRITE INTO A FREE BLOCK: guest 0x40D22A6C, 2 bytes, thread "Job Manager"
    = +12 of free block 0x40D22A60 (size 64)
  writer:    sub_82816A40 <- sub_82817138 <- sub_82812A20
             <- sub_8290AFA0 <- sub_8290B750 <- sub_82A412B8
  freed by:  sub_82792040 <- sub_82791FD8 <- sub_82791500 (render_thread)

sub_82791CF8 (under Sk8::WorldPresentation::AddRenderInstance) allocates
two u16 arrays at self+496/+500 and publishes INTERIOR pointers into
them - &A[idx], &B[idx] - into other objects' +64/+68. A job work item
captures one at its +12 and walks it writing 4x u16 records. When
sub_82792040 frees those arrays on render_thread, the job keeps writing
into freed memory. If the block is on the pool's free list, the write
lands on its link word, which is how 0xFFFFFFFF gets there.

The fix, skate3_instance_free_defer_ms (default 0 = off), holds the
arrays freed by that teardown for N ms so a late job write lands on
memory the allocator has not reused. It is bounded by a 512 KB cap, so
a long job latency cannot starve the pool. This is a mitigation: the
root fix is draining or cancelling the outstanding jobs before teardown,
which needs a job-manager drain primitive I could not identify.

Also adds the diagnostics that found it, all default-off: a pool
integrity checker (per-alloc sweep of all 20 size-class bins, poison and
verify of freed blocks, and a check of the link word carried out of a
popped block), and the store watch's host side. The watch needs a
REX_STORE_* hook in the generated header, which is regenerated by
codegen and so is not included here.

Validation is honest but incomplete: 28 runs / 865 map-load events with
the fix on produced no corruption and no crash, but the corruption
occurred only ~2-3 times in ~3900 map-load events without it, so a clean
arm this size has close to even odds by chance. Longer soaks needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
andrewnakas 2026-08-12 10:59:09 -06:00
parent f6e0ae87fd
commit ace55528f3
2 changed files with 927 additions and 0 deletions

View file

@ -245,6 +245,7 @@ set(SKATE3_COMMON_SOURCES
src/skate3_demo_path.cpp
src/skate3_draw_distance.cpp
src/skate3_fov.cpp
src/skate3_heap_check.cpp
src/skate3_iso_installer.cpp
src/native/skate3_native_diagnostics.cpp
src/native/skate3_native_entity.cpp

926
src/skate3_heap_check.cpp Normal file
View file

@ -0,0 +1,926 @@
// Integrity checker for the guest's small-block pool allocator.
//
// The remaining map-load crash is heap corruption, not a null pointer. A
// --protect_zero=true run caught a WRITE to guest 0x00000004 on render_thread
// with lr = 0x82990940 (sub_82990768 +0x1D8). Decoded:
//
// sub_82990768 (pool alloc) read the current chunk's free-block list head
// [chunk+8] and got 0xFFFFFFFF
// -> treated it as a free block, rounded it down
// to its 32 KiB chunk header:
// ((0xFFFFFFFF+0x7FFF) & 0xFFFF8000) - 0x8000
// = 0xFFFF8000
// sub_8298CEF8 (list unlink) node->next and node->prev at 0xFFFF8000 read as
// 0, so "next->prev = prev" stored through null.
//
// The null store is three steps downstream of the defect. The defect is that
// 0xFFFFFFFF was in the free list at all - something wrote -1 into a block that
// was sitting free, or into the chunk header itself. By the time the allocator
// trips over it the writer is long gone, so this file catches the corruption at
// the earliest moment it is observable and records enough to name the writer:
//
// 1. Every alloc sweeps all 20 size-class bins and checks each bin's head
// chunk's free-list head. A legitimate value is 0 or a block inside that
// chunk; anything else is definitive, no lock needed. This fires one step
// BEFORE the crash, while the chunk pointer is still in hand.
// 2. Every free poisons the block it is about to release (past word 0, which
// the allocator uses for the free-list link) and records who freed it.
// Every alloc verifies the poison of the block it hands back. A block that
// changed while it was free is a use-after-free, and the recorded free-site
// lr names the subsystem that owned it.
//
// Off by default: it costs a 40-load sweep plus a block-sized fill per pool op.
//
// Layout below was decoded from generated/skate3_recomp.53.cpp (sub_82990768,
// sub_82990A58, sub_8298CEF8) - do not re-derive it.
//
// pool +8 critical section (the allocator's own lock)
// +56/+60 free-chunk count / free-chunk list
// +64 + bin*8 bin chunk list {head, tail} bins 0..19
// +224/+228 arena [lo, hi) (the guest's own "is this mine" test)
// +232 + bin*4 per-bin counters
// +312/+316 live chunk count / high-water
//
// chunk +0/+4 next/prev (+4 is stamped 0xDEC6FBAD when the whole
// chunk is released)
// +8 free-block list head <- the word that was corrupt
// +12/+14/+16 u16 block size / blocks per chunk / blocks in use
// +20 first block (= chunk+32)
// +32... blocks; a free block holds its "next" in word 0 only
//
// Chunks are 32 KiB aligned and a block's chunk is
// ((block + 32767) & 0xFFFF8000) - 32768.
#include "generated/skate3_init.h"
#include <execinfo.h>
#include <signal.h>
#include <sys/prctl.h>
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <chrono>
#include <deque>
#include <mutex>
#include <unordered_map>
#include <vector>
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/logging/api.h>
REXCVAR_DEFINE_BOOL(skate3_heap_check, false, "Skate 3",
"Validate the guest small-block pool on every alloc/free "
"and report the first corrupt free list")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(skate3_heap_check_poison, true, "Skate 3",
"Also poison freed pool blocks and verify them on reuse "
"(catches use-after-free writes; costs a fill per free)")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(skate3_heap_check_abort, true, "Skate 3",
"Abort on the first heap-check failure so the crash "
"reporter dumps the guest state at the point of detection")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_INT32(skate3_instance_free_defer_ms, 0, "Skate 3",
"CANDIDATE FIX for the cross-thread use-after-free: hold "
"the u16 arrays freed by the WorldPresentation instance "
"teardown for this many ms, so a Job Manager worker still "
"walking an interior pointer into them writes to memory "
"nobody has reused (0 = off)")
.range(0, 5000)
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_INT32(skate3_heap_quarantine_kb, 0, "Skate 3",
"Hold this many KB of freed pool blocks back from the "
"allocator, so a stale write has a still-free block to "
"land on for the watch to catch (0 = off)")
.range(0, 2048)
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(skate3_heap_watch_selftest, false, "Skate 3",
"Prove the store watch works: mark one freshly allocated "
"block as free and expect its initialisation to trip it")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(skate3_heap_watch, false, "Skate 3",
"Trap the guest store that writes into a freed pool block. "
"Needs the REX_STORE_* watch hook in skate3_init.h")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
// Store watch, armed over the whole pool arena with a shadow bit per 16-byte
// granule saying "this granule is inside a block that is currently FREE".
// Every guest store checks it (see REX_WATCH_CHECK in generated/skate3_init.h),
// so the writer is caught in the act instead of being inferred from a poison
// diff hundreds of thousands of pool ops later.
//
// Nothing legitimate writes to a free block: the allocator only writes word 0
// when it PUSHES a block (before we mark it) and when it re-carves a released
// chunk (we clear the whole chunk's bits then). Any hit is the bug.
extern "C" {
uint32_t g_skate3_watch_lo = 0;
uint32_t g_skate3_watch_len = 0; // 0 disarms the check
uint8_t* g_skate3_watch_bits = nullptr;
}
thread_local bool tl_in_instance_dtor = false;
namespace {
constexpr uint32_t kBins = 20;
constexpr uint32_t kBinList = 64; // pool + kBinList + bin*8 = {head, tail}
constexpr uint32_t kArenaLo = 224;
constexpr uint32_t kArenaHi = 228;
constexpr uint32_t kChunkSize = 32768;
constexpr uint32_t kChunkData = 32; // first block offset within a chunk
constexpr uint32_t kChunkFreeHead = 8;
constexpr uint32_t kChunkBlockSize = 12; // u16
constexpr uint32_t kChunkCapacity = 14; // u16
constexpr uint32_t kChunkUsed = 16; // u16
// Freed-block fill. Not a valid guest pointer, so anything that follows a
// poisoned link faults loudly instead of wandering.
constexpr uint8_t kPoison = 0xCD;
constexpr int kFreeFrames = 12;
struct FreeRecord {
uint32_t lr = 0; // guest caller that freed the block
uint32_t block_size = 0; // size class at the time, to spot recycled chunks
uint64_t seq = 0; // pool-op counter, for "how long ago"
uint64_t epoch = 0; // chunk generation, see g_epoch
char thread[16] = {0};
// Host frames of the free. The generated code compiles one host function per
// guest function, so these ARE the guest call chain - and unlike lr (which
// only ever names the allocator's own wrapper) they reach the game code that
// owned the block.
void* frames[kFreeFrames] = {nullptr};
int frame_count = 0;
};
std::mutex g_mutex;
std::unordered_map<uint32_t, FreeRecord> g_freed;
// A chunk whose last block is released goes back on the pool's free-chunk list
// and can be re-carved at a different size class, so every block address in it
// may become a different object - or the interior of one. Bumping a generation
// counter there and matching it on verify is what keeps that churn from being
// misread as a use-after-free.
std::unordered_map<uint32_t, uint64_t> g_epoch;
std::atomic<uint64_t> g_seq{0};
std::atomic<uint64_t> g_allocs{0};
std::atomic<uint64_t> g_frees{0};
std::atomic<int> g_reports{0};
std::atomic<int> g_modulus_warns{0};
struct QuarantinedBlock {
uint32_t pool;
uint32_t block;
uint32_t size;
};
std::mutex g_quarantine_mutex;
std::deque<QuarantinedBlock> g_quarantine;
uint64_t g_quarantine_bytes = 0;
uint64_t QuarantineBytes() {
static const uint64_t bytes =
uint64_t(REXCVAR_GET(skate3_heap_quarantine_kb)) * 1024;
return bytes;
}
// Blocks freed by Sk8::WorldPresentation's instance teardown (sub_82792040)
// are the ones that matter: it frees two u16 arrays whose INTERIOR pointers
// (&A[idx]) sub_82791CF8 published into other objects, so a stale holder
// writing its u16 "invalid" markers lands exactly here. Giving them their own
// quarantine means the budget is not spent on unrelated pool traffic, and they
// stay unreusable for minutes rather than a second.
std::deque<QuarantinedBlock> g_dtor_quarantine;
uint64_t g_dtor_quarantine_bytes = 0;
std::atomic<uint64_t> g_dtor_blocks{0};
bool Enabled() {
static const bool on = REXCVAR_GET(skate3_heap_check);
return on;
}
uint64_t DeferMs() {
static const uint64_t ms = uint64_t(REXCVAR_GET(skate3_instance_free_defer_ms));
return ms;
}
// The fix must be usable on its own: the diagnostic half (poison, watch, bin
// sweep) costs far too much to leave on in normal play.
bool HooksActive() { return Enabled() || DeferMs() != 0; }
uint64_t NowMs() {
return uint64_t(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count());
}
struct DeferredBlock {
uint32_t pool;
uint32_t block;
uint32_t size;
uint64_t due_ms;
};
std::mutex g_defer_mutex;
std::deque<DeferredBlock> g_defer;
uint64_t g_defer_bytes = 0;
// Safety valve: never hold more than this, however short the deferral turns out
// to be relative to job latency. A starved pool is a worse failure than the bug.
constexpr uint64_t kDeferMaxBytes = 512 * 1024;
bool PoisonEnabled() {
static const bool on = REXCVAR_GET(skate3_heap_check_poison);
return on;
}
bool AbortOnFailure() {
static const bool on = REXCVAR_GET(skate3_heap_check_abort);
return on;
}
uint32_t ChunkOf(uint32_t block) {
// Exactly the guest's arithmetic, wraparound included.
return ((block + 32767u) & 0xFFFF8000u) - 32768u;
}
const char* ThreadName() {
static thread_local char name[20] = {0};
if (name[0] == 0 &&
prctl(PR_GET_NAME, reinterpret_cast<unsigned long>(name), 0, 0, 0) != 0) {
name[0] = '?';
name[1] = 0;
}
return name;
}
// The pool object is static data that exists before it is initialised; treat an
// implausible arena as "not ready" rather than as a failure.
bool PoolArena(uint8_t* base, uint32_t pool, uint32_t* lo, uint32_t* hi) {
if (pool < 0x10000) {
return false;
}
*lo = REX_LOAD_U32(pool + kArenaLo);
*hi = REX_LOAD_U32(pool + kArenaHi);
return *lo >= 0x10000 && *hi > *lo;
}
void LogFrames(const char* what, void* const* frames, int n) {
char** syms = ::backtrace_symbols(const_cast<void* const*>(frames), n);
for (int i = 0; i < n; ++i) {
REXLOG_ERROR("heap-check: {} #{} {}", what, i,
syms != nullptr ? syms[i] : "<no symbol>");
}
::free(syms);
}
void LogHostBacktrace() {
void* frames[24];
const int n = ::backtrace(frames, 24);
LogFrames("now", frames, n);
}
// 64 bytes of the block around an offset, raw guest bytes. The shape of the
// write is the tell: one pointer-sized field says a stale link was updated, a
// contiguous run says a whole object was written through a dangling this.
void DumpBlock(uint8_t* base, uint32_t block, uint32_t size, uint32_t around) {
const uint32_t start = (around >= 32 ? around - 32 : 0) & ~15u;
const uint32_t end = std::min(start + 64, size);
for (uint32_t off = start; off < end; off += 16) {
const uint8_t* p = base + block + off;
const uint32_t n = std::min(16u, end - off);
char hex[64] = {0};
for (uint32_t i = 0; i < n; ++i) {
std::snprintf(hex + i * 3, 4, "%02X ", p[i]);
}
REXLOG_ERROR("heap-check: +{:<4} {}", off, hex);
}
}
void DumpChunk(uint8_t* base, uint32_t chunk) {
REXLOG_ERROR(
"heap-check: chunk 0x{:08X}: next=0x{:08X} prev=0x{:08X} "
"freehead=0x{:08X} bsize={} cap={} used={} first=0x{:08X}",
chunk, REX_LOAD_U32(chunk + 0), REX_LOAD_U32(chunk + 4),
REX_LOAD_U32(chunk + kChunkFreeHead), REX_LOAD_U16(chunk + kChunkBlockSize),
REX_LOAD_U16(chunk + kChunkCapacity), REX_LOAD_U16(chunk + kChunkUsed),
REX_LOAD_U32(chunk + 20));
}
// One failure is all we need; the state is freshest at the first one.
void Fail(uint8_t* base, uint32_t pool, uint32_t chunk, uint32_t guest_lr) {
if (g_reports.fetch_add(1) != 0) {
return;
}
REXLOG_ERROR(
"heap-check: pool=0x{:08X} arena=[0x{:08X},0x{:08X}) thread={} "
"guest lr=0x{:08X} after {} allocs / {} frees",
pool, REX_LOAD_U32(pool + kArenaLo), REX_LOAD_U32(pool + kArenaHi),
ThreadName(), guest_lr, g_allocs.load(), g_frees.load());
if (chunk != 0) {
DumpChunk(base, chunk);
}
LogHostBacktrace();
rex::FlushLogging();
if (AbortOnFailure()) {
// SIGABRT: skate3_crash_report turns it into a full guest register dump in
// <log_file>.crash, at the point of DETECTION rather than four allocations
// later at the fault.
::raise(SIGABRT);
}
}
// A chunk's free-list head is either 0 or a block inside that chunk. Nothing
// else is legal, at any moment, with or without the pool lock held - which is
// what makes this safe to check from outside the guest's critical section.
//
// Returns false (and reports) when the value cannot be explained.
bool CheckChunkFreeHead(uint8_t* base, uint32_t pool, uint32_t chunk,
uint32_t guest_lr, const char* where) {
const uint32_t head = REX_LOAD_U32(chunk + kChunkFreeHead);
if (head == 0) {
return true;
}
const uint32_t lo = chunk + kChunkData;
const uint32_t hi = chunk + kChunkSize;
if (head >= lo && head < hi && (head & 3u) == 0) {
// Secondary signal only. The chunk's block size is rewritten a few stores
// after the chunk is linked into its bin, so a mid-init read can see a size
// from the chunk's previous life and fail the modulus while the pointer
// itself is perfectly valid. Worth knowing about, never worth aborting on.
const uint32_t bsize = REX_LOAD_U16(chunk + kChunkBlockSize);
if (bsize != 0 && (head - lo) % bsize != 0 &&
g_modulus_warns.fetch_add(1) < 4) {
REXLOG_WARN(
"heap-check: {} free head 0x{:08X} is not on a block boundary "
"(chunk 0x{:08X}, block size {}) - benign if it is a mid-init race",
where, head, chunk, bsize);
}
return true;
}
REXLOG_ERROR(
"heap-check: CORRUPT free list at {}: chunk 0x{:08X} free head is "
"0x{:08X}, which is outside [0x{:08X},0x{:08X}). This is the value the "
"allocator would have handed out as a block.",
where, chunk, head, lo, hi);
Fail(base, pool, chunk, guest_lr);
return false;
}
// The free-list link lives in word 0 of a free block, which is the one word the
// poison cannot cover (the allocator writes it). It is still checkable, one step
// removed: the value the allocator just popped out of a block IS the chunk's new
// free head, so a bad head right after an allocation names the block whose link
// word was clobbered - and that block's free record names who owned it.
void CheckPoppedLink(uint8_t* base, uint32_t pool, uint32_t chunk,
uint32_t block, uint32_t guest_lr) {
const uint32_t head = REX_LOAD_U32(chunk + kChunkFreeHead);
if (head == 0 || (head >= chunk + kChunkData && head < chunk + kChunkSize &&
(head & 3u) == 0)) {
return;
}
REXLOG_ERROR(
"heap-check: CORRUPT free-list LINK: block 0x{:08X} was handed out and "
"its link word carried 0x{:08X} into the chunk's free head - the next "
"allocation from chunk 0x{:08X} would have returned that as a block.",
block, head, chunk);
FreeRecord rec;
bool have = false;
{
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_freed.find(block);
if (it != g_freed.end()) {
rec = it->second;
have = true;
}
}
if (have) {
REXLOG_ERROR("heap-check: that block was freed on thread {} here:",
rec.thread);
LogFrames("freed-by", rec.frames, rec.frame_count);
}
Fail(base, pool, chunk, guest_lr);
}
// Sweep every bin's head chunk. That head is the chunk the next allocation of
// that size class will draw from, so this covers exactly the state the crash
// walked into.
void CheckAllBins(uint8_t* base, uint32_t pool, uint32_t guest_lr,
const char* where) {
uint32_t lo = 0, hi = 0;
if (!PoolArena(base, pool, &lo, &hi)) {
return;
}
for (uint32_t bin = 0; bin < kBins; ++bin) {
const uint32_t head = REX_LOAD_U32(pool + kBinList + bin * 8);
if (head == 0) {
continue;
}
if ((head & (kChunkSize - 1)) != 0 || head < lo || head >= hi) {
REXLOG_ERROR(
"heap-check: CORRUPT bin list at {}: bin {} head chunk is 0x{:08X} "
"(not 32 KiB aligned, or outside the arena [0x{:08X},0x{:08X}))",
where, bin, head, lo, hi);
Fail(base, pool, 0, guest_lr);
return;
}
if (!CheckChunkFreeHead(base, pool, head, guest_lr, where)) {
return;
}
}
}
// Periodic proof that the hooks are actually running. Absence of a failure
// report is not evidence of a healthy heap; absence of THIS line means the run
// never reached the allocator and says nothing at all.
void Liveness(uint64_t allocs) {
// The first one lands within seconds of boot and proves the override took;
// the rest are frequent enough that a 300 s run prints several, which is how
// the quarantines get verified as actually holding something.
if (allocs != 1 && allocs % 250'000 != 0) {
return;
}
size_t tracked = 0;
{
std::lock_guard<std::mutex> lock(g_mutex);
tracked = g_freed.size();
}
size_t q = 0, dq = 0;
uint64_t qb = 0, dqb = 0;
{
std::lock_guard<std::mutex> lock(g_quarantine_mutex);
q = g_quarantine.size();
qb = g_quarantine_bytes;
dq = g_dtor_quarantine.size();
dqb = g_dtor_quarantine_bytes;
}
REXLOG_INFO(
"heap-check: {} allocs / {} frees, {} tracked free; quarantine {} blocks "
"({} KB), instance-teardown quarantine {} blocks ({} KB) from {} teardown "
"frees",
allocs, g_frees.load(std::memory_order_relaxed), tracked, q, qb / 1024, dq,
dqb / 1024, g_dtor_blocks.load(std::memory_order_relaxed));
}
// --- store watch -----------------------------------------------------------
std::atomic<bool> g_selftest_armed{false};
bool WatchSelftest() {
static const bool on = REXCVAR_GET(skate3_heap_watch_selftest);
return on;
}
bool WatchEnabled() {
static const bool on = REXCVAR_GET(skate3_heap_watch);
return on;
}
std::atomic<bool> g_watch_armed{false};
// Both observed corruptions were in the pool at 0x830DACA8 (a static address in
// the guest image, identical across runs). Other pools exist, and the watch has
// one arena's worth of shadow bits, so prefer that one and say so - watching the
// wrong arena would waste a whole run and look exactly like "no findings".
constexpr uint32_t kPoolOfInterest = 0x830DACA8;
void WatchArm(uint32_t pool, uint32_t lo, uint32_t hi) {
if (pool != kPoolOfInterest) {
static std::atomic<int> other{0};
if (other.fetch_add(1) == 0) {
REXLOG_INFO(
"heap-check: store watch skipping pool 0x{:08X} (waiting for the one "
"at 0x{:08X})",
pool, kPoolOfInterest);
}
return;
}
if (!WatchEnabled() || g_watch_armed.load(std::memory_order_relaxed)) {
return;
}
const uint32_t len = hi - lo;
if (len == 0 || len > 64u * 1024 * 1024) {
return;
}
static std::once_flag once;
std::call_once(once, [lo, len] {
g_skate3_watch_bits = static_cast<uint8_t*>(std::calloc(len / 128 + 1, 1));
if (g_skate3_watch_bits == nullptr) {
return;
}
g_skate3_watch_lo = lo;
// Publish the length last: it is what arms the check in the store macro.
__atomic_store_n(&g_skate3_watch_len, len, __ATOMIC_RELEASE);
g_watch_armed.store(true, std::memory_order_relaxed);
REXLOG_INFO(
"heap-check: store watch armed over pool 0x{:08X} arena "
"[0x{:08X},0x{:08X})",
kPoolOfInterest, lo, lo + len);
});
}
void WatchMark(uint32_t addr, uint32_t size, bool free_now) {
if (g_skate3_watch_len == 0 || addr - g_skate3_watch_lo >= g_skate3_watch_len) {
return;
}
const uint32_t base_off = addr - g_skate3_watch_lo;
for (uint32_t off = 0; off < size; off += 16) {
const uint32_t g = (base_off + off) >> 4;
uint8_t& byte = g_skate3_watch_bits[g >> 3];
const uint8_t bit = uint8_t(1u << (g & 7));
if (free_now) {
__atomic_or_fetch(&byte, bit, __ATOMIC_RELAXED);
} else {
__atomic_and_fetch(&byte, uint8_t(~bit), __ATOMIC_RELAXED);
}
}
}
uint8_t* g_base = nullptr;
std::atomic<uint32_t> g_pool{0};
// Replicates the guest's own ownership test in sub_82990A58, plus a block
// boundary check, so we never write poison into memory this pool does not own.
bool BlockExtent(uint8_t* base, uint32_t pool, uint32_t block, uint32_t* chunk,
uint32_t* size) {
uint32_t lo = 0, hi = 0;
if (!PoolArena(base, pool, &lo, &hi) || block < lo || block >= hi) {
return false;
}
const uint32_t c = ChunkOf(block);
if (block < c || block >= c + kChunkSize) {
return false;
}
const uint32_t bsize = REX_LOAD_U16(c + kChunkBlockSize);
if (bsize < 16 || bsize > 2048 || block < c + kChunkData ||
(block - (c + kChunkData)) % bsize != 0 ||
block + bsize > c + kChunkSize) {
return false;
}
*chunk = c;
*size = bsize;
return true;
}
} // namespace
// Called from REX_STORE_* (see generated/skate3_init.h) when a guest store
// lands in a granule marked free. noinline and non-static so the return address
// is the store site inside the recompiled function - which, resolved against
// the binary, IS the guest function that wrote it.
extern "C" __attribute__((noinline)) void skate3_heap_watch_hit(uint32_t ea,
uint32_t size) {
static std::atomic<int> hits{0};
if (hits.fetch_add(1) != 0) {
return;
}
// One is all we need, and the report below does not stop the process (the
// crash reporter declines SIGABRT). Disarm so the rest of the session does
// not pay a call per store into the arena.
__atomic_store_n(&g_skate3_watch_len, 0u, __ATOMIC_RELEASE);
uint8_t* base = g_base;
void* site = __builtin_return_address(0);
REXLOG_ERROR(
"heap-check: WRITE INTO A FREE BLOCK: guest 0x{:08X}, {} bytes, from "
"thread {}. Nothing legitimate writes to a free block - this is the "
"corrupting store.",
ea, size, ThreadName());
LogFrames("writer", &site, 1);
LogHostBacktrace();
if (base == nullptr) {
return;
}
const uint32_t chunk = ChunkOf(ea);
const uint32_t bsize = REX_LOAD_U16(chunk + kChunkBlockSize);
if (bsize >= 16 && bsize <= 2048 && ea >= chunk + kChunkData) {
const uint32_t block =
chunk + kChunkData + (ea - (chunk + kChunkData)) / bsize * bsize;
REXLOG_ERROR("heap-check: that is +{} of free block 0x{:08X} (size {})",
ea - block, block, bsize);
FreeRecord rec;
bool have = false;
{
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_freed.find(block);
if (it != g_freed.end()) {
rec = it->second;
have = true;
}
}
if (have) {
REXLOG_ERROR("heap-check: it was freed on thread {} here:", rec.thread);
LogFrames("freed-by", rec.frames, rec.frame_count);
}
}
Fail(base, g_pool.load(), chunk, 0);
}
// Pool alloc: r3 = pool, r4 = size, returns the block in r3 (0 on failure).
// Sizes over 2048 and the wrapper entry points (sub_8298D078/sub_8298D098 add 8
// to r3 and tail-call into here) all funnel through this one function.
extern "C" REX_FUNC(sub_82990768) {
if (!HooksActive()) {
__imp__sub_82990768(ctx, base);
return;
}
const uint32_t pool = ctx.r3.u32;
const uint32_t guest_lr = uint32_t(ctx.lr);
Liveness(g_allocs.fetch_add(1, std::memory_order_relaxed) + 1);
g_base = base;
g_pool.store(pool, std::memory_order_relaxed);
if (WatchEnabled() && !g_watch_armed.load(std::memory_order_relaxed)) {
uint32_t alo = 0, ahi = 0;
if (PoolArena(base, pool, &alo, &ahi)) {
WatchArm(pool, alo, ahi);
}
}
CheckAllBins(base, pool, guest_lr, "alloc entry");
__imp__sub_82990768(ctx, base);
const uint32_t block = ctx.r3.u32;
if (block == 0) {
return;
}
// The block is ours alone between the allocator returning it and the caller
// touching it, so this needs no lock against the guest.
uint32_t chunk = 0, bsize = 0;
if (!BlockExtent(base, pool, block, &chunk, &bsize)) {
return;
}
CheckPoppedLink(base, pool, chunk, block, guest_lr);
// The block must be inside the WATCHED arena - the game runs more than one
// pool, and arming on a block from the other one marks nothing and proves
// nothing.
if (WatchSelftest() && g_skate3_watch_len != 0 &&
block - g_skate3_watch_lo < g_skate3_watch_len &&
!g_selftest_armed.exchange(true)) {
// Deliberately leave a block that was just handed out marked FREE. Its new
// owner is about to initialise it, and that store must trip the watch. No
// hit here means the watch is broken, and every quiet run that follows
// would be quiet for the wrong reason.
WatchMark(block, bsize, /*free_now=*/true);
REXLOG_INFO(
"heap-check: SELFTEST armed on live block 0x{:08X} (size {}) - the next "
"guest store into it must trip the watch",
block, bsize);
} else {
WatchMark(block, bsize, /*free_now=*/false);
}
if (!PoisonEnabled()) {
return;
}
FreeRecord rec;
{
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_freed.find(block);
if (it == g_freed.end()) {
return; // never passed through our free hook - nothing to verify
}
rec = it->second;
g_freed.erase(it);
auto ep = g_epoch.find(chunk);
const uint64_t epoch = ep == g_epoch.end() ? 0 : ep->second;
if (rec.epoch != epoch || rec.block_size != bsize) {
return; // chunk was released and re-carved; the poison means nothing now
}
}
const uint8_t* bytes = base + block;
uint32_t first = 0, last = 0, changed = 0;
for (uint32_t off = 4; off < bsize; ++off) {
if (bytes[off] == kPoison) {
continue;
}
if (changed++ == 0) {
first = off;
}
last = off;
}
if (changed == 0) {
return;
}
REXLOG_ERROR(
"heap-check: USE AFTER FREE: block 0x{:08X} (size {}) was written while "
"it sat on the free list - {} bytes changed in +{}..+{}, word at +{} is "
"0x{:08X}. Freed on thread {} (guest lr=0x{:08X}), {} pool ops ago.",
block, bsize, changed, first, last, first & ~3u,
REX_LOAD_U32(block + (first & ~3u)), rec.thread, rec.lr,
g_seq.load() - rec.seq);
DumpBlock(base, block, bsize, first);
LogFrames("freed-by", rec.frames, rec.frame_count);
Fail(base, pool, chunk, guest_lr);
}
// Pool free: r3 = pool, r4 = ptr, returns 1 if this pool owned the pointer.
extern "C" REX_FUNC(sub_82990A58) {
if (!HooksActive()) {
__imp__sub_82990A58(ctx, base);
return;
}
const uint32_t pool = ctx.r3.u32;
const uint32_t block = ctx.r4.u32;
const uint32_t guest_lr = uint32_t(ctx.lr);
g_frees.fetch_add(1, std::memory_order_relaxed);
uint32_t chunk = 0, bsize = 0;
const bool ours = BlockExtent(base, pool, block, &chunk, &bsize);
// --- candidate fix: defer the instance-teardown arrays ------------------
// Only these blocks, and only for a bounded time. A job that captured an
// interior pointer into them keeps writing after teardown; holding the
// memory means those writes land somewhere nobody has reused, instead of on
// a free-list link word.
if (ours && tl_in_instance_dtor && DeferMs() != 0) {
std::vector<DeferredBlock> release;
const uint64_t now = NowMs();
if (Enabled()) {
// Keep the block visible to the watch while it is held. That is what
// makes the fix falsifiable: if it is working, the job's write still
// trips the watch (it really does write after the free) but no
// CORRUPT free-list LINK ever appears, because the write landed on
// memory the allocator does not own. "Nothing happened" would not
// distinguish a working fix from a quiet run.
std::memset(base + block, kPoison, bsize);
WatchMark(block, bsize, /*free_now=*/true);
}
{
std::lock_guard<std::mutex> lock(g_defer_mutex);
g_defer.push_back({pool, block, bsize, now + DeferMs()});
g_defer_bytes += bsize;
while (!g_defer.empty() &&
(g_defer.front().due_ms <= now || g_defer_bytes > kDeferMaxBytes)) {
release.push_back(g_defer.front());
g_defer_bytes -= g_defer.front().size;
g_defer.pop_front();
}
}
for (const DeferredBlock& d : release) {
if (Enabled()) {
WatchMark(d.block, d.size, /*free_now=*/false);
}
ctx.r3.u32 = d.pool;
ctx.r4.u32 = d.block;
__imp__sub_82990A58(ctx, base);
// Same bookkeeping the normal path does. Skipping it leaves the watch
// bits set on a chunk that later empties and gets re-carved, and the
// allocator's own legitimate write to each block's link word then trips
// the watch - a false positive that reads exactly like the real bug.
if (Enabled()) {
WatchMark(d.block, d.size, /*free_now=*/true);
const uint32_t dchunk = ChunkOf(d.block);
if (REX_LOAD_U16(dchunk + kChunkUsed) == 0) {
WatchMark(dchunk, kChunkSize, /*free_now=*/false);
std::lock_guard<std::mutex> lock(g_mutex);
++g_epoch[dchunk];
}
}
}
ctx.r3.u32 = 1; // we took ownership of the caller's block
return;
}
if (!Enabled()) {
__imp__sub_82990A58(ctx, base);
return;
}
if (ours) {
CheckChunkFreeHead(base, pool, chunk, guest_lr, "free entry");
}
// The record and the fill are the expensive half (a backtrace and a
// block-sized memset per free); the watch does not need them, so they are
// separately switchable. Marking below is NOT - without it the watch has no
// free/live shadow and would sit silent forever.
if (ours && PoisonEnabled()) {
bool double_free = false;
FreeRecord prev;
{
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_freed.find(block);
if (it != g_freed.end() && it->second.block_size == bsize) {
double_free = true;
prev = it->second;
} else {
FreeRecord rec;
rec.lr = guest_lr;
rec.block_size = bsize;
rec.seq = g_seq.fetch_add(1, std::memory_order_relaxed);
auto ep = g_epoch.find(chunk);
rec.epoch = ep == g_epoch.end() ? 0 : ep->second;
std::snprintf(rec.thread, sizeof(rec.thread), "%s", ThreadName());
rec.frame_count = ::backtrace(rec.frames, kFreeFrames);
g_freed[block] = rec;
}
}
if (double_free) {
REXLOG_ERROR(
"heap-check: DOUBLE FREE: block 0x{:08X} (size {}) is already on the "
"free list - freed first by guest lr=0x{:08X} on thread {}, now "
"again from guest lr=0x{:08X} on thread {}.",
block, bsize, prev.lr, prev.thread, guest_lr, ThreadName());
Fail(base, pool, chunk, guest_lr);
} else {
// Fill past word 0: the allocator writes the free-list link there, and
// the fresh-chunk carve rewrites word 0 of every block - so word 0 is the
// one word we cannot own, and everything above it must survive untouched
// until the block is handed out again.
std::memset(base + block + 4, kPoison, bsize - 4);
}
}
// Quarantine. The watch can only see a stale write if the block is still
// free when it lands, and the pool hands blocks straight back out (LIFO), so
// that window is on the order of the next allocation. Holding a bounded set
// of freed blocks back from the allocator widens it by however long the queue
// takes to drain - and a quarantined block is safer than a free one: the
// allocator does not know it exists, so NOTHING may write to it, word 0
// included.
if (ours && QuarantineBytes() != 0) {
std::memset(base + block, kPoison, bsize);
WatchMark(block, bsize, /*free_now=*/true);
const bool dtor = tl_in_instance_dtor;
// Drain until under budget, not once per push: evicting a single block per
// free bounds the queue's LENGTH, and the byte total then drifts with the
// size mix. A quarantine that grows without limit would starve the pool and
// manufacture failures that look exactly like the bug being hunted.
std::vector<QuarantinedBlock> release;
{
std::lock_guard<std::mutex> lock(g_quarantine_mutex);
auto& queue = dtor ? g_dtor_quarantine : g_quarantine;
uint64_t& bytes = dtor ? g_dtor_quarantine_bytes : g_quarantine_bytes;
if (dtor) {
g_dtor_blocks.fetch_add(1, std::memory_order_relaxed);
}
queue.push_back({pool, block, bsize});
bytes += bsize;
while (bytes > QuarantineBytes() && !queue.empty()) {
release.push_back(queue.front());
bytes -= queue.front().size;
queue.pop_front();
}
}
for (const QuarantinedBlock& old : release) {
// Unmark before the real free: the allocator writes the link word as it
// pushes the block, and that write is legitimate.
WatchMark(old.block, old.size, /*free_now=*/false);
ctx.r3.u32 = old.pool;
ctx.r4.u32 = old.block;
__imp__sub_82990A58(ctx, base);
const uint32_t old_chunk = ChunkOf(old.block);
CheckChunkFreeHead(base, old.pool, old_chunk, guest_lr,
"quarantine release");
WatchMark(old.block, old.size, /*free_now=*/true);
if (REX_LOAD_U16(old_chunk + kChunkUsed) == 0) {
WatchMark(old_chunk, kChunkSize, /*free_now=*/false);
std::lock_guard<std::mutex> lock(g_mutex);
++g_epoch[old_chunk];
}
}
// Whatever we actually released, the caller asked about ITS block and we
// took ownership of it.
ctx.r3.u32 = 1;
return;
}
__imp__sub_82990A58(ctx, base);
if (ours) {
CheckChunkFreeHead(base, pool, chunk, guest_lr, "free exit");
// Only now: the allocator writes the block's link word during the push, and
// that write is legitimate.
WatchMark(block, bsize, /*free_now=*/true);
// Last block out: the chunk is now on the pool's free-chunk list and the
// next bin to want a chunk can re-carve it at a different block size. Every
// record for it is stale from here on, and the re-carve legitimately writes
// a link word into every block in it.
if (REX_LOAD_U16(chunk + kChunkUsed) == 0) {
WatchMark(chunk, kChunkSize, /*free_now=*/false);
std::lock_guard<std::mutex> lock(g_mutex);
++g_epoch[chunk];
}
}
}
// Sk8::WorldPresentation instance teardown. sub_82791CF8 allocates two u16
// arrays at self+496/+500 and publishes INTERIOR pointers into them
// (&A[idx], &B[idx]) into other objects' +64/+68; this frees both arrays
// without those holders being cleared, which is the dangling-alias setup the
// caught corruption fits. Flagging the frees it performs routes them into
// their own quarantine, where a stale write has minutes to land instead of a
// second - see g_dtor_quarantine.
extern "C" REX_FUNC(sub_82792040) {
if (!HooksActive()) {
__imp__sub_82792040(ctx, base);
return;
}
const bool prev = tl_in_instance_dtor;
tl_in_instance_dtor = true;
__imp__sub_82792040(ctx, base);
tl_in_instance_dtor = prev;
}