Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions crates/unbounded-spsc/RUSTSEC-0000-0000.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's drop this metadata from the advisory contents.


`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<T> = std::mem::transmute(self.producer.get());
```

`self.producer` has type `UnsafeCell<spsc::Producer<T>>`. `UnsafeCell::<X>::get(&self)` returns `*mut X` — a pointer-sized value. The author's intent (per the surrounding comment) was a value-level transmute (`Producer<T>` and `Consumer<T>` are layout-compatible newtypes around `Arc<Buffer<T>>`). The shipped code is one indirection off: it copies 8 bytes of pointer into the bytes of a `Consumer<T>`, whose internal `Arc::ptr` is then the address of the producer field on the `Sender`, not the real `ArcInner<Buffer<T>>`.

The subsequent `consumer.try_pop()` walks `Buffer<T>` fields at offsets that lie inside the `Sender<T>` struct (`send_new`, `inner`) — an OOB read of the Sender's own frame. The fake `Consumer<T>`'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::<Box<u64>>();
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.
Comment on lines +36 to +64
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's drop the "Trigger" and "Smoking-gun upstream evidence" sections.