One Shot, One Kill: Race Condition UAF in Rust's `oneshot` Crate
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:
- Tell the
Senderthat nobody is listening anymore (DISCONNECTED). - Clean up the
Wakerit 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:
- Setup: Create a
oneshotchannel. Pass theSenderto a background thread. - The Bait: On the main thread, poll the
Receiver. Since the Sender hasn't sent anything, the Receiver registers aWakerand sets the state toRECEIVING. - The Switch: Wrap the Receiver poll in a
tokio::time::timeout(Duration::from_millis(1), ...). When the timeout fires, the Future is cancelled, triggeringReceiver::drop. - The Trigger: Simultaneously, on the background thread, drop the
Sender. TheSenderchecks the state. If it seesDISCONNECTED(because the Receiver just panicked and swapped it), it frees the heap allocation. - 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:
- Identify: Run
cargo tree | grep oneshotto see if you are pulling in a vulnerable version (< 0.1.12). - Update: Run
cargo update -p oneshotto pull version0.1.12. - Verify: Ensure your
Cargo.lockreflects the change.
This patch introduces zero API changes, so it is a drop-in replacement. There is no reason to delay.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
oneshot faern | < 0.1.12 | 0.1.12 |
| Attribute | Detail |
|---|---|
| Attack Vector | Local / Concurrency |
| CVSS | 8.1 (High) |
| CWE | CWE-416 (Use After Free) |
| Impact | Memory Corruption, DoS, Potential RCE |
| Exploitability | Race Condition (Time-sensitive) |
| Fix Commit | d1a1506010bc48962634807d0dcca682af4f50ba |
MITRE ATT&CK Mapping
The product reuses or references memory after it has been freed.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.