Skip to content

UAF in allocator-backed Arc::make_mut after caught unwind #155746

Description

@lopopolo

Thee is a soundness issue in allocator-backed alloc::sync::Arc::make_mut caused by panic-unsafety in the branch that dissociates existing Weak references.

This finding was discovered by OpenAI Codex, validated by @lopopolo. We have been scanning the Rust toolchain using RUSTSEC-2026-0078 as a seed for partially committed state corruption after unwind.

The below code fails validation under Miri with a use after free.

#![feature(allocator_api)]
#![cfg_attr(not(test), allow(dead_code, unused_features))]

//! Minimized standalone witness for allocator-backed `Arc::make_mut`
//! panic-unsafety.
//!
//! Stable regression-style witness:
//!
//! ```sh
//! cargo +nightly test --manifest-path alloc-arc-make-mut-ub-repro/Cargo.toml \
//!   arc_make_mut_preserves_live_arc_after_allocator_clone_panic -- --exact --nocapture
//! ```
//!
//! Miri UB witness:
//!
//! ```sh
//! cargo +nightly miri test --manifest-path alloc-arc-make-mut-ub-repro/Cargo.toml \
//!   miri_arc_make_mut_clone_drop_clone_then_read_original_after_allocator_clone_panic \
//!   -- --exact --nocapture
//! ```

use std::{
    alloc::{AllocError, Allocator, Global, Layout},
    mem::forget,
    panic::{AssertUnwindSafe, catch_unwind},
    ptr::NonNull,
    sync::{
        Arc, Weak,
        atomic::{AtomicUsize, Ordering::SeqCst},
    },
};

#[derive(Debug)]
struct PanicAlloc {
    state: Arc<PanicAllocState>,
}

#[derive(Debug)]
struct PanicAllocState {
    clone_count: AtomicUsize,
    panic_on: AtomicUsize,
}

impl PanicAlloc {
    fn new() -> Self {
        Self {
            state: Arc::new(PanicAllocState {
                clone_count: AtomicUsize::new(0),
                panic_on: AtomicUsize::new(usize::MAX),
            }),
        }
    }

    fn arm(&self, panic_on: usize) {
        self.state.clone_count.store(0, SeqCst);
        self.state.panic_on.store(panic_on, SeqCst);
    }

    fn disarm(&self) {
        self.state.panic_on.store(usize::MAX, SeqCst);
    }
}

impl Clone for PanicAlloc {
    fn clone(&self) -> Self {
        let clone_number = self.state.clone_count.fetch_add(1, SeqCst) + 1;
        if clone_number == self.state.panic_on.load(SeqCst) {
            panic!("allocator clone panic");
        }
        Self {
            state: Arc::clone(&self.state),
        }
    }
}

unsafe impl Allocator for PanicAlloc {
    fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError> {
        Global.allocate(layout)
    }

    unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: Layout) {
        unsafe { Global.deallocate(ptr, layout) }
    }
}

fn panics_silently<T>(f: impl FnOnce() -> T) -> bool {
    let previous_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(|_| {}));
    let panicked = catch_unwind(AssertUnwindSafe(f)).is_err();
    std::panic::set_hook(previous_hook);
    panicked
}

fn corrupt_arc_make_mut_pair() -> (Arc<String, PanicAlloc>, Weak<String, PanicAlloc>) {
    let alloc = PanicAlloc::new();
    let mut arc = Arc::new_in(String::from("hello"), alloc.clone());
    let weak = Arc::downgrade(&arc);

    alloc.arm(1);
    let make_mut_panicked = panics_silently(|| {
        let _ = Arc::make_mut(&mut arc);
    });
    alloc.disarm();

    assert!(make_mut_panicked, "setup should panic inside Arc::make_mut");
    (arc, weak)
}

#[test]
fn arc_make_mut_preserves_live_arc_after_allocator_clone_panic() {
    let (arc, weak) = corrupt_arc_make_mut_pair();
    let strong_after_panic = Arc::strong_count(&arc);
    let weak_upgrades_after_panic = weak.upgrade().is_some();

    // Avoid exercising later drop behavior when validating the immediate
    // post-unwind state on a vulnerable toolchain.
    forget(weak);
    forget(arc);

    assert_eq!(
        strong_after_panic, 1,
        "the original Arc should still report one strong reference after the panic"
    );
    assert!(
        weak_upgrades_after_panic,
        "the existing Weak should still upgrade after the panic"
    );
}

#[cfg(miri)]
#[test]
fn miri_arc_make_mut_clone_drop_clone_then_read_original_after_allocator_clone_panic() {
    let (arc, weak) = corrupt_arc_make_mut_pair();

    // Safe code after the caught panic: resurrect the corrupted `Arc` with a
    // clone, drop that clone so it tears down the shared `String`, then read
    // through the original handle.
    let cloned = Arc::clone(&arc);
    drop(cloned);
    assert_eq!(arc.as_bytes()[0], b'h');
    drop(weak);
}

The issue requires:

  • an allocator-backed Arc<T, A>,
  • at least one live Weak<T, A>,
  • a panic in A::clone() during Arc::make_mut,
  • and a caller that catches the unwind and continues using the recovered Arc.

On the validated toolchain, the recovered Arc survives with its strong count cleared to 0. Later plain safe code can turn that corruption into use-after-free:

  1. call Arc::clone(&arc) on the recovered Arc,
  2. drop that clone,
  3. read through the original Arc<String>.

Under Miri, that sequence produces a dangling-pointer UB report from String::as_bytes.

The likely root cause is in Arc::make_mut in library/alloc/src/sync.rs:2495-2552. In the strong == 1 && weak != 1 branch, the implementation first clears the last strong count with compare_exchange(1, 0, ...), then clones the allocator to materialize an implicit weak guard:

  • this.inner().strong.compare_exchange(1, 0, Acquire, Relaxed) at line 2506
  • let _weak = Weak { ptr: this.ptr, alloc: this.alloc.clone() }; at line 2524

If this.alloc.clone() panics there and the unwind is caught, the original Arc is left in a partially committed state with strong == 0, but the caller still holds a live Arc handle. Later safe Arc::clone / drop can destroy the shared payload while another safe Arc still points at it.

The provided code fails under Miri with UB:

$ cargo +nightly miri test --manifest-path alloc-arc-make-mut- ub-repro/Cargo.toml \
    miri_arc_make_mut_clone_drop_clone_then_read_original_after_allocator_clone_panic \
    -- --exact --nocapture
running 1 test
test miri_arc_make_mut_clone_drop_clone_then_read_original_after_allocator_clone_panic ... error: Undefined Behavior: pointer not dereferenceable: alloc40300 has been freed, so this pointer is dangling
    --> /Users/lopopolo/.rustup/toolchains/nightly-aarch64-apple-darwin/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:1841:13
     |
1841 |             &*core::intrinsics::aggregate_raw_ptr::<*const [T], _, _>(self.as_ptr(), self.len)
     |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
     |
     = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
     = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
help: alloc40300 was allocated here:
    --> src/lib.rs:96:31
     |
  96 |     let mut arc = Arc::new_in(String::from("hello"), alloc.clone());
     |                               ^^^^^^^^^^^^^^^^^^^^^
help: alloc40300 was deallocated here:
    --> src/lib.rs:139:5
     |
 139 |     drop(cloned);
     |     ^^^^^^^^^^^^
     = note: this is on thread `miri_arc_make_mut_clone_drop_clone_then_read_original_after_all`
     = note: stack backtrace:
             0: std::vec::Vec::<u8>::as_slice
                 at /Users/lopopolo/.rustup/toolchains/nightly-aarch64-apple-darwin/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:1841:13: 1841:95
             1: std::string::String::as_bytes
                 at /Users/lopopolo/.rustup/toolchains/nightly-aarch64-apple-darwin/lib/rustlib/src/rust/library/alloc/src/string.rs:1446:9: 1446:28
             2: miri_arc_make_mut_clone_drop_clone_then_read_original_after_allocator_clone_panic
                 at src/lib.rs:140:16: 140:30
             3: miri_arc_make_mut_clone_drop_clone_then_read_original_after_allocator_clone_panic::{closure#0}
                 at src/lib.rs:132:87: 132:87

On my validated toolchain, the stable witness fails with strong_count == 0 instead of 1, and Miri reports a dangling-pointer read where the String buffer is allocated at src/lib.rs:96 and freed by drop(cloned) at src/lib.rs:139.

The straightforward mitigation seems to be preserving the original strong count and allocator state until all fallible work in this branch has completed. In particular, Arc::make_mut should not publish strong == 0 before allocator cloning or any later fallible setup that can unwind.

I previously discussed this with @cuviper and @emilyalbini via a report to the Security WG, where we concluded

While this is theoretically an issue that could happen on stable (the allocator API is unstable, but still used inside of std itself with the Global allocator), in practice std::alloc::Global cannot panic during clone (as it also implements Copy), so this issue can only surface when using nightly features. Because of that, we do not believe it needs an embargo or a security advisory to be published. It is a legit soundness issue though [...]

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-bugCategory: This is a bug.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessP-highHigh priorityT-libsRelevant to the library team, which will review and decide on the PR/issue.requires-nightlyThis issue requires a nightly compiler in some way. When possible, use a F-* label instead.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions