Skip to content

Commit 508d21b

Browse files
authored
feat(fspy): improve tracking static executables using seccomp_unotify (#258)
# Improve tracking static executables using seccomp_unotify Enhanced the Linux syscall tracking to capture more file system operations and fixed a blocking issue in the tokio runtime. ### What changed? - Fixed a blocking issue in the tokio runtime by moving `tokio_command.spawn()` to `spawn_blocking` - Improved syscall handler to track more file system operations: - Added support for `execve` and `execveat` syscalls - Added support for `getdents` and `getdents64` syscalls - Added support for `stat`, `lstat`, `newfstatat`, and `fstatat` syscalls - Added support for `openat2` syscall - Improved path resolution for relative paths - Properly tracked file access modes (read, write, readwrite) - Refactored the syscall handler code into separate modules for better organization - Added comprehensive tests for all the new syscall tracking capabilities ### Why make this change? Tracking static executables with `seccomp_unotify`​ was half-baked, but now that we should officially support oxlint type-aware linting, it needs to be polished.
1 parent 1c7c9d7 commit 508d21b

14 files changed

Lines changed: 417 additions & 120 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fspy/src/unix/mod.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use fspy_shared_unix::{
1919
use futures_util::FutureExt;
2020
#[cfg(target_os = "linux")]
2121
use syscall_handler::SyscallHandler;
22+
use tokio::task::spawn_blocking;
2223

2324
use crate::{
2425
Command, TrackedChild,
@@ -124,15 +125,16 @@ pub(crate) async fn spawn_impl(mut command: Command) -> io::Result<TrackedChild>
124125
});
125126
}
126127

127-
let child = tokio_command.spawn()?;
128-
129-
drop(tokio_command);
128+
// tokio_command.spawn blocks while executing the `pre_exec` closure.
129+
// Run it inside spawn_blocking to avoid blocking the tokio runtime, especially the supervisor loop,
130+
// which needs to accept incoming connections while `pre_exec` is connecting to it.
131+
let child = spawn_blocking(move || tokio_command.spawn()).await??;
130132

131133
let arenas_future = async move {
132134
let arenas = std::iter::once(exec_resolve_accesses);
133135
#[cfg(target_os = "linux")]
134136
let arenas =
135-
arenas.chain(supervisor.stop().await?.into_iter().map(|handler| handler.arena));
137+
arenas.chain(supervisor.stop().await?.into_iter().map(|handler| handler.into_arena()));
136138
io::Result::Ok(arenas.collect::<Vec<_>>())
137139
};
138140

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
use std::io;
2+
3+
use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd};
4+
5+
use super::SyscallHandler;
6+
7+
impl SyscallHandler {
8+
fn handle_execve(&mut self, caller: Caller, fd: Fd, path_ptr: CStrPtr) -> io::Result<()> {
9+
// TODO: parse shebangs to track reading interpreters
10+
self.handle_open(caller, fd, path_ptr, libc::O_RDONLY)
11+
}
12+
13+
pub(super) fn execveat(
14+
&mut self,
15+
caller: Caller,
16+
(fd, path_ptr): (Fd, CStrPtr),
17+
) -> io::Result<()> {
18+
self.handle_execve(caller, fd, path_ptr)
19+
}
20+
21+
pub(super) fn execve(&mut self, caller: Caller, (path_ptr,): (CStrPtr,)) -> io::Result<()> {
22+
self.handle_execve(caller, Fd::cwd(), path_ptr)
23+
}
24+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use std::io;
2+
3+
use fspy_seccomp_unotify::supervisor::handler::arg::{Caller, Fd};
4+
5+
use super::SyscallHandler;
6+
7+
impl SyscallHandler {
8+
#[cfg(target_arch = "x86_64")]
9+
pub(super) fn getdents(&mut self, caller: Caller, (fd,): (Fd,)) -> io::Result<()> {
10+
self.handle_open_dir(caller, fd)
11+
}
12+
13+
pub(super) fn getdents64(&mut self, caller: Caller, (fd,): (Fd,)) -> io::Result<()> {
14+
self.handle_open_dir(caller, fd)
15+
}
16+
}
Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,76 @@
1-
use std::{io, os::unix::ffi::OsStrExt};
1+
mod execve;
2+
mod getdents;
3+
mod open;
4+
mod stat;
5+
6+
use std::{
7+
borrow::Cow,
8+
ffi::{OsStr, c_int},
9+
io,
10+
os::unix::ffi::OsStrExt,
11+
path::{Path, PathBuf},
12+
};
213

314
use fspy_seccomp_unotify::{
415
impl_handler,
5-
supervisor::handler::arg::{CStrPtr, Fd, Ignored},
16+
supervisor::handler::arg::{CStrPtr, Caller, Fd},
617
};
718
use fspy_shared::ipc::{AccessMode, NativeStr, PathAccess};
19+
use nix::NixPath;
820

921
use crate::arena::PathAccessArena;
1022

1123
const PATH_MAX: usize = libc::PATH_MAX as usize;
1224

13-
#[derive(Default, Debug)]
25+
#[derive(Debug)]
1426
pub struct SyscallHandler {
15-
pub(crate) arena: PathAccessArena,
27+
arena: PathAccessArena,
28+
path_read_buf: [u8; PATH_MAX],
1629
}
1730

18-
impl SyscallHandler {
19-
fn handle_open(&mut self, path: CStrPtr) -> io::Result<()> {
20-
path.read_with_buf::<PATH_MAX, _, _>(|path| {
21-
let Some(path) = path else {
22-
// Ignore paths that are too long to fit in PATH_MAX
23-
return Ok(());
24-
};
25-
self.arena
26-
.add(PathAccess { mode: AccessMode::Read, path: NativeStr::from_bytes(path) });
27-
Ok(())
28-
})?;
29-
Ok(())
31+
impl Default for SyscallHandler {
32+
fn default() -> Self {
33+
Self { arena: PathAccessArena::default(), path_read_buf: [0; PATH_MAX] }
3034
}
35+
}
3136

32-
#[cfg(target_arch = "x86_64")]
33-
fn open(&mut self, (path,): (CStrPtr,)) -> io::Result<()> {
34-
self.handle_open(path)
37+
impl SyscallHandler {
38+
pub fn into_arena(self) -> PathAccessArena {
39+
self.arena
3540
}
3641

37-
fn openat(&mut self, (_, path): (Ignored, CStrPtr)) -> io::Result<()> {
38-
self.handle_open(path)
42+
fn handle_open(
43+
&mut self,
44+
caller: Caller,
45+
dir_fd: Fd,
46+
path_ptr: CStrPtr,
47+
flags: c_int,
48+
) -> io::Result<()> {
49+
let Some(path_len) = path_ptr.read(caller, &mut self.path_read_buf)? else {
50+
// Ignore paths that are too long to fit in PATH_MAX
51+
return Ok(());
52+
};
53+
let mut path = Cow::Borrowed(Path::new(OsStr::from_bytes(&self.path_read_buf[..path_len])));
54+
if !path.is_absolute() {
55+
let mut resolved_path = PathBuf::from(dir_fd.get_path(caller)?);
56+
if !path.is_empty() {
57+
resolved_path.push(&path);
58+
}
59+
path = Cow::Owned(resolved_path);
60+
}
61+
self.arena.add(PathAccess {
62+
mode: match flags & libc::O_ACCMODE {
63+
libc::O_RDWR => AccessMode::ReadWrite,
64+
libc::O_WRONLY => AccessMode::Write,
65+
_ => AccessMode::Read,
66+
},
67+
path: NativeStr::from_bytes(path.as_os_str().as_bytes()),
68+
});
69+
Ok(())
3970
}
4071

41-
fn getdents64(&mut self, (fd,): (Fd,)) -> io::Result<()> {
42-
let path = fd.get_path()?;
72+
fn handle_open_dir(&mut self, caller: Caller, fd: Fd) -> io::Result<()> {
73+
let path = fd.get_path(caller)?;
4374
self.arena.add(PathAccess {
4475
mode: AccessMode::ReadDir,
4576
path: NativeStr::from_bytes(path.as_bytes()),
@@ -50,7 +81,19 @@ impl SyscallHandler {
5081

5182
impl_handler!(
5283
SyscallHandler:
84+
5385
#[cfg(target_arch = "x86_64")] open,
5486
openat,
87+
openat2,
88+
89+
#[cfg(target_arch = "x86_64")] getdents,
5590
getdents64,
91+
92+
#[cfg(target_arch = "x86_64")] stat,
93+
#[cfg(target_arch = "x86_64")] lstat,
94+
#[cfg(target_arch = "x86_64")] newfstatat,
95+
#[cfg(target_arch = "aarch64")] fstatat,
96+
97+
execve,
98+
execveat,
5699
);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
use std::{ffi::c_int, io};
2+
3+
use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd, Ptr};
4+
5+
use super::SyscallHandler;
6+
7+
impl SyscallHandler {
8+
#[cfg(target_arch = "x86_64")]
9+
pub(super) fn open(
10+
&mut self,
11+
caller: Caller,
12+
(path, flags): (CStrPtr, c_int),
13+
) -> io::Result<()> {
14+
self.handle_open(caller, Fd::cwd(), path, flags)
15+
}
16+
17+
pub(super) fn openat(
18+
&mut self,
19+
caller: Caller,
20+
(dir_fd, path, flags): (Fd, CStrPtr, c_int),
21+
) -> io::Result<()> {
22+
self.handle_open(caller, dir_fd, path, flags)
23+
}
24+
25+
pub(super) fn openat2(
26+
&mut self,
27+
caller: Caller,
28+
// open_how is a pointer to struct `open_how`, but we only care about flags here, so use `Ptr<u64>`
29+
(dir_fd, path, open_how): (Fd, CStrPtr, Ptr<u64>),
30+
) -> io::Result<()> {
31+
// SAFETY: open_how is a valid pointer to struct `open_how` in the target process, which has `flags` as the first field of type `u64`
32+
let flags = unsafe { open_how.read(caller) }?;
33+
self.handle_open(caller, dir_fd, path, c_int::try_from(flags).unwrap_or(libc::O_RDWR))
34+
}
35+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
use std::io;
2+
3+
use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd};
4+
5+
use super::SyscallHandler;
6+
7+
impl SyscallHandler {
8+
#[cfg(target_arch = "x86_64")]
9+
pub(super) fn stat(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> {
10+
self.handle_open(caller, Fd::cwd(), path, libc::O_RDONLY)
11+
}
12+
13+
#[cfg(target_arch = "x86_64")]
14+
pub(super) fn lstat(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> {
15+
self.handle_open(caller, Fd::cwd(), path, libc::O_RDONLY)
16+
}
17+
18+
#[cfg(target_arch = "aarch64")]
19+
pub(super) fn fstatat(
20+
&mut self,
21+
caller: Caller,
22+
(dir_fd, path_ptr): (Fd, CStrPtr),
23+
) -> io::Result<()> {
24+
self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
25+
}
26+
27+
#[cfg(target_arch = "x86_64")]
28+
pub(super) fn newfstatat(
29+
&mut self,
30+
caller: Caller,
31+
(dir_fd, path_ptr): (Fd, CStrPtr),
32+
) -> io::Result<()> {
33+
self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
34+
}
35+
}

crates/fspy/tests/static_executable.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ fn test_bin_path() -> &'static Path {
3535
TEST_BIN_PATH.as_path()
3636
}
3737

38-
async fn track_test_bin(args: &[&str]) -> PathAccessIterable {
38+
async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable {
3939
let mut cmd = fspy::Spy::global().unwrap().new_command(test_bin_path());
40+
if let Some(cwd) = cwd {
41+
cmd.current_dir(cwd);
42+
};
4043
cmd.args(args);
4144
let mut tracked_child = cmd.spawn().await.unwrap();
4245

@@ -48,6 +51,60 @@ async fn track_test_bin(args: &[&str]) -> PathAccessIterable {
4851

4952
#[tokio::test]
5053
async fn open_read() {
51-
let accesses = track_test_bin(&["open_read", "/hello"]).await;
54+
let accesses = track_test_bin(&["open_read", "/hello"], None).await;
55+
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read);
56+
}
57+
58+
#[tokio::test]
59+
async fn open_write() {
60+
let accesses = track_test_bin(&["open_write", "/hello"], None).await;
61+
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Write);
62+
}
63+
64+
#[tokio::test]
65+
async fn open_readwrite() {
66+
let accesses = track_test_bin(&["open_readwrite", "/hello"], None).await;
67+
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::ReadWrite);
68+
}
69+
70+
#[tokio::test]
71+
async fn openat2_read() {
72+
let accesses = track_test_bin(&["openat2_read", "/hello"], None).await;
73+
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read);
74+
}
75+
76+
#[tokio::test]
77+
async fn openat2_write() {
78+
let accesses = track_test_bin(&["openat2_write", "/hello"], None).await;
79+
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Write);
80+
}
81+
82+
#[tokio::test]
83+
async fn openat2_readwrite() {
84+
let accesses = track_test_bin(&["openat2_readwrite", "/hello"], None).await;
85+
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::ReadWrite);
86+
}
87+
88+
#[tokio::test]
89+
async fn open_relative() {
90+
let accesses = track_test_bin(&["open_read", "hello"], Some("/home")).await;
91+
assert_contains(&accesses, Path::new("/home/hello"), fspy::AccessMode::Read);
92+
}
93+
94+
#[tokio::test]
95+
async fn readdir() {
96+
let accesses = track_test_bin(&["readdir", "/home"], None).await;
97+
assert_contains(&accesses, Path::new("/home"), fspy::AccessMode::ReadDir);
98+
}
99+
100+
#[tokio::test]
101+
async fn stat() {
102+
let accesses = track_test_bin(&["stat", "/hello"], None).await;
103+
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read);
104+
}
105+
106+
#[tokio::test]
107+
async fn execve() {
108+
let accesses = track_test_bin(&["execve", "/hello"], None).await;
52109
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read);
53110
}

0 commit comments

Comments
 (0)