What changed, and why it matters
This commit replaces an unbounded task queue inside the BitBox02 firmware's Rust executor with a fixed 16-slot ring buffer. It also adds a hard limit of 16 active tasks and protects queue access with critical sections so interrupt-driven code and the main loop don't corrupt the queue. The change removes a third-party queue dependency and adds unit tests. It is a defensive hardening patch: it prevents memory exhaustion from an ever-growing queue and removes allocation from interrupt paths, but it does not by itself fix a known exploitable bug.
Treat as a hardening improvement. Review firmware paths that spawn tasks to ensure the 16-task cap is not hit during normal or adversarial use (e.g., rapid U2F operations). Verify that the chosen critical-section implementation is appropriate for the target's interrupt model and that panics in spawn/schedule have safe behavior on the embedded target. Continue monitoring for any follow-up fixes that address edge cases around task cancellation or queue-full handling.
Security signals we found
Replaced unbounded queue with fixed-capacity ring buffer to prevent memory exhaustion
Removed allocation from scheduling/wake path, including interrupt context
Added critical-section synchronization between wakers and main-loop executor
Added active-task cap so exhaustion is reported from spawn, not from interrupt-context waker
Removed third-party concurrent-queue and crossbeam-utils dependencies, reducing supply-chain surface
Added unit tests for queue slot reuse and task-limit enforcement
Evidence from the diff
The bitbox-executor previously used concurrent_queue::ConcurrentQueue::unbounded(), which could grow without limit and allocated on push. The new implementation uses a const-sized array of 16 Option
Changed components
src/rust/bitbox-executor/src/lib.rssrc/rust/bitbox-executor/Cargo.tomlsrc/rust/Cargo.locktest/simulator-graphical/Cargo.locktest/simulator-graphical-bb03/Cargo.lockInspect captured patch +186 / −28
diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock
index 8031b04..72fde91 100644
--- a/src/rust/Cargo.lock
+++ b/src/rust/Cargo.lock
@@ -154,7 +154,7 @@ name = "bitbox-executor"
version = "0.1.0"
dependencies = [
"async-task",
- "concurrent-queue",
+ "critical-section",
]
[[package]]
@@ -514,15 +514,6 @@ dependencies = [
"zeroize",
]
-[[package]]
-name = "concurrent-queue"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
-dependencies = [
- "crossbeam-utils",
-]
-
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -581,12 +572,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
-[[package]]
-name = "crossbeam-utils"
-version = "0.8.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
-
[[package]]
name = "crypto-bigint"
version = "0.5.5"
diff --git a/src/rust/bitbox-executor/Cargo.toml b/src/rust/bitbox-executor/Cargo.toml
index 2bac889..3436574 100644
--- a/src/rust/bitbox-executor/Cargo.toml
+++ b/src/rust/bitbox-executor/Cargo.toml
@@ -4,5 +4,8 @@ version = "0.1.0"
edition = "2024"
[dependencies]
-async-task = { version="4.7.1", default-features=false }
-concurrent-queue = { version="2.5.0", default-features=false }
+async-task = { version = "4.7.1", default-features = false }
+critical-section = { workspace = true }
+
+[dev-dependencies]
+critical-section = { workspace = true, features = ["std"] }
diff --git a/src/rust/bitbox-executor/src/lib.rs b/src/rust/bitbox-executor/src/lib.rs
index d6df2e6..2ec7790 100644
--- a/src/rust/bitbox-executor/src/lib.rs
+++ b/src/rust/bitbox-executor/src/lib.rs
@@ -3,16 +3,110 @@
#![no_std]
use async_task::{Builder, Runnable, Task};
-use concurrent_queue::ConcurrentQueue;
+use core::cell::RefCell;
+use core::sync::atomic::{AtomicUsize, Ordering};
+use critical_section::Mutex;
+
+// There are currently three root-task sources: startup, U2F unlock, and U2F confirm. They are
+// normally serialized, so only a few slots are needed today. Reserve 16 slots for future workflows
+// and more concurrency. On the 32-bit firmware target each slot holds one 4-byte Runnable pointer,
+// so the queue buffer uses 64 bytes in the Executor.
+//
+// async-task coalesces repeated wakeups, so each active task owns at most one queued Runnable.
+// queue.len() only counts scheduled tasks; sleeping and currently running tasks are not in it.
+// Bounding all active tasks therefore also bounds the largest possible queue.
+const MAX_TASKS: usize = 16;
+
+struct Queue {
+ entries: [Option<Runnable>; MAX_TASKS],
+ head: usize,
+ len: usize,
+}
+
+impl Queue {
+ const fn new() -> Self {
+ Self {
+ entries: [const { None }; MAX_TASKS],
+ head: 0,
+ len: 0,
+ }
+ }
+
+ fn push_back(&mut self, runnable: Runnable) -> Result<(), Runnable> {
+ if self.len == MAX_TASKS {
+ return Err(runnable);
+ }
+
+ let tail = (self.head + self.len) % MAX_TASKS;
+ debug_assert!(self.entries[tail].is_none());
+ self.entries[tail] = Some(runnable);
+ self.len += 1;
+ Ok(())
+ }
+
+ fn pop_front(&mut self) -> Option<Runnable> {
+ if self.len == 0 {
+ return None;
+ }
+
+ let runnable = self.entries[self.head].take();
+ debug_assert!(runnable.is_some());
+ self.head = (self.head + 1) % MAX_TASKS;
+ self.len -= 1;
+ runnable
+ }
+}
+
+// The fixed-capacity queue prevents allocation while scheduling. This guard ensures the queue
+// cannot be exhausted and reports excess tasks from spawn() rather than from a waker that may be
+// running in interrupt context.
+struct ActiveTaskGuard(&'static AtomicUsize);
+
+impl ActiveTaskGuard {
+ fn new(active_tasks: &'static AtomicUsize) -> Self {
+ if active_tasks
+ .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |active_tasks| {
+ (active_tasks < MAX_TASKS).then_some(active_tasks + 1)
+ })
+ .is_err()
+ {
+ panic!("maximum number of executor tasks exceeded");
+ }
+ Self(active_tasks)
+ }
+}
+
+impl Drop for ActiveTaskGuard {
+ fn drop(&mut self) {
+ let previous = self.0.fetch_sub(1, Ordering::Relaxed);
+ debug_assert!(previous > 0);
+ }
+}
pub struct Executor {
- queue: ConcurrentQueue<Runnable>,
+ queue: Mutex<RefCell<Queue>>,
+ active_tasks: AtomicUsize,
}
impl Executor {
pub const fn new() -> Executor {
Executor {
- queue: ConcurrentQueue::unbounded(),
+ queue: Mutex::new(RefCell::new(Queue::new())),
+ active_tasks: AtomicUsize::new(0),
+ }
+ }
+
+ fn schedule(&self, runnable: Runnable) {
+ // Wakers can call this from interrupt context while try_tick() accesses the queue from the
+ // main loop, hence the critical section.
+ let result =
+ critical_section::with(|cs| self.queue.borrow(cs).borrow_mut().push_back(runnable));
+ if let Err(runnable) = result {
+ // A scheduled task owns this reference. Leaking it on this fatal invariant violation
+ // avoids running task destruction in interrupt context before the panic handler takes
+ // over.
+ core::mem::forget(runnable);
+ panic!("executor queue full");
}
}
@@ -20,9 +114,10 @@ impl Executor {
///
/// Running a scheduled task means simply polling its future once
pub fn try_tick(&self) -> bool {
- match self.queue.pop() {
- Err(_) => false,
- Ok(runnable) => {
+ let runnable = critical_section::with(|cs| self.queue.borrow(cs).borrow_mut().pop_front());
+ match runnable {
+ None => false,
+ Some(runnable) => {
runnable.run();
true
}
@@ -30,15 +125,30 @@ impl Executor {
}
/// Spawns a task onto the executor.
+ ///
+ /// At most 16 tasks may be active at once.
+ ///
+ /// This may allocate and must not be called from interrupt context.
pub fn spawn<T: 'static>(&'static self, future: impl Future<Output = T> + 'static) -> Task<T> {
+ let active_task_guard = ActiveTaskGuard::new(&self.active_tasks);
+
// `schedule` is the function eventually being called when `Waker.wake()` is called. The
// function schedules the task by placing the tasks Runnable into the executors queue.
- let schedule = move |runnable| self.queue.push(runnable).unwrap();
+ let schedule = move |runnable| self.schedule(runnable);
// SAFETY
// 1. `future` doesn't need to be `Send` because the firmware is single threaded
// 2. `schedule` doesn't need to be `Send` and `Sync` beause the firmware is single threaded
- let (runnable, task) = unsafe { Builder::new().spawn_unchecked(|()| future, schedule) };
+ let (runnable, task) = unsafe {
+ Builder::new().spawn_unchecked(
+ move |()| async move {
+ // Keep the task counted until its future completes or is cancelled.
+ let _active_task_guard = active_task_guard;
+ future.await
+ },
+ schedule,
+ )
+ };
// Schedule the task once to get started
runnable.schedule();
@@ -51,3 +161,63 @@ impl Default for Executor {
Executor::new()
}
}
+
+#[cfg(test)]
+mod tests {
+ extern crate std;
+
+ use super::*;
+ use core::future::{pending, poll_fn};
+ use core::task::Poll;
+ use std::boxed::Box;
+ use std::panic::{AssertUnwindSafe, catch_unwind};
+ use std::vec::Vec;
+
+ fn executor() -> &'static Executor {
+ Box::leak(Box::new(Executor::new()))
+ }
+
+ #[test]
+ fn test_try_tick_reuses_queue_slots() {
+ const NUM_WAKES: usize = 100;
+
+ let executor = executor();
+ let poll_count: &'static AtomicUsize = Box::leak(Box::new(AtomicUsize::new(0)));
+ executor
+ .spawn(poll_fn(move |cx| {
+ let count = poll_count.load(Ordering::Relaxed);
+ if count == NUM_WAKES {
+ Poll::Ready(())
+ } else {
+ poll_count.store(count + 1, Ordering::Relaxed);
+ cx.waker().wake_by_ref();
+ Poll::Pending
+ }
+ }))
+ .detach();
+
+ for _ in 0..=NUM_WAKES {
+ assert!(executor.try_tick());
+ }
+ assert!(!executor.try_tick());
+ assert_eq!(poll_count.load(Ordering::Relaxed), NUM_WAKES);
+ }
+
+ #[test]
+ fn test_spawn_task_limit() {
+ let executor = executor();
+ let tasks = (0..MAX_TASKS)
+ .map(|_| executor.spawn(pending::<()>()))
+ .collect::<Vec<_>>();
+
+ let result = catch_unwind(AssertUnwindSafe(|| executor.spawn(pending::<()>())));
+ assert!(result.is_err());
+
+ drop(tasks);
+ for _ in 0..MAX_TASKS {
+ assert!(executor.try_tick());
+ }
+ assert!(!executor.try_tick());
+ assert_eq!(executor.active_tasks.load(Ordering::Relaxed), 0);
+ }
+}
diff --git a/test/simulator-graphical-bb03/Cargo.lock b/test/simulator-graphical-bb03/Cargo.lock
index 0ce5fd4..1573f20 100644
--- a/test/simulator-graphical-bb03/Cargo.lock
+++ b/test/simulator-graphical-bb03/Cargo.lock
@@ -360,7 +360,7 @@ name = "bitbox-executor"
version = "0.1.0"
dependencies = [
"async-task",
- "concurrent-queue",
+ "critical-section",
]
[[package]]
diff --git a/test/simulator-graphical/Cargo.lock b/test/simulator-graphical/Cargo.lock
index 36e70dc..6e42e8c 100644
--- a/test/simulator-graphical/Cargo.lock
+++ b/test/simulator-graphical/Cargo.lock
@@ -322,7 +322,7 @@ name = "bitbox-executor"
version = "0.1.0"
dependencies = [
"async-task",
- "concurrent-queue",
+ "critical-section",
]
[[package]]
Why this scored 42/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.