Skip to content

Commit e7cb59c

Browse files
wan9chiclaude
andcommitted
test(fspy): cover execveat and fexecve interception
These two exec entry points name their program by file descriptor and had no coverage — the use-after-free fixed in the previous commit lived in execveat's resolved-path handling and was only ever found by reading the code. The interposer reports a program's path while resolving it, before the program is opened, so a missing program still produces the access and no executable needs to be staged. execveat passes a dirfd plus a relative pathname — the resolve-and-copy lane that dangled — and asserts the joined absolute path is captured. fexecve names its program only as /proc/self/fd/N (which the client does not report), so the test points it at a shebang script whose interpreter is reported instead, proving the fd was resolved. Both execs fail as intended and the child exits cleanly. Linux-gnu only: the interfaces do not exist on macOS, and musl uses the seccomp backend with an empty preload. execveat goes through the libc symbol so the interposer runs, rather than nix's raw SYS_execveat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1f8f298 commit e7cb59c

1 file changed

Lines changed: 108 additions & 0 deletions

File tree

crates/fspy/tests/exec_fd.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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

Comments
 (0)