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:
- call
Arc::clone(&arc) on the recovered Arc,
- drop that clone,
- 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 [...]
Thee is a soundness issue in allocator-backed
alloc::sync::Arc::make_mutcaused by panic-unsafety in the branch that dissociates existingWeakreferences.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.
The issue requires:
Arc<T, A>,Weak<T, A>,A::clone()duringArc::make_mut,Arc.On the validated toolchain, the recovered
Arcsurvives with its strong count cleared to0. Later plain safe code can turn that corruption into use-after-free:Arc::clone(&arc)on the recoveredArc,Arc<String>.Under Miri, that sequence produces a dangling-pointer UB report from
String::as_bytes.The likely root cause is in
Arc::make_mutinlibrary/alloc/src/sync.rs:2495-2552. In thestrong == 1 && weak != 1branch, the implementation first clears the last strong count withcompare_exchange(1, 0, ...), then clones the allocator to materialize an implicit weak guard:this.inner().strong.compare_exchange(1, 0, Acquire, Relaxed)at line 2506let _weak = Weak { ptr: this.ptr, alloc: this.alloc.clone() };at line 2524If
this.alloc.clone()panics there and the unwind is caught, the originalArcis left in a partially committed state withstrong == 0, but the caller still holds a liveArchandle. Later safeArc::clone/dropcan destroy the shared payload while another safeArcstill points at it.The provided code fails under Miri with UB:
On my validated toolchain, the stable witness fails with
strong_count == 0instead of1, and Miri reports a dangling-pointer read where theStringbuffer is allocated atsrc/lib.rs:96and freed bydrop(cloned)atsrc/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_mutshould not publishstrong == 0before 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