Skip to content

Fix panic-safety in AlignedBox::realloc (double-free on panicking element Drop) - #6

Merged
michaellass merged 2 commits into
michaellass:masterfrom
tooson9010-spec:fix/realloc-panic-safety
Sep 7, 2026
Merged

Fix panic-safety in AlignedBox::realloc (double-free on panicking element Drop)#6
michaellass merged 2 commits into
michaellass:masterfrom
tooson9010-spec:fix/realloc-panic-safety

Conversation

@tooson9010-spec

Copy link
Copy Markdown
Contributor

Found while auditing this crate's unsafe teardown paths for panic-safety.

Summary

When shrinking, realloc takes the Box out of self.container and drops the tail
elements. If an element's Drop panics, self.container still holds the old pointer
while ownership has moved out, so AlignedBox's own Drop frees those elements a
second time, a double-free (CWE-415) reachable from safe Rust.

Fix

Drop the tail back to front under a guard. On unwind the guard restores
self.container and self.layout to the still-live prefix [0..valid], so every
element is freed exactly once. On the normal path the guard is disarmed and the
existing realloc flow continues unchanged.

Verification

Added realloc_shrink_panicking_drop_is_sound: a box of elements whose Drop panics
is shrunk, then dropped. Without the fix the second drop double-frees the tail
(glibc "double free detected", SIGABRT); with the fix it unwinds cleanly.
Existing tests pass. Confirmed on 0.3.0.

…ment Drop)

When shrinking, realloc takes the Box out of self.container and drops the tail
elements. Each element's Drop is user-controlled and may panic; if it does,
self.container still holds the old pointer while ownership has moved out, so
AlignedBox's own Drop frees those elements a second time -- a double-free
reachable from safe Rust. Drop the tail back to front under a guard that, on
unwind, restores self.container/self.layout to the still-live prefix, so every
element is freed exactly once. Adds a regression test.
@tooson9010-spec

Copy link
Copy Markdown
Contributor Author

Two notes:

  • This only covers the shrink path. The realloc-failure branch re-runs
    initializer on the dropped slots, so a panicking initializer (e.g. a
    panicking T::default) still leaves self.container stale and can double-free.
    Different root cause, so I didn't touch it here; can send a separate PR if
    you want it fixed too.

  • The element being dropped when the panic hits (index valid) is excluded
    from the restored prefix [0..valid], so it's never freed twice. It leaks
    instead, which is fine on an unwinding path.

@michaellass

Copy link
Copy Markdown
Owner

Hi. Thanks for tracking this down and providing a fix! The first CI fail is just a missing cargo fmt. The second one could actually indicate an issue detected by miri:

~/git/crates/aligned_box (git)-[tooson9010-spec-fix/realloc-panic-safety] % cargo miri test
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running unittests src/lib.rs (target/miri/x86_64-unknown-linux-gnu/debug/build/aligned_box/9e2a8e54c3168622/out/aligned_box-9e2a8e54c3168622)

running 16 tests
test tests::aliasing ... ok
test tests::alignment ... ok
test tests::clone ... ok
test tests::clone_rss ... ignored
test tests::copy_sem ... ok
test tests::defaults ... ok
test tests::drop_contained ... ok
test tests::free ... ignored
test tests::min_align ... ignored
test tests::move_sem ... ok
test tests::read_write ... ok
test tests::realloc_shrink_panicking_drop_is_sound ... error: Undefined Behavior: incorrect layout on deallocation: alloc174631 has size 192 and alignment 64, but gave size 168 and alignment 64
  --> src/lib.rs:84:13
   |
84 |             alloc::alloc::dealloc(ptr as *mut u8, self.layout);
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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
   = note: this is on thread `tests::realloc_`
   = note: stack backtrace:
           0: <AlignedBox<[tests::realloc_shrink_panicking_drop_is_sound::PanicOnDrop]> as std::ops::Drop>::drop
               at src/lib.rs:84:13: 84:63
           1: std::ptr::drop_glue::<AlignedBox<[tests::realloc_shrink_panicking_drop_is_sound::PanicOnDrop]>> - shim(Some(AlignedBox<[tests::realloc_shrink_panicking_drop_is_sound::PanicOnDrop]>))
               at /scratch/bevan/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:848:1: 850:25
           2: std::mem::drop::<AlignedBox<[tests::realloc_shrink_panicking_drop_is_sound::PanicOnDrop]>>
               at /scratch/bevan/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/mem/mod.rs:1049:1: 1049:2
           3: tests::realloc_shrink_panicking_drop_is_sound
               at src/lib.rs:883:9: 883:16
           4: tests::realloc_shrink_panicking_drop_is_sound::{closure#0}
               at src/lib.rs:846:48: 846:48

I think there is an off-by-one error in the set value for guard.valid. The loop iterates over item indices (0..n-1), so guard.valid has to be set to i+1, right? However, with that modification, the test fails and detects a double-free again.

@michaellass

Copy link
Copy Markdown
Owner

Ah, this is what you described in your second note. The reasoning is that we don't really know about the state of that object, so we don't restore it, correct? I guess we would have to call realloc again to reduce the size of the memory allocation accordingly.

@michaellass

Copy link
Copy Markdown
Owner

I was able to fix the miri issue with the following change:

--- a/src/lib.rs
+++ b/src/lib.rs
@@ -296,10 +296,13 @@ impl<T> AlignedBox<[T]> {
         impl<T> Drop for ShrinkGuard<T> {
             fn drop(&mut self) {
                 unsafe {
-                    let slice = std::slice::from_raw_parts_mut(self.elem_ptr, self.valid);
                     let memsize = std::mem::size_of::<T>() * self.valid;
                     let layout = alloc::alloc::Layout::from_size_align(memsize, self.align)
-                        .expect("prefix layout is valid");
+                        .expect("prefix layout is invalid");
+                    let new_ptr =
+                        alloc::alloc::realloc(self.elem_ptr as *mut u8, *self.layout, memsize)
+                            as *mut T; // FIXME check ret
+                    let slice = std::slice::from_raw_parts_mut(new_ptr, self.valid);
                     *self.container =
                         std::mem::ManuallyDrop::new(alloc::boxed::Box::from_raw(slice));
                     *self.layout = layout;

However, we need to check the return value of realloc here. In case it fails, we would have to restore the dropped items, similar to what we do later on when realloc fails. And as you pointed out, this code path also needs some changes to get it safe.

@tooson9010-spec

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing, and sorry for the bad patch. You're right — the guard shrinks *self.layout without actually reallocating, so dealloc gets a layout that doesn't match.

On the off-by-one: valid is i on purpose. Element i is the one whose Drop unwound, so its state is unknown and we can't drop it again. That's the leak from my second note.

Rather than calling realloc in the guard, what if we leave the layout alone and only shrink the Box? The box length decides which elements get dropped and the layout decides the deallocation, so I'd expect dealloc to match again with no fallible step in the guard. The allocation would stay larger than the logical length, but AlignedBox doesn't expose capacity.

I don't have a machine to test on right now, so I'll check it under miri in a couple of days and report back! Thank you again for the detailed review!

The guard rebuilt the box at the prefix length but left the allocation at its
original size, so Drop deallocated with a mismatched layout and a pointer whose
provenance no longer covered the allocation. Shrink the allocation with realloc
instead, keeping container.len() * size_of::<T>() equal to layout.size(). If
that realloc fails the original allocation is untouched, so reinitialize the
dropped slots and restore the full-length box, mirroring the existing
realloc-failure path.
@tooson9010-spec

tooson9010-spec commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@michaellass

Sorry for the delay.
Pushed the fix. Turns out you were right about needing the realloc.

Leaving the layout alone fixes the size mismatch, but miri then reports a
stacked-borrows error instead: the box built at the prefix length has provenance
covering only the prefix, so dealloc on the full allocation is still UB. So the
allocation has to shrink.

The guard now reallocs, with the return value checked. On failure the original
allocation is untouched, so the dropped slots get reinitialized and the
full-length box is restored. The realloc-failure path below does the same thing,
which is why the guard now carries initializer. I also replaced the .expect()
with from_size_align_unchecked, since a panic in the guard during unwinding would
abort, and shrinking a valid layout keeps it valid.

Miri, the sanitizers, tests and clippy all pass locally. The CI run needs your
approval to start.

Still not covered: if initializer panics inside the guard, that aborts. That's
the same problem as the realloc-failure path you mentioned, so I left it alone.

@michaellass
michaellass merged commit 0295535 into michaellass:master Sep 7, 2026
10 checks passed
@michaellass

Copy link
Copy Markdown
Owner

Thanks a lot for the follow-up fix!

If you want to look into the handling of a panic during the initializer call as well, I would be happy to review any changes. Otherwise, I hope to find some time to look into it soon.

@tooson9010-spec

Copy link
Copy Markdown
Contributor Author

@michaellass
Thanks for merging!

I'd rather leave the initializer path to you, if that's alright. It's a different
root cause and I'd only be guessing at what shape of fix fits the crate best.
Happy to review or test whatever you come up with.

Two questions on process. Are you planning a release with this fix? And would you
be alright with me filing a RustSec advisory once it's out, so users on 0.3.0 get
notified? The double free is reachable from safe Rust, so it seems worth having on
record.

@michaellass

Copy link
Copy Markdown
Owner

@tooson9010-spec
I just published v0.3.1 with the fix to crates.io. Yes, please feel free to file a RustSec advisory. Thank you!

@tooson9010-spec

Copy link
Copy Markdown
Contributor Author

@michaellass
Thanks for the quick release! Filed the advisory as rustsec/advisory-db#3204.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants