GHSA-RVR2-R3PV-5M4P

One Shot, One Kill: Race Condition UAF in Rust's `oneshot` Crate

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 27, 2026·5 min read·1 visit

Executive Summary (TL;DR)

The `oneshot` crate, a popular high-performance channel for Rust, contained a race condition in its `Drop` implementation. When an async receiver was dropped (cancelled), it signaled 'DISCONNECTED' to the sender *before* it finished cleaning up its internal Waker. If the sender reacted instantly and freed the channel memory, the receiver would subsequently attempt to access that freed memory, resulting in a Use-After-Free (UAF). Fixed in version 0.1.12.

A high-severity Use-After-Free vulnerability in the `oneshot` Rust crate allows for memory corruption during the teardown of asynchronous channels. By racing the deallocation of the Sender against the cleanup of the Receiver's Waker, an attacker can trigger a read/write to freed memory.

The Hook: Safety Off

Rust is the poster child for memory safety. It promises us a world without dangling pointers, buffer overflows, or double-free errors. But there is a dirty little secret in the high-performance Rust ecosystem: unsafe. To squeeze out every last nanosecond of latency, library authors often bypass the borrow checker manually. It's like turning off the traction control in a Ferrari—it goes faster, but if you hit a patch of ice, you're going into the wall.

The oneshot crate is one such speed demon. It provides a Single-Producer Single-Consumer (SPSC) channel designed to send exactly one message. It's lean, it's mean, and it uses shared memory state synchronization to avoid heavy locking. It's widely used in async runtimes where passing a result from a worker thread back to a main loop needs to be instantaneous.

But in version 0.1.11 and earlier, the developer made a classic concurrency mistake: signaling completion before actual completion. They unlocked the door before checking if the room was empty.

The Flaw: The 'Check-Then-Act' Trap

The vulnerability lies in how the Receiver cleans up after itself when it gets dropped. In an async context, if you are waiting on a channel (polling it) and you get cancelled (e.g., a timeout occurs), the Receiver is dropped. At this point, it needs to do two things:

  1. Tell the Sender that nobody is listening anymore (DISCONNECTED).
  2. Clean up the Waker it registered so the runtime doesn't try to wake a dead task.

The logic in the vulnerable code did exactly this, but in the wrong order. It atomically swapped the state to DISCONNECTED first. This acted as a starting gun. The Sender, potentially running on another thread, sees DISCONNECTED, realizes it's the last owner of the channel, and immediately deallocates the heap memory.

Meanwhile, back on the Receiver thread, the CPU is just moving to the next instruction: accessing the channel to drop the Waker. But that channel pointer now points to unmapped memory (or worse, memory reallocated by something else). The Receiver reaches into the void and pulls back chaos.

The Code: The Smoking Gun

Let's look at the breakdown. The code below is from the Drop implementation of Receiver. This is where the race occurs.

Vulnerable Code (< 0.1.12)

// The fatal mistake: Swapping to DISCONNECTED gives up ownership too early.
let old_state = channel.state.swap(DISCONNECTED, Acquire);
 
// If we were previously waiting (RECEIVING), we need to clean up the waker.
if old_state == RECEIVING {
    // DANGER: By the time we reach this line, the Sender might have
    // already seen DISCONNECTED and freed 'channel'.
    unsafe { channel.drop_waker() };
}

When state.swap(DISCONNECTED) executes, the Sender (if it's checking the state or dropping) gets the green light to free the underlying allocation. The Receiver effectively said "I'm done here!" while still rummaging through the drawers.

The Fix (0.1.12)

The patch in commit d1a1506010bc48962634807d0dcca682af4f50ba changes the protocol. Instead of immediately disconnecting, we first try to transition to an EMPTY state. This state tells the Sender "I'm not listening, but I'm still here, so don't touch my stuff."

// FIX: Transition to EMPTY first. This is a "holding pattern".
if channel.state.load(Relaxed) == RECEIVING
    && channel.state.compare_exchange(RECEIVING, EMPTY, Relaxed, Relaxed).is_ok()
{
    // We successfully claimed the transition. The memory is ours.
    unsafe { channel.drop_waker() };
}
 
// NOW we can tell the world we are gone.
let old_state = channel.state.swap(DISCONNECTED, Release);

By using compare_exchange to move from RECEIVING -> EMPTY, the Receiver ensures it has exclusive rights to touch the Waker before handing over the keys to the Sender.

The Exploit: Losing the Race

To exploit this, we don't need complex heap feng shui initially; we just need to lose a race condition consistently. In Rust async runtimes (like Tokio), this is trivial using timeout or select!.

Here is the recipe for disaster:

  1. Setup: Create a oneshot channel. Pass the Sender to a background thread.
  2. The Bait: On the main thread, poll the Receiver. Since the Sender hasn't sent anything, the Receiver registers a Waker and sets the state to RECEIVING.
  3. The Switch: Wrap the Receiver poll in a tokio::time::timeout(Duration::from_millis(1), ...). When the timeout fires, the Future is cancelled, triggering Receiver::drop.
  4. The Trigger: Simultaneously, on the background thread, drop the Sender. The Sender checks the state. If it sees DISCONNECTED (because the Receiver just panicked and swapped it), it frees the heap allocation.
  5. The Crash: The Receiver continues its drop logic, calls drop_waker() on the freed pointer, and crashes the process.

While a crash is the most likely outcome, in a sufficiently complex application, this UAF could lead to writing to a reallocated object, potentially allowing an attacker to corrupt vtables or function pointers if the heap allocator reuses that chunk immediately.

The Fix: Remediation

If you are using oneshot, check your Cargo.lock immediately. This isn't a vulnerability you can mitigate with a WAF or a firewall rule; it's a fundamental logic bug in the application binary.

Remediation Steps:

  1. Identify: Run cargo tree | grep oneshot to see if you are pulling in a vulnerable version (< 0.1.12).
  2. Update: Run cargo update -p oneshot to pull version 0.1.12.
  3. Verify: Ensure your Cargo.lock reflects the change.

This patch introduces zero API changes, so it is a drop-in replacement. There is no reason to delay.

Fix Analysis (1)

Technical Appendix

CVSS Score
8.1/ 10
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
EPSS Probability
0.04%
Top 100% most exploited

Affected Systems

Rust applications using 'oneshot' crate < 0.1.12Asynchronous Rust services using tokio/async-std with oneshot channels

Affected Versions Detail

Product
Affected Versions
Fixed Version
oneshot
faern
< 0.1.120.1.12
AttributeDetail
Attack VectorLocal / Concurrency
CVSS8.1 (High)
CWECWE-416 (Use After Free)
ImpactMemory Corruption, DoS, Potential RCE
ExploitabilityRace Condition (Time-sensitive)
Fix Commitd1a1506010bc48962634807d0dcca682af4f50ba
CWE-416
Use After Free

The product reuses or references memory after it has been freed.

Vulnerability Timeline

Vulnerability reported (Issue #73)
2026-01-25
Fix implemented and merged (PR #74)
2026-01-25
Version 0.1.12 released to crates.io
2026-01-25
Advisory published
2026-01-25

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.