Skip to content

Unsafe code can't soundly rely on aborting in wasm (featuring Arc reference count overflow) #149708

@theemathas

Description

@theemathas

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.mjs

The 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-runtimeArea: std's runtime and "pre-main" init for handling backtraces, unwinds, stack overflowsC-bugCategory: This is a bug.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessO-wasmTarget: WASM (WebAssembly), http://webassembly.org/T-langRelevant to the language teamT-libsRelevant to the library team, which will review and decide on the PR/issue.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions