From b6ad813b60b8f9c980963b5ffb3745768386d88d Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Thu, 5 Nov 2026 10:55:05 -0800 Subject: [PATCH 1/3] deps: require tokio 1.53.3 RUSTSEC-2026-0386 marks tokio's safe AsyncFd constructors unsound: they accept any AsRawFd, so a registration can outlive the descriptor it names. The fixed releases deprecate them in favour of the unsafe AsyncFd::register, which exists only from 1.51.6 and 1.53.3 on (1.52 is not patched), so the crates that register descriptors must require it. Signed-off-by: Cong Wang --- Cargo.lock | 4 ++-- crates/sandlock-core/Cargo.toml | 2 +- crates/sandlock-oci/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bdc1855..67e357bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1815,9 +1815,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index 039f846e..21d9275b 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -14,7 +14,7 @@ libc = "0.2.191" log = "0.4" syscalls = { version = "0.8", default-features = false } nix = { version = "0.29", features = ["dir", "user"] } -tokio = { version = "1", features = ["rt", "net", "time", "sync", "macros", "io-util", "fs"] } +tokio = { version = "1.53.3", features = ["rt", "net", "time", "sync", "macros", "io-util", "fs"] } tokio-util = "0.7" serde = { version = "1", features = ["derive"] } thiserror = "2" diff --git a/crates/sandlock-oci/Cargo.toml b/crates/sandlock-oci/Cargo.toml index 2738081a..a9075d23 100644 --- a/crates/sandlock-oci/Cargo.toml +++ b/crates/sandlock-oci/Cargo.toml @@ -28,7 +28,7 @@ http = ["sandlock-core/http"] sandlock-core = { version = "0.9.1", path = "../sandlock-core", default-features = false } anyhow = "1" clap = { version = "4", features = ["derive"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util"] } +tokio = { version = "1.53.3", features = ["rt-multi-thread", "macros", "net", "io-util"] } serde = { version = "1", features = ["derive"] } serde_json = "1" libc = "0.2" From 93dcaa8e6f0599918c1fda941ae7c8b93c8efcb6 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Thu, 5 Nov 2026 10:55:47 -0800 Subject: [PATCH 2/3] async: register owned descriptors with AsyncFd::register tokio deprecates the safe AsyncFd constructors because nothing ties the registration to the descriptor's lifetime. Every site here hands the AsyncFd an owned fd, or an Arc that keeps it open, so the unsafe constructor's contract holds by construction and the SAFETY notes say which. Signed-off-by: Cong Wang --- crates/sandlock-core/src/control.rs | 6 ++- crates/sandlock-core/src/netlink/proxy.rs | 3 +- .../sandlock-core/src/network/send_engine.rs | 7 +++- crates/sandlock-core/src/sandbox.rs | 12 +++--- crates/sandlock-core/src/seccomp/notif.rs | 39 +++++++++++-------- crates/sandlock-oci/src/supervisor.rs | 4 +- 6 files changed, 43 insertions(+), 28 deletions(-) diff --git a/crates/sandlock-core/src/control.rs b/crates/sandlock-core/src/control.rs index 78394730..db87befa 100644 --- a/crates/sandlock-core/src/control.rs +++ b/crates/sandlock-core/src/control.rs @@ -224,7 +224,8 @@ fn peer_cred(fd: RawFd) -> Option { fn into_async(fd: ControlFd) -> Option> { fd.set_nonblocking().ok()?; - AsyncFd::new(fd).ok() + // SAFETY: the AsyncFd owns the ControlFd, which closes only on drop. + unsafe { AsyncFd::register(fd) }.ok() } async fn accept(listener: &AsyncFd) -> std::io::Result { @@ -311,7 +312,8 @@ async fn control_loop( if peer_cred(stream.as_raw_fd()).map(|c| c.uid) != Some(my_uid) { continue; } - let Ok(stream) = AsyncFd::new(stream).map(ControlStream) else { + // SAFETY: the AsyncFd owns the ControlFd, which closes only on drop. + let Ok(stream) = unsafe { AsyncFd::register(stream) }.map(ControlStream) else { continue; }; let _ = tokio::time::timeout( diff --git a/crates/sandlock-core/src/netlink/proxy.rs b/crates/sandlock-core/src/netlink/proxy.rs index a7ef4091..72dcba46 100644 --- a/crates/sandlock-core/src/netlink/proxy.rs +++ b/crates/sandlock-core/src/netlink/proxy.rs @@ -46,7 +46,8 @@ pub fn spawn_responder( } async fn responder_loop(fd: OwnedFd, reply_pid: u32) -> std::io::Result<()> { - let async_fd = AsyncFd::with_interest(fd, Interest::READABLE)?; + // SAFETY: the AsyncFd owns the fd. + let async_fd = unsafe { AsyncFd::register_with_interest(fd, Interest::READABLE) }?; let mut buf = vec![0u8; RECV_BUF]; loop { diff --git a/crates/sandlock-core/src/network/send_engine.rs b/crates/sandlock-core/src/network/send_engine.rs index 398b9156..cba834bb 100644 --- a/crates/sandlock-core/src/network/send_engine.rs +++ b/crates/sandlock-core/src/network/send_engine.rs @@ -168,8 +168,11 @@ async fn push_until_done( flags: i32, mut offset: usize, ) -> Result { - let afd = tokio::io::unix::AsyncFd::with_interest(dup_fd, tokio::io::Interest::WRITABLE) - .map_err(|_| libc::EIO)?; + // SAFETY: the AsyncFd owns the fd. + let afd = unsafe { + tokio::io::unix::AsyncFd::register_with_interest(dup_fd, tokio::io::Interest::WRITABLE) + } + .map_err(|_| libc::EIO)?; loop { let mut guard = afd.writable().await.map_err(|_| libc::EIO)?; let ret = send_materialized_at( diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 5742e9ec..8db65cf1 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -3587,11 +3587,13 @@ async fn wait_child_exit_via_pidfd( ) -> crate::result::ExitStatus { use crate::result::ExitStatus; - let async_fd = - match tokio::io::unix::AsyncFd::with_interest(pidfd, tokio::io::Interest::READABLE) { - Ok(fd) => fd, - Err(_) => return wait_child_exit_blocking(pid).await, - }; + // SAFETY: the AsyncFd owns the pidfd. + let async_fd = match unsafe { + tokio::io::unix::AsyncFd::register_with_interest(pidfd, tokio::io::Interest::READABLE) + } { + Ok(fd) => fd, + Err(_) => return wait_child_exit_blocking(pid).await, + }; loop { // pidfd becomes readable when the process exits; no data is read. diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 55ce3262..54c77b24 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -3369,13 +3369,16 @@ pub async fn supervisor( ) { // Register the notif fd with the Tokio IO driver so we can wait for // readiness via epoll instead of a dedicated blocking thread. - let async_fd = match tokio::io::unix::AsyncFd::with_interest( - Arc::new(notif_fd), - tokio::io::Interest::READABLE, - ) { + // SAFETY: the AsyncFd holds a strong reference, so the fd outlives it. + let async_fd = match unsafe { + tokio::io::unix::AsyncFd::register_with_interest( + Arc::new(notif_fd), + tokio::io::Interest::READABLE, + ) + } { Ok(fd) => fd, Err(err) => { - let _ = startup.send(Err(err)); + let _ = startup.send(Err(err.into())); return; } }; @@ -3499,18 +3502,20 @@ pub(crate) fn spawn_pid_watcher( pidfd: Arc, ) { tokio::spawn(async move { - let async_fd = - match tokio::io::unix::AsyncFd::with_interest(pidfd, tokio::io::Interest::READABLE) { - Ok(f) => f, - Err(_) => { - // AsyncFd registration failed (extremely unusual); - // fall back to immediate cleanup so we don't leak the - // index entry. The process is still alive, so its slot - // stays held until `release_exited_slots` sees it die. - cleanup_pid(&ctx, key).await; - return; - } - }; + // SAFETY: the AsyncFd holds a strong reference, so the fd outlives it. + let async_fd = match unsafe { + tokio::io::unix::AsyncFd::register_with_interest(pidfd, tokio::io::Interest::READABLE) + } { + Ok(f) => f, + Err(_) => { + // AsyncFd registration failed (extremely unusual); + // fall back to immediate cleanup so we don't leak the + // index entry. The process is still alive, so its slot + // stays held until `release_exited_slots` sees it die. + cleanup_pid(&ctx, key).await; + return; + } + }; // pidfd becomes readable when the process exits; we don't // read any data, so `readable()` is just an await point. let _ = async_fd.readable().await; diff --git a/crates/sandlock-oci/src/supervisor.rs b/crates/sandlock-oci/src/supervisor.rs index 47aba11f..284b58ae 100644 --- a/crates/sandlock-oci/src/supervisor.rs +++ b/crates/sandlock-oci/src/supervisor.rs @@ -813,7 +813,9 @@ fn exit_watcher(pid: i32) -> Option Date: Thu, 5 Nov 2026 10:56:06 -0800 Subject: [PATCH 3/3] async: register a dup where the waiter only borrows the fd Reaper::exit and MainExit::wait registered a raw fd borrowed from a struct, so their soundness rested on callers never dropping the owner early, and a second concurrent waiter would hit EEXIST from epoll: exit() would then return before the reaper exited. Each waiter now registers its own dup, which shares readiness with the original and keeps the AsyncFd contract local. Signed-off-by: Cong Wang --- crates/sandlock-core/src/controller.rs | 9 +++++++-- crates/sandlock-core/src/reaper.rs | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/sandlock-core/src/controller.rs b/crates/sandlock-core/src/controller.rs index 9096ec32..428c7767 100644 --- a/crates/sandlock-core/src/controller.rs +++ b/crates/sandlock-core/src/controller.rs @@ -112,8 +112,13 @@ impl MainExit { let Some(fd) = &self.pipe else { return Err(std::io::Error::other("main's exit has no status pipe")); }; - let ready = - tokio::io::unix::AsyncFd::with_interest(fd.as_raw_fd(), tokio::io::Interest::READABLE)?; + // SAFETY: the AsyncFd owns its own dup of the pipe. + let ready = unsafe { + tokio::io::unix::AsyncFd::register_with_interest( + fd.try_clone()?, + tokio::io::Interest::READABLE, + ) + }?; loop { if let Some(exit) = self.get() { return Ok(exit); diff --git a/crates/sandlock-core/src/reaper.rs b/crates/sandlock-core/src/reaper.rs index 95f6ef7d..02657612 100644 --- a/crates/sandlock-core/src/reaper.rs +++ b/crates/sandlock-core/src/reaper.rs @@ -31,7 +31,11 @@ impl Reaper { /// The reaper exits once the session has no process left. pub async fn exit(&self) { - if let Ok(fd) = tokio::io::unix::AsyncFd::new(self.pidfd.as_raw_fd()) { + let Ok(pidfd) = self.pidfd.try_clone() else { + return; + }; + // SAFETY: the AsyncFd owns its own dup of the pidfd. + if let Ok(fd) = unsafe { tokio::io::unix::AsyncFd::register(pidfd) } { let _ = fd.readable().await; } }