Skip to main content

evalbox_sandbox/notify/
supervisor.rs

1//! Seccomp notification supervisor.
2//!
3//! Runs in the parent process, handling intercepted syscalls from the sandboxed child.
4//! The supervisor receives notifications via the seccomp listener fd and decides
5//! how to respond based on the configured [`NotifyMode`].
6//!
7//! ## Modes
8//!
9//! - **Monitor**: Log syscall and return `SECCOMP_USER_NOTIF_FLAG_CONTINUE`
10//! - **Virtualize**: Translate filesystem paths via [`VirtualFs`], inject fds via `ADDFD`
11
12use std::fs::File;
13use std::io::{self, Read, Seek, SeekFrom};
14use std::os::fd::{AsRawFd, OwnedFd, RawFd};
15
16use evalbox_sys::seccomp_notify::{
17    SECCOMP_ADDFD_FLAG_SEND, SECCOMP_USER_NOTIF_FLAG_CONTINUE, SeccompNotif, SeccompNotifAddfd,
18    SeccompNotifResp, notif_addfd, notif_id_valid, notif_recv, notif_send,
19};
20
21use super::virtual_fs::VirtualFs;
22use crate::plan::NotifyMode;
23
24/// Events emitted by the supervisor for future user-facing notifications.
25#[derive(Debug)]
26pub enum NotifyEvent {
27    /// A syscall was intercepted and handled.
28    SyscallHandled {
29        /// PID of the process that made the syscall.
30        pid: u32,
31        /// Syscall number.
32        syscall_nr: i32,
33        /// Whether the syscall was allowed.
34        allowed: bool,
35    },
36}
37
38/// Seccomp notification supervisor.
39pub struct Supervisor {
40    listener_fd: OwnedFd,
41    mode: NotifyMode,
42    vfs: VirtualFs,
43}
44
45impl Supervisor {
46    /// Create a new supervisor.
47    pub fn new(listener_fd: OwnedFd, mode: NotifyMode, vfs: VirtualFs) -> Self {
48        Self {
49            listener_fd,
50            mode,
51            vfs,
52        }
53    }
54
55    /// Get the raw fd for registering with poll/mio.
56    pub fn fd(&self) -> RawFd {
57        self.listener_fd.as_raw_fd()
58    }
59
60    /// Handle a notification event. Call when the listener fd is readable.
61    ///
62    /// Returns `Some(NotifyEvent)` on success, `None` if the notification was
63    /// stale (child died or already handled).
64    pub fn handle_event(&self) -> io::Result<Option<NotifyEvent>> {
65        let mut notif = SeccompNotif::default();
66
67        if let Err(e) = notif_recv(self.listener_fd.as_raw_fd(), &mut notif) {
68            // ENOENT means the target process died before we could receive
69            if e == rustix::io::Errno::NOENT {
70                return Ok(None);
71            }
72            return Err(io::Error::from_raw_os_error(e.raw_os_error()));
73        }
74
75        match self.mode {
76            NotifyMode::Disabled => {
77                debug_assert!(
78                    false,
79                    "supervisor received notification with NotifyMode::Disabled"
80                );
81                self.respond_continue(&notif)?;
82                Ok(None)
83            }
84            NotifyMode::Monitor => self.handle_monitor(&notif),
85            NotifyMode::Virtualize => self.handle_virtualize(&notif),
86        }
87    }
88
89    fn handle_monitor(&self, notif: &SeccompNotif) -> io::Result<Option<NotifyEvent>> {
90        let syscall_name = syscall_name(notif.data.nr);
91        eprintln!(
92            "[notify] pid={} syscall={}({}) args=[{:#x}, {:#x}, {:#x}]",
93            notif.pid,
94            syscall_name,
95            notif.data.nr,
96            notif.data.args[0],
97            notif.data.args[1],
98            notif.data.args[2],
99        );
100
101        self.respond_continue(notif)?;
102
103        Ok(Some(NotifyEvent::SyscallHandled {
104            pid: notif.pid,
105            syscall_nr: notif.data.nr,
106            allowed: true,
107        }))
108    }
109
110    // Casts are safe: SYS_* constants fit i32; args values are kernel ABI (small ints for flags/fds).
111    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
112    fn handle_virtualize(&self, notif: &SeccompNotif) -> io::Result<Option<NotifyEvent>> {
113        let syscall_nr = notif.data.nr;
114
115        // For *at()-family syscalls, args[1] is the pathname pointer.
116        // For legacy syscalls (open/creat, x86_64 only), args[0] is the pathname pointer.
117        let path_addr = if is_at_syscall(syscall_nr) {
118            notif.data.args[1]
119        } else {
120            notif.data.args[0]
121        };
122
123        // Read path from child's memory
124        let path = match self.read_child_string(notif.pid, path_addr) {
125            Ok(p) => p,
126            Err(_) => {
127                // Can't read memory, let syscall proceed
128                self.respond_continue(notif)?;
129                return Ok(None);
130            }
131        };
132
133        // TOCTOU check: verify notification is still valid after reading memory
134        if notif_id_valid(self.listener_fd.as_raw_fd(), notif.id).is_err() {
135            return Ok(None); // Notification is stale
136        }
137
138        // Try to translate path
139        if let Some(real_path) = self.vfs.translate(&path) {
140            // For open-family: open the file ourselves and inject the fd
141            if is_open_syscall(syscall_nr) {
142                let flags = if syscall_nr == libc::SYS_openat as i32 {
143                    notif.data.args[2] as i32
144                } else {
145                    notif.data.args[1] as i32
146                };
147
148                match self.open_and_inject(notif, &real_path, flags) {
149                    Ok(()) => {
150                        return Ok(Some(NotifyEvent::SyscallHandled {
151                            pid: notif.pid,
152                            syscall_nr,
153                            allowed: true,
154                        }));
155                    }
156                    Err(_) => {
157                        // Fall through to continue
158                    }
159                }
160            }
161        }
162
163        // No translation or non-open syscall: let it proceed as-is
164        self.respond_continue(notif)?;
165        Ok(Some(NotifyEvent::SyscallHandled {
166            pid: notif.pid,
167            syscall_nr,
168            allowed: true,
169        }))
170    }
171
172    fn respond_continue(&self, notif: &SeccompNotif) -> io::Result<()> {
173        let resp = SeccompNotifResp {
174            id: notif.id,
175            val: 0,
176            error: 0,
177            flags: SECCOMP_USER_NOTIF_FLAG_CONTINUE,
178        };
179        notif_send(self.listener_fd.as_raw_fd(), &resp)
180            .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))
181    }
182
183    // Cast is safe: fd from libc::open is a small non-negative int fitting u32.
184    #[allow(clippy::cast_sign_loss)]
185    fn open_and_inject(
186        &self,
187        notif: &SeccompNotif,
188        real_path: &std::path::Path,
189        flags: i32,
190    ) -> io::Result<()> {
191        use std::ffi::CString;
192        use std::os::unix::ffi::OsStrExt;
193
194        let path_c = CString::new(real_path.as_os_str().as_bytes())
195            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid path"))?;
196
197        // Open the file at the translated path
198        let fd = unsafe { libc::open(path_c.as_ptr(), flags & !libc::O_CLOEXEC, 0o666) };
199        if fd < 0 {
200            return Err(io::Error::last_os_error());
201        }
202
203        // Inject the fd into the child and atomically respond
204        let addfd = SeccompNotifAddfd {
205            id: notif.id,
206            flags: SECCOMP_ADDFD_FLAG_SEND,
207            srcfd: fd as u32,
208            newfd: 0,
209            newfd_flags: 0,
210        };
211
212        let result = notif_addfd(self.listener_fd.as_raw_fd(), &addfd)
213            .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()));
214
215        // Close our copy of the fd
216        unsafe { libc::close(fd) };
217
218        result.map(|_| ())
219    }
220
221    /// Read a null-terminated string from the child's memory via `/proc/pid/mem`.
222    fn read_child_string(&self, pid: u32, addr: u64) -> io::Result<String> {
223        let mem_path = format!("/proc/{pid}/mem");
224        let mut file = File::open(&mem_path)?;
225        file.seek(SeekFrom::Start(addr))?;
226
227        let mut buf = vec![0u8; 4096];
228        let n = file.read(&mut buf)?;
229        buf.truncate(n);
230
231        // Find null terminator
232        if let Some(nul_pos) = buf.iter().position(|&b| b == 0) {
233            buf.truncate(nul_pos);
234        }
235
236        String::from_utf8(buf)
237            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid UTF-8 in path"))
238    }
239}
240
241/// Returns true if this is an *at()-family syscall where args[1] is the pathname.
242#[allow(clippy::cast_possible_truncation)]
243fn is_at_syscall(nr: i32) -> bool {
244    let nr = nr as i64;
245    nr == libc::SYS_openat
246        || nr == libc::SYS_newfstatat
247        || nr == libc::SYS_faccessat
248        || nr == libc::SYS_faccessat2
249        || nr == libc::SYS_readlinkat
250}
251
252/// Returns true if this is an open-family syscall (fd injection target).
253#[allow(clippy::cast_possible_truncation)]
254fn is_open_syscall(nr: i32) -> bool {
255    let nr = nr as i64;
256    if nr == libc::SYS_openat {
257        return true;
258    }
259    #[cfg(target_arch = "x86_64")]
260    if nr == libc::SYS_open || nr == libc::SYS_creat {
261        return true;
262    }
263    false
264}
265
266/// Map syscall number to name for logging.
267// Cast is safe: i32 syscall number to i64 is always lossless.
268#[allow(clippy::cast_possible_truncation)]
269fn syscall_name(nr: i32) -> &'static str {
270    match nr as i64 {
271        libc::SYS_openat => "openat",
272        libc::SYS_faccessat => "faccessat",
273        libc::SYS_faccessat2 => "faccessat2",
274        libc::SYS_newfstatat => "newfstatat",
275        libc::SYS_statx => "statx",
276        libc::SYS_readlinkat => "readlinkat",
277        #[cfg(target_arch = "x86_64")]
278        libc::SYS_open => "open",
279        #[cfg(target_arch = "x86_64")]
280        libc::SYS_creat => "creat",
281        #[cfg(target_arch = "x86_64")]
282        libc::SYS_access => "access",
283        #[cfg(target_arch = "x86_64")]
284        libc::SYS_stat => "stat",
285        #[cfg(target_arch = "x86_64")]
286        libc::SYS_lstat => "lstat",
287        #[cfg(target_arch = "x86_64")]
288        libc::SYS_readlink => "readlink",
289        _ => "unknown",
290    }
291}
292
293#[cfg(test)]
294#[allow(clippy::cast_possible_truncation)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn syscall_names() {
300        assert_eq!(syscall_name(libc::SYS_openat as i32), "openat");
301        assert_eq!(syscall_name(libc::SYS_newfstatat as i32), "newfstatat");
302        assert_eq!(syscall_name(9999), "unknown");
303    }
304}