← All reports

[JSC] IPInt slow path for `memory.atomic.notify` truncates the Memory64 pointer and offset to 32 bits

A Memory64 address of 2^32 looked like address 0 to the bounds check.

Component: JSC | afc44e3

Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.cpp

WASM_IPINT_EXTERN_CPP_DECL(memory_atomic_notify, IPIntStackEntry* args)
{
#if CPU(ARM64) || CPU(X86_64)
- unsigned offset = args[0].i32;
+ uint64_t offset = args[0].i64;
uint8_t memoryIndex = args[1].i32;
int32_t count = args[2].i32;
- unsigned base = args[3].i32;
+ uint64_t base = args[3].i64;
int32_t result = Wasm::memoryAtomicNotify(instance, base, offset, count, memoryIndex);
WASM_RETURN_TWO(std::bit_cast<void*>(static_cast<intptr_t>(result)), nullptr);

JSTests/wasm/stress/memory64-atomic-notify-out-of-bounds.js

// memory.atomic.notify with a Memory64 pointer just past the 32-bit boundary.
// The memory is 1 page (64 KiB), so pointer 2^32 is out of bounds and must trap.
assert.throws(() => exports.notify(0x1_0000_0000n), WebAssembly.RuntimeError, "Out of bounds memory access");
assert.throws(() => exports.notify(0xffff_ffff_ffff_ffffn), WebAssembly.RuntimeError, "Out of bounds memory access");
assert.eq(exports.notify(0n), 0);

When IPInt cannot handle an instruction inline in assembly, it falls back to a C++ slow path, passing arguments through an IPIntStackEntry union whose .i32 and .i64 members share storage — reading the wrong member silently drops the upper bits with no runtime error. memory.atomic.notify is the Wasm equivalent of a futex wake, and its slow path read both the 64-bit address operand and the immediate offset through .i32. The fix changes both to .i64. Because the assembly fast path always passed full 64-bit values, the bug was invisible to Memory32 modules and only surfaced when a Memory64 address had its upper 32 bits set.

IPInt assembly fast path
  └─► pushes args[0]=offset (i64), args[3]=base (i64) onto stack as full 64-bit values

BEFORE (buggy slow path):
  offset = args[0].i32  →  0x1_0000_0000 truncates to 0x00000000
  base   = args[3].i32  →  0x1_0000_0000 truncates to 0x00000000
                            bounds_check(0x00000000) → IN BOUNDS → no trap ✗

AFTER (fixed slow path):
  offset = args[0].i64  →  0x1_0000_0000 preserved
  base   = args[3].i64  →  0x1_0000_0000 preserved
                            bounds_check(0x1_0000_0000) → OUT OF BOUNDS → trap ✓

An out-of-bounds address such as 2^32 truncated to 0 and looked in-bounds, so memory.atomic.notify operated on address 0 of linear memory instead of trapping. This is a confirmed bounds check bypass in the Memory64 slow path, and anyone auditing atomic instruction safety in Memory64 contexts should treat it as a confirmed vulnerability class rather than an isolated slip.

The .i32 read from a union storing .i64 values is a mechanical pattern that copy-paste reproduces easily, and there is no reason to assume it is confined to memory_atomic_notify. Audit WasmIPIntSlowPaths.cpp for every other atomic instruction — memory_atomic_wait32, memory_atomic_wait64, the atomic.rmw.* family, atomic.store, atomic.load — and every other Memory64-capable memory instruction slow path, for the same args[N].i32 versus args[N].i64 mismatch; a single missed instance produces the identical truncation. In review, any unsigned/uint32_t local receiving an address or offset in a Memory64-capable slow path is the tell — the declared C++ type is the giveaway before the union member even matters. Separately, verify that the fix's treatment of memoryIndex and count is right: both are read as .i32 and appear genuinely 32-bit, but that assumption should be confirmed against what the assembly fast path actually pushes.