|
| 1 | +//! Tests for the exec entry points that name their program by file |
| 2 | +//! descriptor: `execveat` (directory fd plus relative pathname) and |
| 3 | +//! `fexecve` (program fd). Under test are the preload library's interposers |
| 4 | +//! for them, which had no coverage at all — a use-after-free in `execveat`'s |
| 5 | +//! resolved-path handling was only ever found by reading the code. |
| 6 | +//! |
| 7 | +//! The interposer reports a program's path while *resolving* it, before the |
| 8 | +//! program is opened, so a program that does not exist still produces the |
| 9 | +//! path access — no real executable needs to be staged. The exec then fails, |
| 10 | +//! the child returns from the closure, and the harness sees a clean exit. |
| 11 | +//! |
| 12 | +//! Scope: these interfaces do not exist on macOS, and on musl the preload is |
| 13 | +//! empty (the seccomp backend traces syscalls instead), so the tests cover |
| 14 | +//! the gnu preload lane only. `execveat` must be reached through the libc |
| 15 | +//! symbol — a raw `SYS_execveat` syscall (what `nix::unistd::execveat` emits) |
| 16 | +//! would bypass the interposer under test. |
| 17 | +#![cfg(all(target_os = "linux", not(target_env = "musl")))] |
| 18 | + |
| 19 | +use std::path::Path; |
| 20 | + |
| 21 | +use fspy::AccessMode; |
| 22 | +use test_log::test; |
| 23 | + |
| 24 | +use crate::test_utils::assert_contains; |
| 25 | + |
| 26 | +mod test_utils; |
| 27 | + |
| 28 | +#[test(tokio::test)] |
| 29 | +async fn execveat_resolves_and_captures_the_relative_pathname() -> anyhow::Result<()> { |
| 30 | + let tmp_dir = tempfile::tempdir()?; |
| 31 | + let dir = std::fs::canonicalize(tmp_dir.path())?; |
| 32 | + // The resolved path the interposer should report: the directory fd's path |
| 33 | + // joined with the relative pathname. The file need not exist — it is |
| 34 | + // reported during resolution, before it is opened. |
| 35 | + let expected = dir.join("ghost-program"); |
| 36 | + |
| 37 | + let accesses = track_fn!(dir.to_str().unwrap().to_owned(), |dir: String| { |
| 38 | + use std::os::fd::AsRawFd as _; |
| 39 | + |
| 40 | + let dirfd = nix::fcntl::open( |
| 41 | + dir.as_str(), |
| 42 | + nix::fcntl::OFlag::O_RDONLY | nix::fcntl::OFlag::O_DIRECTORY, |
| 43 | + nix::sys::stat::Mode::empty(), |
| 44 | + ) |
| 45 | + .expect("failed to open the directory fd"); |
| 46 | + |
| 47 | + // dirfd + relative pathname: the lane where the interposer resolves |
| 48 | + // the directory and copies the joined path — the copy that used to |
| 49 | + // dangle. Through the libc symbol so the interposer runs; expected to |
| 50 | + // fail with ENOENT, which is fine — the access is already reported. |
| 51 | + let args: [*mut libc::c_char; 2] = |
| 52 | + [c"ghost-program".as_ptr().cast_mut(), core::ptr::null_mut()]; |
| 53 | + let env: [*mut libc::c_char; 1] = [core::ptr::null_mut()]; |
| 54 | + // SAFETY: dirfd is a valid directory fd; the argument and environment |
| 55 | + // arrays are NULL-terminated arrays of valid NUL-terminated strings, |
| 56 | + // alive across the call. execveat does not mutate them. |
| 57 | + let ret = unsafe { |
| 58 | + libc::execveat( |
| 59 | + dirfd.as_raw_fd(), |
| 60 | + c"ghost-program".as_ptr(), |
| 61 | + args.as_ptr(), |
| 62 | + env.as_ptr(), |
| 63 | + 0, |
| 64 | + ) |
| 65 | + }; |
| 66 | + // execveat only returns on failure; the child exits cleanly here so |
| 67 | + // the harness's success assertion holds. |
| 68 | + assert_eq!(ret, -1, "execveat unexpectedly succeeded on a missing program"); |
| 69 | + }) |
| 70 | + .await?; |
| 71 | + |
| 72 | + assert_contains(&accesses, &expected, AccessMode::READ); |
| 73 | + Ok(()) |
| 74 | +} |
| 75 | + |
| 76 | +#[test(tokio::test)] |
| 77 | +async fn fexecve_resolves_through_the_program_fd() -> anyhow::Result<()> { |
| 78 | + let tmp_dir = tempfile::tempdir()?; |
| 79 | + let dir = std::fs::canonicalize(tmp_dir.path())?; |
| 80 | + // A shebang script whose interpreter does not exist. `fexecve` names the |
| 81 | + // program only as `/proc/self/fd/N` (which the client deliberately does |
| 82 | + // not report), but resolving the script reads its `#!` line and reports |
| 83 | + // the interpreter path — a non-`/proc` path that is reported and is the |
| 84 | + // observable proof the fexecve interposer ran and resolved the fd. |
| 85 | + let interpreter = Path::new("/fspy-exec-fd-missing-interpreter"); |
| 86 | + let script = dir.join("script"); |
| 87 | + std::fs::write(&script, format!("#!{}\n", interpreter.display()))?; |
| 88 | + |
| 89 | + let accesses = track_fn!(script.to_str().unwrap().to_owned(), |script: String| { |
| 90 | + let program_fd = nix::fcntl::open( |
| 91 | + script.as_str(), |
| 92 | + nix::fcntl::OFlag::O_RDONLY, |
| 93 | + nix::sys::stat::Mode::empty(), |
| 94 | + ) |
| 95 | + .expect("failed to open the script fd"); |
| 96 | + |
| 97 | + // nix's fexecve goes through the libc symbol, so the interposer runs. |
| 98 | + // It fails because the interpreter is missing; the child exits |
| 99 | + // cleanly afterwards. |
| 100 | + let err = nix::unistd::fexecve(program_fd, &[c"script"], &[c"X=1"]) |
| 101 | + .expect_err("fexecve unexpectedly succeeded"); |
| 102 | + assert_eq!(err, nix::Error::ENOENT, "unexpected fexecve error: {err}"); |
| 103 | + }) |
| 104 | + .await?; |
| 105 | + |
| 106 | + assert_contains(&accesses, interpreter, AccessMode::READ); |
| 107 | + Ok(()) |
| 108 | +} |
0 commit comments