Skip to main content

unbounded-spsc CVE-2026-46690

| EUVDEUVD-2026-36469 MEDIUM
Out-of-bounds Read (CWE-125)
2026-05-29 https://github.com/spearman/unbounded-spsc GHSA-6m57-8r3p-pqx6
5.8
CVSS 3.1 · NVD
Share

Severity by source

NVD PRIMARY
5.8 MEDIUM
AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H

Primary rating from NVD · only source for this CVE.

CVSS VectorNVD

CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H
Attack Vector
Local
Attack Complexity
High
Privileges Required
Low
User Interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
High

Lifecycle Timeline

3
Source Code Evidence Fetched
May 29, 2026 - 19:47 vuln.today
Analysis Generated
May 29, 2026 - 19:47 vuln.today
CVE Published
May 29, 2026 - 19:05 nvd
MEDIUM 5.8

DescriptionNVD

Summary

Sender::send in src/lib.rs contains an unsafe block in the DISCONNECTED arm that transmutes a raw pointer (*mut Producer<T>) into the bytes of a value-level Consumer<T>. The author's intent, visible in the surrounding comment at lines 386-390, was a value transmute. The shipped code is one level of indirection off.

The resulting Consumer<T> has its internal Arc::ptr set to the address of the producer field on the Sender, not the real ArcInner<Buffer<T>>. Every subsequent consumer.try_pop() walks Buffer<T> fields at offsets that lie inside the Sender<T> struct (over send_new, inner) and adjacent memory, an out-of-bounds read. When the fake Consumer<T> is dropped at the end of the unsafe block, its Drop calls Arc::drop_in_place on a non-ArcInner address: it decrements bytes that the type system treats as strong_count: AtomicUsize but that are actually the real Arc::ptr value of the Sender, and at zero count it calls dealloc(Layout::for_value(...)) on an address the allocator never returned.

Reachable from 100% safe Rust through the canonical channel pattern: a tx.send(msg) that races with rx.drop(). This is consistent with the SIGSEGV that issue #3 reports in your own test suite.

Affected code (0.2.0, master at 23a9ce7)

rust
// src/lib.rs:384-401
DISCONNECTED => {
    self.inner.counter.store (DISCONNECTED, Ordering::SeqCst);
    // We want to guarantee if a message was not received that we get it
    // back; since spsc::{Producer,Consumer} have the same
    // internal representation (as a singleton struct containing Arc
    // <Buffer <T>>), we can safely transmute the producer in order to
    // pop the message back if it was orphaned.
    unsafe {
      let consumer : spsc::Consumer <T>
        = std::mem::transmute (self.producer.get());     // <-- POINTER, not value
      let first    = consumer.try_pop();
      let second   = consumer.try_pop();
      assert!(second.is_none());                          // <-- line 396; smoking-gun assert
      if let Some(t) = first {
        return Err (SendError (t))
      }
    }
},
self.producer is UnsafeCell<spsc::Producer<T>> (line 29). UnsafeCell::<X>::get(&self) returns *mut X, a raw pointer, 8 bytes on 64-bit. The signature of transmute is transmute::<Src, Dst>(src: Src) -> Dst, so the call expands to transmute::<*mut spsc::Producer<T>, spsc::Consumer<T>>(self.producer.get()). 8 bytes of pointer are reinterpreted as the bytes of a Consumer<T>.

In bounded-spsc-queue-0.4.0, both Producer<T> and Consumer<T> are newtypes around Arc<Buffer<T>>, one pointer wide. The destination value therefore has Arc::ptr == &mut Producer<T> as *const ArcInner<Buffer<T>>. To be a valid Arc<Buffer<T>>, that pointer must point to ArcInner { strong: AtomicUsize, weak: AtomicUsize, data: Buffer<T> }, but it actually points to the start of Sender<T> (the producer field). The first 8 bytes there hold the real Arc::ptr. The fake Arc reads those bytes as strong_count. The fake try_pop() then reads Buffer<T> head/tail/data slots starting at offset 16 inside the Sender<T>, that is, inside the send_new and inner fields.

The author's intent (per the comment at lines 386-390) was a value-level transmute:

let producer_val: spsc::Producer<T> = std::ptr::read(self.producer.get());
let consumer    : spsc::Consumer<T> = std::mem::transmute(producer_val);
which is layout-sound iff Producer<T> and Consumer<T> have identical layouts (they do, both are single-Arc newtypes). The shipped code is one indirection off.

Reachability
The branch is not reachable single-threaded. Receiver::drop (line 332) stores connected = false before setting counter = DISCONNECTED; Sender::send (line 359) early-returns on connected == false. The trigger is a TOCTOU race:

Sender's self.inner.connected.load(SeqCst) reads true.
Receiver-drop runs: stores connected = false and counter.compare_exchange(_, DISCONNECTED, SeqCst, SeqCst).
Sender's self.inner.counter.fetch_add(1, SeqCst) (line 379) sees DISCONNECTED and enters the unsafe block.
Under heavy contention this reproduces ~3/10 trials in release mode.

Proof of concept (race shape)
// Cargo.toml: unbounded-spsc = "0.2"
use std::thread;
use unbounded_spsc::channel;

fn main() {
    for trial in 0..500 {
        let (tx, rx) = channel::<Box<u64>>();
        let started = std::sync::Arc::new(
            std::sync::atomic::AtomicBool::new(false));
        let s = started.clone();
        let h = thread::spawn(move || {
            s.store(true, std::sync::atomic::Ordering::SeqCst);
            for _ in 0..10_000 {
                let _ = tx.send(Box::new(0xDEAD_BEEF));
            }
        });
        while !started.load(std::sync::atomic::Ordering::SeqCst) {
            std::hint::spin_loop();
        }
        drop(rx);
        let _ = h.join();
        eprintln!("trial {trial} ok");
    }
}
Observed:

Release-mode (no sanitizer): Segmentation fault (core dumped) reliably within a few trials. The non-segfaulting trials are masked by the separate send_new.send(new_consumer).unwrap() panic, see Secondary defect below.
-Zsanitizer=address -Zbuild-std (nightly): ASan reports stack-buffer-overflow / stack-use-after-scope from the fake-Consumer's try_pop walking off the Sender frame.
This matches the SIGSEGV reported in your own issue #3.

Smoking-gun upstream evidence
src/lib.rs:975 in the project's test suite carries a TODO:

// TODO: failures
// - failed with assertion on line 394 in send fn
//   assert!(second.is_none())
That is the assertion site of the transmute block (line 396 in 0.2.0 / master). You have observed try_pop() returning a non-None value where logically there should be none, which is exactly what reading random bytes from the Sender's send_new / inner fields produces, and the symptom has been marked as a flaky test rather than recognised as UB.

Impact
Reachable from 100% safe Rust. Concrete UB primitives:

OOB read of bytes adjacent to the Sender<T> struct via fake Consumer<T>::try_pop(). The popped T is returned through Err(SendError(t)) to safe-code, an allocator-layout-controlled leak of process memory.
OOB write via fake Arc::drop AtomicUsize::fetch_sub on bytes that are actually the real Arc::ptr value of the Sender.
Allocator corruption via fake Arc::drop calling dealloc(Layout::for_value(...)) on a non-allocated address. The Sender struct holds the real Arc<Inner> immediately after the producer field; the deallocator call therefore uses a layout the allocator never allocated, which on glibc is a confirmed double-free / arbitrary-bucket-poisoning primitive, and on hardened allocators (jemalloc-secure, mimalloc-secure) is an immediate abort.
Secondary defect (same call path, bonus)
Sender::send line 369:

self.send_new.send(new_consumer).unwrap();
When the Sender's message queue is full, a fresh bounded_spsc_queue::Channel is allocated and the new Consumer<T> is shipped over an std::sync::mpsc side-channel to the Receiver. If the Receiver has already been dropped, receive_new is gone and this unwrap() panics. The panic surfaces in your own test suite, issue #2 (tests::port_gone_concurrent panicked at src/lib.rs:369) and the in-source TODO at lines 365-368 already note the question "Are we sure that this is safe to unwrap or should we handle the result explicitly ?".

The fix is to return Err(SendError(t)) instead of unwrapping, same shape as the channel-closed result the function already returns on the connected-false path. This is not a memory-safety defect, only a panic, but it lives on the same TX/RX-race code path and a single coordinated patch can address both. Filing it here so we cover the full call site in one cycle.

Suggested patch (primary defect)
Replace the pointer-as-value transmute with a value-level read and a ManuallyDrop to suppress the alias's Producer::drop on subsequent exit:

unsafe {
    use core::mem::ManuallyDrop;

    // Sound value-level transmute: Producer<T> and Consumer<T> are both
    // newtypes around Arc<Buffer<T>>, so the value layouts match.
    // ptr::read takes ownership of the Producer's bytes without running
    // Producer's Drop.
    let producer_val: spsc::Producer<T> = std::ptr::read(self.producer.get());
    let consumer    : spsc::Consumer<T> = std::mem::transmute(producer_val);

    let first  = consumer.try_pop();
    let second = consumer.try_pop();
    assert!(second.is_none());
    if let Some(t) = first {
        return Err(SendError(t));
    }

    // consumer drops here; the same memory backs `producer`, so suppress
    // the double Producer drop:
    let _ = ManuallyDrop::new(consumer);
}
Cleaner: restructure Sender<T> to hold producer and consumer in a private enum Endpoint<T> so no transmute is required, or use the bounded_spsc_queue::Producer<T>::reclaim() escape hatch if available.

Suggested patch (secondary defect)
if let Err(std::sync::mpsc::SendError(_)) = self.send_new.send(new_consumer) {
    // Receiver has been dropped: take the message back as the public
    // SendError, the same way the connected==false early-return does.
    return Err(SendError(t));
}
Regression test (release-mode, race shape)
#[test]
fn race_disconnect_does_not_corrupt_sender_or_abort() {
    for _ in 0..200 {
        let (tx, rx) = unbounded_spsc::channel::<Box<u64>>();
        let h = std::thread::spawn(move || {
            for _ in 0..10_000 {
                let _ = tx.send(Box::new(0xDEAD_BEEF));
            }
        });
        drop(rx);
        h.join().unwrap();
    }
}
Reverse dependencies
Two crates on crates.io depend on unbounded-spsc, both owned by you: apis (process-calculus framework) and gooey-rs (tile-UI library, unbounded-spsc gated behind opengl/fmod features). The OpenGL/FMOD callback-mailbox use is a natural rx-drop-during-tx-send scenario at scene-graph teardown. A single coordinated bump cycle is feasible.

Researcher
Berkant Koc me@berkoc.com
PGP: 0C588DFD76204987284213EA0AC529C41F8AA5D6

AnalysisAI

Memory unsafety in unbounded-spsc 0.2.0 (Rust crate) allows a local attacker with low privileges to cause out-of-bounds reads, allocator corruption, and process crashes by winning a TOCTOU race between Sender::send and Receiver::drop. The root defect is a pointer-as-value transmute in the DISCONNECTED arm of Sender::send (src/lib.rs:384-401): the code transmutes an 8-byte raw pointer (*mut Producer<T>) directly into Consumer<T>, meaning the fake Consumer's internal Arc::ptr points at the Sender struct itself rather than the real ArcInner<Buffer<T>>. No public exploit identified at time of analysis, but a proof-of-concept reproducing SIGSEGV is included in the advisory and reproduces approximately 3 out of 10 trials under heavy contention in release mode.

Technical ContextAI

The affected code is in the unbounded-spsc Rust crate (pkg:rust/unbounded-spsc), a single-producer single-consumer unbounded channel built on top of bounded-spsc-queue. The defect is a misuse of std::mem::transmute inside an unsafe block at src/lib.rs:384-401. self.producer is typed UnsafeCell<spsc::Producer<T>>; calling .get() on it returns *mut spsc::Producer<T>, an 8-byte raw pointer on 64-bit platforms. The transmute call is transmute::<*mut spsc::Producer<T>, spsc::Consumer<T>>, which reinterprets those 8 pointer bytes as the value bytes of Consumer<T>. Since Consumer<T> is a newtype around Arc<Buffer<T>>, the resulting value has its Arc::ptr field set to the address of the producer field within the Sender<T> struct - not to any real ArcInner<Buffer<T>>. Subsequent calls to consumer.try_pop() walk Buffer<T> field offsets starting inside the Sender<T> struct (crossing send_new and inner fields), constituting an out-of-bounds read (CWE-125). When the fake Consumer<T> is dropped, Arc::drop_in_place is called on a non-ArcInner address: it atomically decrements bytes it interprets as strong_count (which are actually the real Arc::ptr of the Sender), and at zero count calls dealloc(Layout::for_value(...)) on an address the allocator never issued. The race is triggered via a TOCTOU window: Sender reads connected == true, Receiver-drop stores connected = false and sets counter = DISCONNECTED, then Sender's fetch_add sees DISCONNECTED and enters the unsafe block. On glibc this produces double-free or arbitrary heap-bucket-poisoning primitives; on hardened allocators (jemalloc-secure, mimalloc-secure) it causes an immediate abort.

RemediationAI

No vendor-released patch identified at time of analysis for unbounded-spsc. The advisory author provides a specific suggested fix: replace the pointer-level transmute with a value-level std::ptr::read of the Producer<T> followed by std::mem::transmute to Consumer<T>, and wrap the consumer in ManuallyDrop before it goes out of scope to suppress the aliased Producer::drop. This is layout-sound because both Producer<T> and Consumer<T> are newtypes around Arc<Buffer<T>> with identical single-pointer layouts. As a cleaner structural fix, the advisory recommends replacing the producer/consumer fields with a private enum Endpoint<T> to eliminate the transmute entirely. For the secondary defect (unwrap panic on send_new.send), replace the unwrap with an explicit Err(SendError(t)) return. Until an upstream patch is released, consumers of unbounded-spsc should pin the crate at its current version and apply the suggested patch locally via a Cargo patch override, or replace unbounded-spsc with an alternative SPSC channel crate such as crossbeam-channel or flume. For gooey-rs users, disabling the opengl and fmod feature flags eliminates the dependency. The regression test included in the advisory (race_disconnect_does_not_corrupt_sender_or_abort) should be added to the project test suite to prevent reintroduction. Advisory: https://github.com/spearman/unbounded-spsc/security/advisories/GHSA-6m57-8r3p-pqx6.

CVE-2012-0217 HIGH POC
7.2 Jun 12

The x86-64 kernel system-call functionality in Xen 4.1.2 and earlier, as used in Citrix XenServer 6.0.2 and earlier and

CVE-2026-33309 CRITICAL POC
9.9 Mar 19

An authenticated path traversal vulnerability in Langflow's file upload functionality allows attackers to write arbitrar

CVE-2019-7304 CRITICAL POC
9.8 Apr 23

Canonical snapd before version 2.37.1 incorrectly performed socket owner validation, allowing an attacker to run arbitra

CVE-2026-33186 CRITICAL POC
9.1 Mar 18

An authorization bypass vulnerability in gRPC-Go allows attackers to circumvent path-based access control by sending HTT

CVE-2026-50180 HIGH POC
8.7 Jul 02

Arbitrary file read in Langroid's SQLChatAgent (<= 0.63.0) lets an attacker who can influence the LLM-generated SQL exfi

CVE-2020-14966 HIGH POC
7.5 Jun 22

An issue was discovered in the jsrsasign package through 8.0.18 for Node.js. Rated high severity (CVSS 7.5), this vulner

CVE-2020-13822 HIGH POC
7.7 Jun 04

The Elliptic package 6.5.2 for Node.js allows ECDSA signature malleability via variations in encoding, leading '\0' byte

CVE-2026-29181 HIGH POC
7.5 Apr 07

Resource exhaustion in OpenTelemetry Go propagation library (v1.41.0 and earlier) enables remote attackers to trigger se

CVE-2019-7303 HIGH POC
7.5 Apr 23

A vulnerability in the seccomp filters of Canonical snapd before version 2.37.4 allows a strict mode snap to insert char

CVE-2014-4699 MEDIUM POC
6.9 Jul 09

The Linux kernel before 3.15.4 on Intel processors does not properly restrict use of a non-canonical value for the saved

CVE-2017-7725 MEDIUM POC
6.1 Apr 13

concrete5 8.1.0 places incorrect trust in the HTTP Host header during caching, if the administrator did not define a "ca

CVE-2026-48816 MEDIUM POC
6.5 Jul 01

Timestamp forgery in sigstore-js allows an attacker supplying a crafted bundle v0.2 to manipulate certificate validity w

Share

CVE-2026-46690 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy