-
-
Notifications
You must be signed in to change notification settings - Fork 14.1k
Description
Background
In WASM, calling core::intrinsics::abort does not actually abort the program. It throws an exception on the JS side, preserving the (potentially-corrupt) current state in the Rust side. (Thanks to @kpreid for bringing this to my attention.)
In the Rust standard library, if the reference count in an Arc goes over usize::MAX / 2, Rust will abort the program. This is done to avoid an integer overflow that could happen if the reference count were to go over usize::MAX. This assumes that aborting the program will cause the bad state (of having a large reference count in an Arc) to go away, which is not true in WASM.
The unsoundness
src/lib.rs:
use wasm_bindgen::prelude::*;
use std::{
mem::forget,
sync::{Arc, LazyLock, Mutex},
};
type Payload = Box<i32>;
static STORAGE: LazyLock<Arc<Payload>> = LazyLock::new(|| Arc::new(Box::new(1)));
static SPARE: Mutex<Option<Arc<Payload>>> = Mutex::new(None);
#[wasm_bindgen]
pub fn initialize() {
*SPARE.lock().unwrap() = Some(Arc::clone(&STORAGE));
}
#[wasm_bindgen]
pub fn call_me() -> i32 {
loop {
let ref_count = Arc::strong_count(&STORAGE);
if ref_count != 1 {
// Increment the reference count by 1.
// If this the reference count becomes too large, this will abort the rust program.
// This results in an exception on the JS side.
forget(Arc::clone(&STORAGE));
} else {
// We've successfully overflowed the reference count.
// Now we cause a use-after-free.
drop(SPARE.lock().unwrap().take());
return ***STORAGE;
}
}
}run.mjs:
import { call_me, initialize } from './pkg/wasm_unsound.js';
initialize();
let i = 0;
while (true) {
i += 1;
if (i % 1000000 == 0) {
// We're done after this prints 2147000000
console.log(i);
}
try {
console.log(call_me());
break;
} catch (e) {
// Do nothing
}
}Full code for reproducing the unsoundness can be found at https://github.com/theemathas/wasm-unsound. To run the code, use the following commands:
cargo install wasm-pack
wasm-pack build --target nodejs
node run.mjsThe code repeatedly leaks an Arc, increasing its reference count. When the reference count is larger than usize::MAX / 2, the Rust side aborts. The JS side then catches the resulting exception, then re-enters the Rust side, but with the reference count being 1 higher. Eventually, after aborting 2 billion times (which takes a long time), the reference count goes over usize::MAX and overflows. The Rust code then uses this to perform a use-after-free, and returns garbage data to the JS side. In my testing, the program printed the number 1050364, which I assume is data read from some random address.
Meta
rustc --version --verbose:
rustc 1.91.1 (ed61e7d7e 2025-11-07)
binary: rustc
commit-hash: ed61e7d7e242494fb7057f2657300d9e77bb4fcb
commit-date: 2025-11-07
host: x86_64-unknown-linux-gnu
release: 1.91.1
LLVM version: 21.1.2