Skip to main content

detcore/
exit_dependencies.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Descriptors that would make a guest the server another guest's exit waits
10//! for, refused while process exits complete asynchronously.
11//!
12//! With `BackendCapabilities::process_exits_complete_asynchronously`, Detcore
13//! selects no turn between a process's exit grant and its physical exit. A native exit that needs another guest to act therefore
14//! never completes. Coord rulings A and D.1 (dev-hermit
15//! ai_docs/transient/liteinst-inguest-exit-control-design-20261006.md) refuse,
16//! by name, the two descriptors that make a guest such a server:
17//!
18//! - a FUSE or CUSE device (`/dev/fuse` or `/dev/cuse`): the exiting guest's
19//!   close of a file that guest serves sends a synchronous flush to it.
20//!   Recognised by the character device's number (misc major 10, minors 229
21//!   and 203), never by path. A plain file on a FUSE filesystem is not
22//!   refused: when no guest holds the device, the server is outside the guest
23//!   set.
24//! - a seccomp user-notification listener: an exit-time system call the
25//!   listener's filter traps waits for the listener's reply. Recognised by the
26//!   descriptor's anon inode, `anon_inode:seccomp notify`, never by a name.
27//!   Creating one (`SECCOMP_FILTER_FLAG_NEW_LISTENER`) is already refused,
28//!   because guests cannot install filters; an inherited filter with no
29//!   guest-held listener stays allowed.
30//!
31//! A descriptor is checked where it arrives: from an open, through
32//! `SCM_RIGHTS` (`recvmsg`, `recvmmsg`) or through `pidfd_getfd`
33//! (`Detcore::refuse_exit_dependencies`). The in-guest runtime checks the
34//! descriptors a process starts with ([`descriptor_exit_dependency`] on
35//! `/proc/self/fd`). userfaultfd is not refused (ruling D.2): the reference
36//! backend supports it, and a stuck exit is caught by the backend's watchdog.
37//!
38//! Detcore's refusal names the capability and the descriptor, never a backend.
39//! The backend layer, which knows which backend runs and what to try instead,
40//! adds that through [`set_backend_advice`].
41
42use std::os::fd::RawFd;
43use std::path::Path;
44
45use detcore_model::HERMIT_POLICY_REFUSAL_EXIT;
46use reverie::Guest;
47use reverie::syscalls::Syscall;
48use reverie::syscalls::SyscallInfo;
49use reverie::syscalls::Sysno;
50
51use crate::record_or_replay::RecordOrReplay;
52use crate::syscalls::scm_rights::delivered_descriptors;
53use crate::tool_global::unrecoverable_shutdown;
54use crate::tool_local::Detcore;
55
56/// What the backend layer adds to a refusal; see [`set_backend_advice`].
57static BACKEND_ADVICE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
58
59/// Sets the sentence a refusal ends with: which backend refused, and what to
60/// run instead. The backend layer calls it once, before Detcore runs; a later
61/// call is ignored.
62pub fn set_backend_advice(advice: &'static str) {
63    let _ = BACKEND_ADVICE.set(advice);
64}
65
66/// The refusal for a guest holding `what` at descriptor `fd`, which arrived as
67/// `how`.
68fn refusal(what: &str, fd: RawFd, how: &str) -> String {
69    let mut refusal = format!(
70        "hermit: refusing a guest that holds {what} (descriptor {fd}, {how}): this backend \
71         completes process exits asynchronously, so no other guest runs until an exit is \
72         complete, and a guest serving that descriptor could make another guest's exit wait \
73         forever."
74    );
75    if let Some(advice) = BACKEND_ADVICE.get() {
76        refusal.push(' ');
77        refusal.push_str(advice);
78    }
79    refusal
80}
81
82/// The misc character devices' major number.
83const MISC_MAJOR: u64 = 10;
84/// `/dev/fuse` (`FUSE_MINOR` in the kernel's `miscdevice.h`).
85const FUSE_MINOR: u64 = 229;
86/// `/dev/cuse` (`CUSE_MINOR`).
87const CUSE_MINOR: u64 = 203;
88
89/// What `readlink` reports for a seccomp user-notification listener.
90pub const SECCOMP_NOTIFY_LINK: &str = "anon_inode:seccomp notify";
91
92/// The major and minor numbers of a `dev_t`, as glibc's `major` and `minor`.
93fn device_numbers(rdev: u64) -> (u64, u64) {
94    let major = ((rdev >> 8) & 0xfff) | ((rdev >> 32) & !0xfff);
95    let minor = (rdev & 0xff) | ((rdev >> 12) & !0xff);
96    (major, minor)
97}
98
99/// What a character device numbered `rdev` would make its holder serve, or
100/// `None`.
101pub fn device_exit_dependency(rdev: u64) -> Option<&'static str> {
102    match device_numbers(rdev) {
103        (MISC_MAJOR, FUSE_MINOR) => Some("a FUSE device (/dev/fuse)"),
104        (MISC_MAJOR, CUSE_MINOR) => Some("a CUSE device (/dev/cuse)"),
105        _ => None,
106    }
107}
108
109/// What the descriptor at `fd_link`, a `/proc/<pid>/fd/<n>` entry, would make
110/// its holder serve, or `None`. A descriptor that cannot be read is not
111/// refused.
112pub fn descriptor_exit_dependency(fd_link: &Path) -> Option<&'static str> {
113    // Raw readlinkat and newfstatat (see crate::util::raw_syscall): the
114    // in-guest runtime runs this inside the guest before Detcore installs.
115    descriptor_exit_dependency_with(fd_link, |number, args| {
116        // SAFETY: the shared inspection below supplies its live Tool buffers
117        // for these synchronous read-only calls.
118        let result =
119            unsafe { crate::util::raw_syscall(number as libc::c_long, args.map(|arg| arg as u64)) };
120        reverie::Errno::from_ret(result as usize)
121    })
122}
123
124/// The same inspection for startup and callbacks. Only the instruction used
125/// for the two Tool-side syscalls differs; neither is a guest injection.
126fn descriptor_exit_dependency_with(
127    fd_link: &Path,
128    mut execute: impl FnMut(Sysno, [usize; 6]) -> Result<usize, reverie::Errno>,
129) -> Option<&'static str> {
130    use std::os::unix::ffi::OsStrExt;
131
132    let link = std::ffi::CString::new(fd_link.as_os_str().as_bytes()).ok()?;
133    let mut target = [0_u8; 64];
134    let length = execute(
135        Sysno::readlinkat,
136        [
137            libc::AT_FDCWD as usize,
138            link.as_ptr() as usize,
139            target.as_mut_ptr() as usize,
140            target.len(),
141            0,
142            0,
143        ],
144    );
145    if let Ok(length) = length
146        && &target[..length] == SECCOMP_NOTIFY_LINK.as_bytes()
147    {
148        return Some("a seccomp user-notification listener");
149    }
150    // Follows the descriptor to the file it refers to.
151    let mut metadata: libc::stat = unsafe { std::mem::zeroed() };
152    let status = execute(
153        crate::arch::FSTATAT,
154        [
155            libc::AT_FDCWD as usize,
156            link.as_ptr() as usize,
157            (&raw mut metadata) as usize,
158            0,
159            0,
160            0,
161        ],
162    );
163    if status != Ok(0) || metadata.st_mode & libc::S_IFMT != libc::S_IFCHR {
164        return None;
165    }
166    device_exit_dependency(metadata.st_rdev)
167}
168
169/// The first descriptor listed in `fd_directory` (a `/proc/<pid>/fd`
170/// directory) that would make its holder serve another guest's exit: its name
171/// in the directory and what it is. The in-guest runtime checks
172/// `/proc/self/fd` with it before installing Detcore. A directory that cannot
173/// be listed is an error, never a pass. Listed with
174/// [`crate::util::find_in_directory`]: this runs inside the guest, where
175/// `std::fs::read_dir` would leave the listing in the guest's heap.
176pub fn held_exit_dependency(
177    fd_directory: &Path,
178) -> std::io::Result<Option<(String, &'static str)>> {
179    crate::util::find_in_directory(fd_directory, |name| {
180        descriptor_exit_dependency(&fd_directory.join(name))
181            .map(|what| (name.to_string_lossy().into_owned(), what))
182    })
183}
184
185impl<T: RecordOrReplay> Detcore<T> {
186    /// [`Self::refuse_exit_dependencies`] with the call's memory as the
187    /// kernel's view of it holds it (`EntryImport::reinstate_returned`), when
188    /// another thread has rewritten it since (`ThreadState::entry_import`),
189    /// and that rewrite restored after: it reads a received message's
190    /// descriptors from the call's header. Fails only if the rewrite cannot
191    /// be restored.
192    pub(crate) async fn refuse_exit_dependencies_in_view<G: Guest<Self>>(
193        &self,
194        guest: &mut G,
195        call: &Syscall,
196        ret: i64,
197    ) -> Result<(), reverie::Error> {
198        let entry = crate::entry_view(guest, call);
199        let view = entry
200            .as_deref()
201            .map(|entry| (entry, entry.reinstate_returned(&mut guest.memory())));
202        self.refuse_exit_dependencies(guest, call, ret).await;
203        crate::restore_entry_view(guest, view)
204    }
205
206    /// Ends the run, by name, when `call`, which returned `ret`, gave the guest
207    /// a descriptor that would make it serve another guest's exit; see the
208    /// module documentation. Does nothing unless process exits complete
209    /// asynchronously.
210    pub(crate) async fn refuse_exit_dependencies<G: Guest<Self>>(
211        &self,
212        guest: &mut G,
213        call: &Syscall,
214        ret: i64,
215    ) {
216        if !self.cfg.backend.process_exits_complete_asynchronously || ret < 0 {
217            return;
218        }
219        let (arrived, how): (Vec<RawFd>, &str) = match call {
220            Syscall::Openat(_) => (vec![ret as RawFd], "opened"),
221            #[cfg(target_arch = "x86_64")]
222            Syscall::Open(_) => (vec![ret as RawFd], "opened"),
223            _ if call.number() == Sysno::openat2 => (vec![ret as RawFd], "opened"),
224            _ if call.number() == Sysno::pidfd_getfd => {
225                (vec![ret as RawFd], "received through pidfd_getfd")
226            }
227            Syscall::Recvmsg(_) | Syscall::Recvmmsg(_) => {
228                let Some(received) = delivered_descriptors(&guest.memory(), call, ret) else {
229                    return;
230                };
231                (received, "received through SCM_RIGHTS")
232            }
233            _ => return,
234        };
235        let pid = guest.pid().as_raw();
236        for fd in arrived {
237            let Some(what) = descriptor_exit_dependency_with(
238                Path::new(&format!("/proc/{pid}/fd/{fd}")),
239                |number, args| {
240                    // SAFETY: readlinkat/newfstatat borrow only this inspection's
241                    // live Tool-owned path and output buffers. Their operands
242                    // name the same native procfs entry as the startup path;
243                    // they retain no pointers and return on the original stack.
244                    unsafe { guest.tool_syscall(number, args) }
245                },
246            ) else {
247                continue;
248            };
249            eprintln!("{}", refusal(what, fd, how));
250            unrecoverable_shutdown(guest, HERMIT_POLICY_REFUSAL_EXIT).await
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use std::os::fd::AsRawFd;
258
259    use super::*;
260    use crate::syscalls::scm_rights::received_descriptors;
261
262    /// glibc's `makedev`.
263    fn makedev(major: u64, minor: u64) -> u64 {
264        ((major & 0xfff) << 8) | ((major & !0xfff) << 32) | (minor & 0xff) | ((minor & !0xff) << 12)
265    }
266
267    /// A transport error does not change the inspection policy: a failed
268    /// readlink still permits the stat check, and neither call is retried.
269    #[test]
270    fn descriptor_inspection_keeps_operands_results_and_failure_order() {
271        use reverie::Errno;
272
273        let cases = [
274            (
275                Ok(SECCOMP_NOTIFY_LINK.as_bytes()),
276                Ok(0),
277                libc::S_IFCHR,
278                makedev(10, 229),
279                Some("a seccomp user-notification listener"),
280                1,
281            ),
282            (
283                Ok(b"/dev/ordinary".as_slice()),
284                Ok(0),
285                libc::S_IFCHR,
286                makedev(10, 229),
287                Some("a FUSE device (/dev/fuse)"),
288                2,
289            ),
290            (
291                Ok(b"/dev/ordinary".as_slice()),
292                Ok(0),
293                libc::S_IFCHR,
294                makedev(10, 203),
295                Some("a CUSE device (/dev/cuse)"),
296                2,
297            ),
298            (
299                Err(Errno::EACCES),
300                Ok(0),
301                libc::S_IFCHR,
302                makedev(10, 229),
303                Some("a FUSE device (/dev/fuse)"),
304                2,
305            ),
306            (
307                Err(Errno::EINTR),
308                Ok(0),
309                libc::S_IFREG,
310                makedev(10, 229),
311                None,
312                2,
313            ),
314            (
315                Ok(b"anon_inode:seccomp notify suffix".as_slice()),
316                Ok(0),
317                libc::S_IFCHR,
318                makedev(10, 232),
319                None,
320                2,
321            ),
322            (
323                Ok(b"".as_slice()),
324                Err(Errno::EINTR),
325                libc::S_IFCHR,
326                makedev(10, 229),
327                None,
328                2,
329            ),
330            (
331                Err(Errno::ENOENT),
332                Err(Errno::EBADF),
333                libc::S_IFCHR,
334                makedev(10, 229),
335                None,
336                2,
337            ),
338            (
339                Ok(b"/dev/ordinary".as_slice()),
340                Ok(1),
341                libc::S_IFCHR,
342                makedev(10, 229),
343                None,
344                2,
345            ),
346            (
347                Ok(&[b'x'; 64]),
348                Ok(0),
349                libc::S_IFCHR,
350                makedev(10, 203),
351                Some("a CUSE device (/dev/cuse)"),
352                2,
353            ),
354        ];
355        for (link_result, stat_result, mode, device, expected, expected_calls) in cases {
356            let mut calls = 0;
357            let mut path_address = None;
358            let actual =
359                descriptor_exit_dependency_with(Path::new("/proc/123/fd/456"), |number, args| {
360                    calls += 1;
361                    assert_eq!(args[0], libc::AT_FDCWD as usize);
362                    assert_eq!(&args[4..], &[0, 0]);
363                    // SAFETY: the helper lends a live NUL-terminated path to
364                    // this synchronous executor, just as to the kernel.
365                    let path = unsafe { std::ffi::CStr::from_ptr(args[1] as *const libc::c_char) };
366                    assert_eq!(path.to_bytes(), b"/proc/123/fd/456");
367                    if let Some(address) = path_address {
368                        assert_eq!(args[1], address);
369                    } else {
370                        path_address = Some(args[1]);
371                    }
372                    match number {
373                        Sysno::readlinkat => {
374                            assert_eq!(calls, 1);
375                            assert_eq!(args[3], 64);
376                            let bytes = link_result?;
377                            // SAFETY: the helper lends its 64-byte output,
378                            // and every case fits. No pointer is retained.
379                            unsafe {
380                                std::ptr::copy_nonoverlapping(
381                                    bytes.as_ptr(),
382                                    args[2] as *mut u8,
383                                    bytes.len(),
384                                );
385                            }
386                            Ok(bytes.len())
387                        }
388                        Sysno::newfstatat => {
389                            assert_eq!(calls, 2);
390                            assert_eq!(args[3], 0, "follow the descriptor's target");
391                            // Also fill on failure: a failed stat must not
392                            // accidentally classify stale output as success.
393                            // SAFETY: the helper lends a valid aligned stat.
394                            unsafe {
395                                let output = &mut *(args[2] as *mut libc::stat);
396                                output.st_mode = mode;
397                                output.st_rdev = device;
398                            }
399                            stat_result
400                        }
401                        other => panic!("unexpected Tool syscall {other:?}"),
402                    }
403                });
404            assert_eq!(actual, expected);
405            assert_eq!(calls, expected_calls);
406        }
407    }
408
409    #[test]
410    fn an_invalid_descriptor_path_executes_no_inspection() {
411        use std::os::unix::ffi::OsStrExt;
412
413        let path = Path::new(std::ffi::OsStr::from_bytes(b"/proc/self/fd/1\0suffix"));
414        assert_eq!(
415            descriptor_exit_dependency_with(path, |_, _| panic!("invalid path reached executor")),
416            None
417        );
418    }
419
420    #[test]
421    fn only_the_fuse_and_cuse_devices_are_exit_dependencies() {
422        assert_eq!(
423            device_exit_dependency(makedev(10, 229)),
424            Some("a FUSE device (/dev/fuse)")
425        );
426        assert_eq!(
427            device_exit_dependency(makedev(10, 203)),
428            Some("a CUSE device (/dev/cuse)")
429        );
430        // Other misc devices: /dev/kvm, /dev/net/tun, a dynamic minor such as
431        // /dev/userfaultfd's, and the same minors under another major.
432        for (major, minor) in [
433            (10, 232),
434            (10, 200),
435            (10, 125),
436            (1, 229),
437            (4, 203),
438            (259, 229),
439        ] {
440            assert_eq!(
441                device_exit_dependency(makedev(major, minor)),
442                None,
443                "{major}:{minor}"
444            );
445        }
446        // Large numbers survive the split encoding.
447        assert_eq!(device_numbers(makedev(4095, 1 << 19)), (4095, 1 << 19));
448    }
449
450    #[test]
451    fn ordinary_descriptors_are_not_exit_dependencies() {
452        let file = tempfile::tempfile().unwrap();
453        let null = std::fs::File::open("/dev/null").unwrap();
454        let (left, _right) = std::os::unix::net::UnixStream::pair().unwrap();
455        for fd in [file.as_raw_fd(), null.as_raw_fd(), left.as_raw_fd()] {
456            assert_eq!(
457                descriptor_exit_dependency(Path::new(&format!("/proc/self/fd/{fd}"))),
458                None,
459                "fd {fd}"
460            );
461        }
462        // A closed descriptor reads nothing and is not refused.
463        assert_eq!(
464            descriptor_exit_dependency(Path::new("/proc/self/fd/987654")),
465            None
466        );
467    }
468
469    #[test]
470    fn a_received_seccomp_listener_is_recognised_by_its_anon_inode() {
471        // A child installs an allow-everything filter with a listener and sends
472        // the listener over SCM_RIGHTS; the filter stays in the child. Only raw
473        // system calls on buffers prepared before the fork run in the child.
474        const SECCOMP_SET_MODE_FILTER: libc::c_ulong = 1;
475        const SECCOMP_FILTER_FLAG_NEW_LISTENER: libc::c_ulong = 1 << 3;
476        const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
477        let (sender, receiver) = std::os::unix::net::UnixDatagram::pair().unwrap();
478        let filter = [libc::sock_filter {
479            code: (libc::BPF_RET | libc::BPF_K) as u16,
480            jt: 0,
481            jf: 0,
482            k: SECCOMP_RET_ALLOW,
483        }];
484        let program = libc::sock_fprog {
485            len: 1,
486            filter: filter.as_ptr().cast_mut(),
487        };
488        let mut payload = [0u8; 1];
489        let mut iov = libc::iovec {
490            iov_base: payload.as_mut_ptr().cast(),
491            iov_len: 1,
492        };
493        let mut control = [0u64; 3];
494        let mut message: libc::msghdr = unsafe { std::mem::zeroed() };
495        message.msg_iov = &mut iov;
496        message.msg_iovlen = 1;
497        message.msg_control = control.as_mut_ptr().cast();
498        message.msg_controllen = 24;
499        // SAFETY: the child makes only raw system calls and writes into
500        // buffers that exist before the fork, then _exits.
501        let child = unsafe { libc::fork() };
502        assert!(child >= 0, "fork: {}", std::io::Error::last_os_error());
503        if child == 0 {
504            unsafe {
505                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
506                    libc::_exit(2);
507                }
508                let listener = libc::syscall(
509                    libc::SYS_seccomp,
510                    SECCOMP_SET_MODE_FILTER,
511                    SECCOMP_FILTER_FLAG_NEW_LISTENER,
512                    &program as *const libc::sock_fprog,
513                );
514                if listener < 0 {
515                    libc::_exit(3);
516                }
517                let header = control.as_mut_ptr().cast::<libc::cmsghdr>();
518                (*header).cmsg_len = 20;
519                (*header).cmsg_level = libc::SOL_SOCKET;
520                (*header).cmsg_type = libc::SCM_RIGHTS;
521                *control.as_mut_ptr().add(2).cast::<libc::c_int>() = listener as libc::c_int;
522                if libc::sendmsg(sender.as_raw_fd(), &message, 0) != 1 {
523                    libc::_exit(4);
524                }
525                libc::_exit(0);
526            }
527        }
528        let mut status = 0;
529        assert_eq!(unsafe { libc::waitpid(child, &mut status, 0) }, child);
530        assert_eq!(libc::WEXITSTATUS(status), 0, "child status {status:#x}");
531        let mut payload = [0u8; 1];
532        let mut iov = libc::iovec {
533            iov_base: payload.as_mut_ptr().cast(),
534            iov_len: 1,
535        };
536        let mut control = [0u8; 64];
537        let mut message: libc::msghdr = unsafe { std::mem::zeroed() };
538        message.msg_iov = &mut iov;
539        message.msg_iovlen = 1;
540        message.msg_control = control.as_mut_ptr().cast();
541        // This fixture calls the host libc wrapper, whose length is u32 on
542        // musl and usize on GNU; production guest buffers use the kernel ABI.
543        #[cfg(target_env = "musl")]
544        {
545            message.msg_controllen = control.len().try_into().unwrap();
546        }
547        #[cfg(not(target_env = "musl"))]
548        {
549            message.msg_controllen = control.len();
550        }
551        let received =
552            unsafe { libc::recvmsg(receiver.as_raw_fd(), &mut message, libc::MSG_CMSG_CLOEXEC) };
553        assert_eq!(received, 1, "recvmsg: {}", std::io::Error::last_os_error());
554        #[cfg(target_env = "musl")]
555        let control_len = usize::try_from(message.msg_controllen).unwrap();
556        #[cfg(not(target_env = "musl"))]
557        let control_len = message.msg_controllen;
558        let descriptors = received_descriptors(&control[..control_len]);
559        assert_eq!(descriptors.len(), 1, "{descriptors:?}");
560        let listener = unsafe {
561            <std::os::fd::OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(descriptors[0])
562        };
563        assert_eq!(
564            descriptor_exit_dependency(Path::new(&format!(
565                "/proc/self/fd/{}",
566                listener.as_raw_fd()
567            ))),
568            Some("a seccomp user-notification listener")
569        );
570    }
571
572    #[test]
573    fn a_fuse_device_descriptor_is_recognised_by_its_number() {
574        let Ok(fuse) = std::fs::File::open("/dev/fuse") else {
575            eprintln!("skipping: /dev/fuse cannot be opened on this host");
576            return;
577        };
578        assert_eq!(
579            descriptor_exit_dependency(Path::new(&format!("/proc/self/fd/{}", fuse.as_raw_fd()))),
580            Some("a FUSE device (/dev/fuse)")
581        );
582        // The startup scan finds it among this process's descriptors.
583        assert_eq!(
584            held_exit_dependency(Path::new("/proc/self/fd")).unwrap(),
585            Some((fuse.as_raw_fd().to_string(), "a FUSE device (/dev/fuse)"))
586        );
587    }
588
589    #[test]
590    fn the_refusal_names_the_capability_and_ends_with_the_backend_advice() {
591        let text = refusal("a FUSE device (/dev/fuse)", 5, "opened");
592        assert!(
593            text.starts_with(
594                "hermit: refusing a guest that holds a FUSE device (/dev/fuse) (descriptor 5, \
595                 opened): this backend completes process exits asynchronously"
596            ),
597            "{text}"
598        );
599        set_backend_advice("Backend X refused it; try backend Y.");
600        assert!(
601            refusal("a FUSE device (/dev/fuse)", 5, "opened")
602                .ends_with("wait forever. Backend X refused it; try backend Y."),
603        );
604    }
605
606    #[test]
607    fn the_startup_scan_refuses_an_unlistable_directory() {
608        assert!(held_exit_dependency(Path::new("/nonexistent-fd-directory")).is_err());
609    }
610}