diff --git a/crates/unbounded-spsc/RUSTSEC-0000-0000.md b/crates/unbounded-spsc/RUSTSEC-0000-0000.md new file mode 100644 index 0000000000..7f1063bf4f --- /dev/null +++ b/crates/unbounded-spsc/RUSTSEC-0000-0000.md @@ -0,0 +1,64 @@ +```toml +[advisory] +id = "RUSTSEC-0000-0000" +package = "unbounded-spsc" +date = "2026-05-14" +url = "https://github.com/spearman/unbounded-spsc/issues/4" +categories = ["memory-corruption"] +keywords = ["transmute", "uaf", "race", "drop"] +informational = "unsound" +aliases = ["GHSA-6m57-8r3p-pqx6"] + +[versions] +patched = [">= 0.3.0"] +unaffected = [] + +[affected] +functions = { "unbounded_spsc::Sender::send" = ["<= 0.2.0"] } +``` + +# `Sender::send` pointer-as-value transmute causes OOB read and fake-Arc drop under tx/rx race + +Maintainer acknowledged on 2026-05-15; v0.3.0 ships the fix plus a regression test (`race_disconnect_does_not_corrupt_sender_or_abort`), and 0.2.0 + all 0.1.x versions have been yanked on crates.io. + +`Sender::send` (single-file crate, `src/lib.rs:379-405` in 0.2.0 / commit `23a9ce7`) contains an `unsafe` block in the `DISCONNECTED` arm that transmutes the *pointer to* the producer instead of the producer value: + +```rust +let consumer: spsc::Consumer = std::mem::transmute(self.producer.get()); +``` + +`self.producer` has type `UnsafeCell>`. `UnsafeCell::::get(&self)` returns `*mut X` — a pointer-sized value. The author's intent (per the surrounding comment) was a value-level transmute (`Producer` and `Consumer` are layout-compatible newtypes around `Arc>`). The shipped code is one indirection off: it copies 8 bytes of pointer into the bytes of a `Consumer`, whose internal `Arc::ptr` is then the address of the producer field on the `Sender`, not the real `ArcInner>`. + +The subsequent `consumer.try_pop()` walks `Buffer` fields at offsets that lie inside the `Sender` struct (`send_new`, `inner`) — an OOB read of the Sender's own frame. The fake `Consumer`'s `Drop` then decrements bytes treated as `AtomicUsize::strong_count` and calls `dealloc(...)` on a non-allocated address. + +The branch is not reachable single-threaded: the receiver-drop and `connected = false` guard prevent it. The trigger is a TOCTOU race: the sender's `connected.load()` wins but the receiver's `counter.compare_exchange(_, DISCONNECTED, ...)` wins. Under contention the race reproduces in a few hundred trials. + +## Trigger + +```rust +use std::thread; +use unbounded_spsc::channel; + +for _ in 0..500 { + let (tx, rx) = channel::>(); + let h = thread::spawn(move || { + for _ in 0..10_000 { let _ = tx.send(Box::new(0xDEAD_BEEF)); } + }); + drop(rx); + let _ = h.join(); +} +// Release: SIGSEGV within a few iterations. +// ASan: stack-buffer-overflow inside fake-Consumer::try_pop. +``` + +## Smoking-gun upstream evidence + +`src/lib.rs:975` in the project's own test suite carries: + +``` +// TODO: failures +// - failed with assertion on line 394 in send fn +// assert!(second.is_none()) +``` + +That assertion lives in the same transmute block. The author has observed the symptom — `try_pop()` returning `Some` where logically there should be `None` — as a flaky test rather than recognising it as UB.