diff --git a/CHANGELOG.md b/CHANGELOG.md index 55af12527..64574a852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Linux automatic file tracking now covers direct syscalls from dynamic programs and across dynamic/static process transitions, while retaining preload acceleration for supported calls. - **Fixed** Failures while waiting for a started task process to exit no longer incorrectly say the process failed to spawn ([#515](https://github.com/voidzero-dev/vite-task/pull/515)). - **Fixed** Missing env vars requested through `@voidzero-dev/vite-task-client` now return `undefined` instead of `null`, preserving Vite production `NODE_ENV` semantics when builds run through `vp run` ([#508](https://github.com/voidzero-dev/vite-task/pull/508)). - **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)). diff --git a/Cargo.lock b/Cargo.lock index f31c68a5e..59a1867bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,7 @@ dependencies = [ "ctor", "fspy_shared", "fspy_shared_unix", + "fspy_syscall_intercept", "libc", "nix 0.31.2", "wincode", @@ -1375,13 +1376,21 @@ dependencies = [ "elf", "fspy_seccomp_unotify", "fspy_shared", - "memmap2", "nix 0.31.2", "phf 0.13.1", "stackalloc", "wincode", ] +[[package]] +name = "fspy_syscall_intercept" +version = "0.0.0" +dependencies = [ + "iced-x86", + "libc", + "object", +] + [[package]] name = "fspy_test_bin" version = "0.0.0" @@ -1613,6 +1622,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "iced-x86" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b" +dependencies = [ + "lazy_static", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1981,15 +1999,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmap2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" -dependencies = [ - "libc", -] - [[package]] name = "memmem" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 0b9187367..219b2303e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,13 +78,18 @@ fspy_preload_windows = { path = "crates/fspy_preload_windows", artifact = "cdyli fspy_seccomp_unotify = { path = "crates/fspy_seccomp_unotify" } fspy_shared = { path = "crates/fspy_shared" } fspy_shared_unix = { path = "crates/fspy_shared_unix" } +fspy_syscall_intercept = { path = "crates/fspy_syscall_intercept" } futures = "0.3.31" futures-util = "0.3.31" globset = "0.4.18" jsonc-parser = { version = "0.32.0", features = ["serde"] } +iced-x86 = { version = "1.21.0", default-features = false, features = [ + "decoder", + "instr_info", + "std", +] } libc = "0.2.185" libtest-mimic = "0.8.2" -memmap2 = "0.9.7" monostate = "1.0.2" napi = "3" napi-build = "2" @@ -99,6 +104,7 @@ ouroboros = "0.18.5" owo-colors = { version = "4.1.0", features = ["supports-colors"] } passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false } notify = "8.0.0" +object = { version = "0.37.3", default-features = false, features = ["elf", "read_core", "std"] } path-clean = "1.0.1" pathdiff = "0.2.3" petgraph = "0.8.2" diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index f99d25241..522a18675 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -18,7 +18,7 @@ ouroboros = { workspace = true } rustc-hash = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt"] } +tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt", "macros"] } tokio-util = { workspace = true } which = { workspace = true, features = ["tracing"] } diff --git a/crates/fspy/README.md b/crates/fspy/README.md index cf7fcba0e..b9cf14223 100644 --- a/crates/fspy/README.md +++ b/crates/fspy/README.md @@ -2,14 +2,13 @@ Run a command and capture all the paths it tries to access. -## macOS/Linux (glibc) implementation +## macOS implementation -It uses `DYLD_INSERT_LIBRARIES` on macOS and `LD_PRELOAD` on Linux to inject a shared library that intercepts file system calls. -The injection process is almost identical on both platforms other than the environment variable name. The implementation is in `src/unix`. +It uses `DYLD_INSERT_LIBRARIES` to inject a shared library that intercepts file system calls. -## Linux-specific implementation for fully static binaries +## Linux implementation -For fully static binaries (such as `esbuild`), `LD_PRELOAD` does not work. In this case, `seccomp_unotify` is used to intercept direct system calls. The handler is implemented in `src/unix/syscall_handler`. +Linux installs one inherited `seccomp_unotify` filter for the complete task process tree. Dynamically linked executables also receive an `LD_PRELOAD` library that accelerates safely patchable syscall sites. Static binaries, direct syscalls, and every declined acceleration case continue through seccomp. ## Linux musl implementation diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 6c89414ba..7d61075ce 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -32,6 +32,11 @@ pub struct ChildTermination { pub status: ExitStatus, /// The path accesses captured from the child process. pub path_accesses: PathAccessIterable, + /// The error that made filesystem tracing incomplete, if any. + /// + /// The target process is allowed to continue after a seccomp notification + /// decoding error, so its exit status remains available to callers. + pub tracing_error: Option, } pub struct TrackedChild { diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index f01f63b5d..1e64e000f 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -16,7 +16,7 @@ use fspy_shared_unix::payload::Artifacts; use fspy_shared_unix::{ exec::ExecResolveConfig, payload::{Payload, encode_payload}, - spawn::handle_exec, + spawn::handle_root_exec, }; use futures_util::FutureExt; #[cfg(target_os = "linux")] @@ -28,6 +28,11 @@ use tokio_util::sync::CancellationToken; use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; +// Reserved for the preload syscall gate. The payload communicates the exact post-syscall PC so +// the injected library does not need to duplicate this address. +#[cfg(target_os = "linux")] +const GATE_POST_SYSCALL_PC: u64 = 0x0000_0001_0000_0008; + #[derive(Debug)] pub struct SpyImpl { #[cfg(target_os = "macos")] @@ -75,7 +80,8 @@ impl SpyImpl { cancellation_token: CancellationToken, ) -> Result { #[cfg(target_os = "linux")] - let supervisor = supervise::().map_err(SpawnError::Supervisor)?; + let supervisor = + supervise::(GATE_POST_SYSCALL_PC).map_err(SpawnError::Supervisor)?; #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = @@ -99,7 +105,7 @@ impl SpyImpl { let mut exec = command.get_exec(); let mut exec_resolve_accesses = PathAccessArena::default(); - let pre_exec = handle_exec( + let pre_exec = handle_root_exec( &mut exec, ExecResolveConfig::search_path_enabled(None), &encoded_payload, @@ -149,13 +155,18 @@ impl SpyImpl { let arenas = std::iter::once(exec_resolve_accesses); // Stop the supervisor and collect path accesses from it. #[cfg(target_os = "linux")] - let arenas = arenas.chain( - supervisor - .stop() - .await? - .into_iter() - .map(syscall_handler::SyscallHandler::into_arena), - ); + let supervisor_outcome = supervisor.stop().await; + #[cfg(target_os = "linux")] + let tracing_error = supervisor_outcome.error; + #[cfg(target_os = "linux")] + let supervisor_arenas = supervisor_outcome + .handlers + .into_iter() + .map(syscall_handler::SyscallHandler::into_arena); + #[cfg(target_os = "linux")] + let arenas = arenas.chain(supervisor_arenas); + #[cfg(not(target_os = "linux"))] + let tracing_error = None; let arenas = arenas.collect::>(); // Lock the ipc channel after the child has exited. @@ -169,7 +180,7 @@ impl SpyImpl { ipc_receiver_lock_guard, }; - io::Result::Ok(ChildTermination { status, path_accesses }) + io::Result::Ok(ChildTermination { status, path_accesses, tracing_error }) }) .map(|f| f?) // flatten JoinError and io::Result .boxed(), diff --git a/crates/fspy/src/unix/syscall_handler/mod.rs b/crates/fspy/src/unix/syscall_handler/mod.rs index 4b6f7947e..c275da7ad 100644 --- a/crates/fspy/src/unix/syscall_handler/mod.rs +++ b/crates/fspy/src/unix/syscall_handler/mod.rs @@ -57,14 +57,17 @@ impl SyscallHandler { } path = Cow::Owned(resolved_path); } - self.arena.add(PathAccess { - mode: match flags & libc::O_ACCMODE { - libc::O_RDWR => AccessMode::READ | AccessMode::WRITE, - libc::O_WRONLY => AccessMode::WRITE, - _ => AccessMode::READ, - }, - path: path.as_os_str().into(), - }); + let mut mode = match flags & libc::O_ACCMODE { + libc::O_RDWR => AccessMode::READ | AccessMode::WRITE, + libc::O_WRONLY => AccessMode::WRITE, + _ => AccessMode::READ, + }; + if flags & (libc::O_CREAT | libc::O_TRUNC) != 0 + || flags & libc::O_TMPFILE == libc::O_TMPFILE + { + mode.insert(AccessMode::WRITE); + } + self.arena.add(PathAccess { mode, path: path.as_os_str().into() }); Ok(()) } diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 8081e1298..148c237b3 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -67,6 +67,10 @@ impl SpyImpl { } #[expect(clippy::unused_async, reason = "async signature required by SpyImpl trait")] + #[expect( + clippy::unused_async_trait_impl, + reason = "the platform implementations share an async call contract" + )] pub(crate) async fn spawn( &self, mut command: Command, @@ -164,7 +168,7 @@ impl SpyImpl { let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(receiver).await?; let path_accesses = PathAccessIterable { ipc_receiver_lock_guard }; - io::Result::Ok(ChildTermination { status, path_accesses }) + io::Result::Ok(ChildTermination { status, path_accesses, tracing_error: None }) }) .map(|f| f?) // flatten JoinError and io::Result .boxed(), diff --git a/crates/fspy/tests/detached_descendant.rs b/crates/fspy/tests/detached_descendant.rs new file mode 100644 index 000000000..6237aa95c --- /dev/null +++ b/crates/fspy/tests/detached_descendant.rs @@ -0,0 +1,30 @@ +#![cfg(target_os = "linux")] + +use std::{process::Stdio, time::Duration}; + +use tokio::{io::AsyncReadExt as _, time::timeout}; +use tokio_util::sync::CancellationToken; + +#[test_log::test(tokio::test)] +async fn detached_descendant_does_not_block_collection_or_lose_syscalls() -> anyhow::Result<()> { + let mut command = fspy::Command::new("/bin/sh"); + command + .arg("-c") + .arg(r"(sleep 2; if test -r /dev/null; then printf ok; else printf failed; fi) &") + .stdout(Stdio::piped()); + + let mut child = command.spawn(CancellationToken::new()).await?; + let mut stdout = child.stdout.take().expect("stdout should be piped"); + let termination = timeout(Duration::from_secs(1), child.wait_handle) + .await + .expect("detached descendant blocked root result collection")?; + assert!(termination.status.success()); + + let mut descendant_status = Vec::new(); + timeout(Duration::from_secs(5), stdout.read_to_end(&mut descendant_status)) + .await + .expect("detached descendant did not finish its filesystem access")?; + assert_eq!(descendant_status, b"ok", "detached descendant filesystem access failed"); + + Ok(()) +} diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index ae3c27169..c45835d47 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -7,12 +7,14 @@ use std::{ fs::{self, Permissions}, os::unix::fs::PermissionsExt as _, path::{Path, PathBuf}, + process::Stdio, sync::LazyLock, }; use fspy::PathAccessIterable; use fspy_shared_unix::is_dynamically_linked_to_libc; use test_log::test; +use tokio::io::AsyncReadExt as _; use crate::test_utils::assert_contains; @@ -120,3 +122,34 @@ async fn execve() { let accesses = track_test_bin(&["execve", "/hello"], None).await; assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::READ); } + +#[test(tokio::test)] +async fn dynamic_static_dynamic_preserves_preload_payload() { + let tmp_dir = Path::new(env!("CARGO_TARGET_TMPDIR")); + let script_path = tmp_dir.join("fspy-dynamic-descendant.sh"); + fs::write( + &script_path, + b"#!/bin/sh\nif [ -n \"$FSPY_PAYLOAD\" ] && [ -n \"$LD_PRELOAD\" ]; then\n printf fspy-env-preserved\nelse\n printf fspy-env-missing\n exit 1\nfi\n", + ) + .unwrap(); + fs::set_permissions(&script_path, Permissions::from_mode(0o755)).unwrap(); + + let mut command = fspy::Command::new("/bin/sh"); + command + .arg("-c") + .arg("exec \"$1\" execve \"$2\"") + .arg("sh") + .arg(test_bin_path()) + .arg(&script_path); + command.stdout(Stdio::piped()); + let mut child = command.spawn(tokio_util::sync::CancellationToken::new()).await.unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let read_stdout = async move { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).await.unwrap(); + output + }; + let (output, termination) = tokio::join!(read_stdout, child.wait_handle); + assert!(termination.unwrap().status.success()); + assert_eq!(output, b"fspy-env-preserved"); +} diff --git a/crates/fspy/tests/syscall_acceleration.rs b/crates/fspy/tests/syscall_acceleration.rs new file mode 100644 index 000000000..2f7cd375a --- /dev/null +++ b/crates/fspy/tests/syscall_acceleration.rs @@ -0,0 +1,115 @@ +#![cfg(all(target_os = "linux", not(target_env = "musl")))] + +use std::{ffi::CString, os::unix::ffi::OsStrExt as _}; + +use tokio_util::sync::CancellationToken; + +#[cfg(target_arch = "x86_64")] +core::arch::global_asm!( + ".global fspy_test_accelerated_openat", + ".type fspy_test_accelerated_openat, @function", + "fspy_test_accelerated_openat:", + "movq %rcx, %r10", + "movq $257, %rax", + ".global fspy_test_accelerated_openat_site", + "fspy_test_accelerated_openat_site:", + "syscall", + "testq %rax, %rax", + "retq", + ".size fspy_test_accelerated_openat, .-fspy_test_accelerated_openat", + options(att_syntax) +); + +#[cfg(target_arch = "aarch64")] +core::arch::global_asm!( + ".global fspy_test_accelerated_openat", + ".type fspy_test_accelerated_openat, %function", + "fspy_test_accelerated_openat:", + "mov x8, #56", + ".global fspy_test_accelerated_openat_site", + "fspy_test_accelerated_openat_site:", + "svc #0", + "ret", + ".size fspy_test_accelerated_openat, .-fspy_test_accelerated_openat", +); + +unsafe extern "C" { + fn fspy_test_accelerated_openat( + dirfd: libc::c_int, + path: *const libc::c_char, + flags: libc::c_int, + mode: libc::mode_t, + ) -> libc::c_long; + static fspy_test_accelerated_openat_site: u8; +} + +#[test_log::test(tokio::test)] +async fn raw_syscall_uses_acceleration_or_seccomp_fallback() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("accelerated-missing-input"); + let path_arg = path.to_string_lossy().into_owned(); + + let command = subprocess_test::command_for_fn!(path_arg, |path_arg: String| { + let site = (&raw const fspy_test_accelerated_openat_site).cast::(); + #[cfg(target_arch = "x86_64")] + let patched = { + // SAFETY: the label points into this executable's readable text mapping. + (unsafe { site.read() }) == 0xe9 + }; + #[cfg(target_arch = "aarch64")] + let patched = { + // SAFETY: AArch64 instructions are aligned four-byte words in readable text. + let instruction = unsafe { site.cast::().read_unaligned() }; + instruction & 0xfc00_0000 == 0x1400_0000 + }; + if std::env::var_os("FSPY_TEST_REQUIRE_ACCELERATION").is_some() { + assert!(patched, "syscall site was not accelerated"); + } + + let path = CString::new(path_arg).unwrap(); + // SAFETY: the assembly function implements the native openat syscall ABI. + let result = unsafe { + fspy_test_accelerated_openat( + libc::AT_FDCWD, + path.as_ptr(), + libc::O_RDONLY, + libc::mode_t::default(), + ) + }; + assert_eq!(result, libc::c_long::from(-libc::ENOENT)); + }); + + let termination = + fspy::Command::from(command).spawn(CancellationToken::new()).await?.wait_handle.await?; + assert!(termination.status.success(), "child exited with {:?}", termination.status); + assert!(termination.tracing_error.is_none()); + + let matching_accesses = termination + .path_accesses + .iter() + .filter(|access| { + access.path.strip_path_prefix(&path, |stripped| { + stripped.is_ok_and(|stripped| stripped.as_os_str().is_empty()) + }) + }) + .collect::>(); + assert_eq!(matching_accesses.len(), 1, "raw syscall should be recorded exactly once"); + assert_eq!(matching_accesses[0].mode, fspy::AccessMode::READ); + + let control_accesses = termination + .path_accesses + .iter() + .filter(|access| { + access.path.strip_path_prefix("/tmp", |stripped| { + stripped.is_ok_and(|path| { + path.file_name().is_some_and(|name| name.as_bytes().starts_with(b"fspy_ipc_")) + }) + }) + }) + .count(); + assert_eq!( + control_accesses, 1, + "only preload initialization should notify seccomp about the channel lock" + ); + Ok(()) +} diff --git a/crates/fspy/tests/tracing_completeness.rs b/crates/fspy/tests/tracing_completeness.rs new file mode 100644 index 000000000..eb65a01d6 --- /dev/null +++ b/crates/fspy/tests/tracing_completeness.rs @@ -0,0 +1,54 @@ +#![cfg(target_os = "linux")] + +use fspy::AccessMode; +use tokio_util::sync::CancellationToken; + +use crate::test_utils::assert_contains; + +mod test_utils; + +#[test_log::test(tokio::test)] +async fn undecodable_notification_marks_tracing_incomplete_and_continues() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let before_path = temp_dir.path().join("before"); + let after_path = temp_dir.path().join("after"); + let before_arg = before_path.to_string_lossy().into_owned(); + let after_arg = after_path.to_string_lossy().into_owned(); + let cmd = subprocess_test::command_for_fn!( + (before_arg.clone(), after_arg.clone()), + |(before_arg, after_arg): (String, String)| { + let before = std::ffi::CString::new(before_arg).unwrap(); + let after = std::ffi::CString::new(after_arg).unwrap(); + + // SAFETY: each caller provides a valid NUL-terminated path and the + // remaining arguments follow the openat syscall ABI. + let open = |path: &std::ffi::CStr| unsafe { + libc::syscall(libc::SYS_openat, libc::AT_FDCWD, path.as_ptr(), libc::O_RDONLY, 0) + }; + assert_eq!(open(&before), -1); + assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ENOENT)); + + // SAFETY: issuing openat with an invalid userspace pointer is safe; the + // kernel rejects it with EFAULT after the seccomp supervisor continues it. + let result = unsafe { + libc::syscall(libc::SYS_openat, libc::AT_FDCWD, 1usize, libc::O_RDONLY, 0) + }; + assert_eq!(result, -1); + assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(libc::EFAULT)); + + // The handler loop must remain alive after recording the error. + assert_eq!(open(&after), -1); + assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ENOENT)); + } + ); + + let termination = + fspy::Command::from(cmd).spawn(CancellationToken::new()).await?.wait_handle.await?; + + assert!(termination.status.success()); + assert_contains(&termination.path_accesses, &before_path, AccessMode::READ); + assert_contains(&termination.path_accesses, &after_path, AccessMode::READ); + let tracing_error = termination.tracing_error.expect("tracing should be incomplete"); + assert_eq!(tracing_error.raw_os_error(), Some(libc::EFAULT)); + Ok(()) +} diff --git a/crates/fspy_preload_unix/Cargo.toml b/crates/fspy_preload_unix/Cargo.toml index a430d1fa4..b1603d4df 100644 --- a/crates/fspy_preload_unix/Cargo.toml +++ b/crates/fspy_preload_unix/Cargo.toml @@ -17,5 +17,8 @@ fspy_shared_unix = { workspace = true } libc = { workspace = true } nix = { workspace = true, features = ["signal", "fs", "socket", "mman", "time"] } +[target.'cfg(all(target_os = "linux", not(target_env = "musl"), any(target_arch = "aarch64", target_arch = "x86_64")))'.dependencies] +fspy_syscall_intercept = { workspace = true } + [lints] workspace = true diff --git a/crates/fspy_preload_unix/README.md b/crates/fspy_preload_unix/README.md index d0e9644c8..9380e106d 100644 --- a/crates/fspy_preload_unix/README.md +++ b/crates/fspy_preload_unix/README.md @@ -2,4 +2,10 @@ The shared library injected by `DYLD_INSERT_LIBRARIES` on macOS and `LD_PRELOAD` on Linux to intercept file system calls. -This crates only contains code the shared library itself. The injection process is done in `fspy` crate. +This crate only contains code for the shared library itself. The injection process is implemented by the `fspy` crate. + +### Linux raw-syscall acceleration + +On Linux x86-64 and AArch64, this crate registers a callback with [`fspy_syscall_intercept`](../fspy_syscall_intercept/README.md). The callback decodes filesystem syscalls, safely resolves paths, and publishes complete shared-memory records before allowing a syscall through the exact seccomp-allowlisted gate. Unsupported or suppressed paths, invalid pointers, unavailable IPC, and nested or concurrent callback contention use the non-allowlisted gate and remain covered by the seccomp listener. + +The binary scanning, text patching, generated gates, architecture state preservation, runtime capability checks, and trusted-workload caveats are owned and documented by `fspy_syscall_intercept`. diff --git a/crates/fspy_preload_unix/src/accel/dispatcher.rs b/crates/fspy_preload_unix/src/accel/dispatcher.rs new file mode 100644 index 000000000..8f46b0eae --- /dev/null +++ b/crates/fspy_preload_unix/src/accel/dispatcher.rs @@ -0,0 +1,377 @@ +use std::{ + cell::UnsafeCell, + sync::atomic::{AtomicBool, Ordering}, +}; + +use fspy_shared::ipc::AccessMode; +use fspy_syscall_intercept::{SyscallContext, raw_syscall6}; + +use crate::client::{Client, ENCODED_PATH_CAPACITY, global_client}; + +const PATH_CAPACITY: usize = libc::PATH_MAX as usize; + +static IN_DISPATCH: AtomicBool = AtomicBool::new(false); +static DISPATCH_SCRATCH: SharedScratch = SharedScratch(UnsafeCell::new(DispatchScratch::new())); + +struct DispatchScratch { + path: [u8; PATH_CAPACITY], + base: [u8; PATH_CAPACITY], + resolved: [u8; PATH_CAPACITY], + encoded: [u8; ENCODED_PATH_CAPACITY], +} + +impl DispatchScratch { + const fn new() -> Self { + Self { + path: [0; PATH_CAPACITY], + base: [0; PATH_CAPACITY], + resolved: [0; PATH_CAPACITY], + encoded: [0; ENCODED_PATH_CAPACITY], + } + } +} + +struct SharedScratch(UnsafeCell); + +// SAFETY: mutable access is available only while holding the process-wide +// `IN_DISPATCH` guard. Nested and concurrent dispatch fail before access. +unsafe impl Sync for SharedScratch {} + +struct DispatchGuard; + +impl DispatchGuard { + fn enter() -> Option { + IN_DISPATCH + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + .then_some(Self) + } + + #[expect( + clippy::unused_self, + clippy::needless_pass_by_ref_mut, + reason = "the mutable guard borrow ties exclusive scratch access to the guard lifetime" + )] + fn scratch(&mut self) -> &mut DispatchScratch { + // SAFETY: successful construction changed IN_DISPATCH from false to + // true, and this mutable borrow cannot outlive that guard. + unsafe { &mut *DISPATCH_SCRATCH.0.get() } + } +} + +impl Drop for DispatchGuard { + fn drop(&mut self) { + IN_DISPATCH.store(false, Ordering::Release); + } +} + +pub(super) fn try_dispatch(context: &SyscallContext) -> bool { + let Some(mut guard) = DispatchGuard::enter() else { + return false; + }; + try_log(context, guard.scratch()) +} + +fn try_log(context: &SyscallContext, scratch: &mut DispatchScratch) -> bool { + let Some(client) = global_client() else { + return false; + }; + let syscall = context.syscall_number(); + let args = context.arguments(); + + #[cfg(target_arch = "x86_64")] + if is_syscall(syscall, libc::SYS_open) { + return send_path(client, scratch, libc::AT_FDCWD, args[0], mode_from_flags(args[1])); + } + if is_syscall(syscall, libc::SYS_openat) { + return send_path(client, scratch, as_dirfd(args[0]), args[1], mode_from_flags(args[2])); + } + if is_syscall(syscall, libc::SYS_openat2) { + let Some(flags) = copy_u64(args[2]) else { + return false; + }; + let Ok(flags) = usize::try_from(flags) else { + return false; + }; + return send_path(client, scratch, as_dirfd(args[0]), args[1], mode_from_flags(flags)); + } + + #[cfg(target_arch = "x86_64")] + if is_syscall(syscall, libc::SYS_stat) + || is_syscall(syscall, libc::SYS_lstat) + || is_syscall(syscall, libc::SYS_access) + { + return send_path(client, scratch, libc::AT_FDCWD, args[0], AccessMode::READ); + } + if is_syscall(syscall, libc::SYS_newfstatat) + || is_syscall(syscall, libc::SYS_statx) + || is_syscall(syscall, libc::SYS_faccessat) + || is_syscall(syscall, libc::SYS_faccessat2) + { + return send_path(client, scratch, as_dirfd(args[0]), args[1], AccessMode::READ); + } + + #[cfg(target_arch = "x86_64")] + if is_syscall(syscall, libc::SYS_getdents) { + return send_fd_path(client, scratch, as_dirfd(args[0]), AccessMode::READ_DIR); + } + if is_syscall(syscall, libc::SYS_getdents64) { + return send_fd_path(client, scratch, as_dirfd(args[0]), AccessMode::READ_DIR); + } + + if is_syscall(syscall, libc::SYS_execve) { + return send_path(client, scratch, libc::AT_FDCWD, args[0], AccessMode::READ); + } + if is_syscall(syscall, libc::SYS_execveat) { + return send_path(client, scratch, as_dirfd(args[0]), args[1], AccessMode::READ); + } + + false +} + +fn is_syscall(actual: usize, expected: libc::c_long) -> bool { + usize::try_from(expected) == Ok(actual) +} + +fn as_dirfd(value: usize) -> libc::c_int { + let low = u32::try_from(value & u32::MAX as usize).unwrap_or_default(); + libc::c_int::from_ne_bytes(low.to_ne_bytes()) +} + +fn syscall_number(number: libc::c_long) -> Option { + usize::try_from(number).ok() +} + +fn register_from_c_int(value: libc::c_int) -> usize { + usize::from_ne_bytes(i64::from(value).to_ne_bytes()) +} + +fn mode_from_flags(flags: usize) -> AccessMode { + let mut mode = match flags & libc::O_ACCMODE as usize { + value if value == libc::O_RDWR as usize => AccessMode::READ | AccessMode::WRITE, + value if value == libc::O_WRONLY as usize => AccessMode::WRITE, + _ => AccessMode::READ, + }; + if flags & (libc::O_CREAT | libc::O_TRUNC) as usize != 0 + || flags & libc::O_TMPFILE as usize == libc::O_TMPFILE as usize + { + mode.insert(AccessMode::WRITE); + } + mode +} + +fn send_path( + client: &Client, + scratch: &mut DispatchScratch, + dirfd: libc::c_int, + address: usize, + mode: AccessMode, +) -> bool { + let Some(length) = + resolve_path(dirfd, address, &mut scratch.path, &mut scratch.base, &mut scratch.resolved) + else { + return false; + }; + client.try_send_bytes(mode, &scratch.resolved[..length], &mut scratch.encoded) +} + +fn send_fd_path( + client: &Client, + scratch: &mut DispatchScratch, + fd: libc::c_int, + mode: AccessMode, +) -> bool { + let Some(length) = resolve_fd_path(fd, &mut scratch.resolved) else { + return false; + }; + client.try_send_bytes(mode, &scratch.resolved[..length], &mut scratch.encoded) +} + +fn resolve_path( + dirfd: libc::c_int, + address: usize, + path: &mut [u8; PATH_CAPACITY], + base: &mut [u8; PATH_CAPACITY], + resolved: &mut [u8; PATH_CAPACITY], +) -> Option { + let path_length = copy_c_string(address, path)?; + if path[..path_length].first() == Some(&b'/') { + resolved[..path_length].copy_from_slice(&path[..path_length]); + return Some(path_length); + } + + let base_length = + if dirfd == libc::AT_FDCWD { getcwd(base)? } else { resolve_fd_path(dirfd, base)? }; + join_path(&base[..base_length], &path[..path_length], resolved) +} + +fn getcwd(output: &mut [u8; PATH_CAPACITY]) -> Option { + let syscall = syscall_number(libc::SYS_getcwd)?; + // SAFETY: `output` is writable for the supplied length. getcwd is not in + // the monitored set, so this non-allowlisted raw syscall cannot recurse. + let result = + unsafe { raw_syscall6(syscall, output.as_mut_ptr() as usize, output.len(), 0, 0, 0, 0) }; + let length = usize::try_from(result).ok()?; + if length == 0 || length > output.len() || output[length - 1] != 0 { + return None; + } + Some(length - 1) +} + +fn resolve_fd_path(fd: libc::c_int, output: &mut [u8; PATH_CAPACITY]) -> Option { + let mut proc_path = [0; 64]; + let prefix = b"/proc/self/fd/"; + proc_path[..prefix.len()].copy_from_slice(prefix); + let proc_path_end = proc_path.len() - 1; + let digits = write_i32_decimal(fd, &mut proc_path[prefix.len()..proc_path_end])?; + proc_path[prefix.len() + digits] = 0; + let syscall = syscall_number(libc::SYS_readlinkat)?; + + // SAFETY: both buffers are valid for the supplied lengths. readlinkat is + // deliberately executed at the fallback PC and is not monitored. + let result = unsafe { + raw_syscall6( + syscall, + register_from_c_int(libc::AT_FDCWD), + proc_path.as_ptr() as usize, + output.as_mut_ptr() as usize, + output.len(), + 0, + 0, + ) + }; + let length = usize::try_from(result).ok()?; + (length < output.len()).then_some(length) +} + +fn join_path(base: &[u8], path: &[u8], output: &mut [u8; PATH_CAPACITY]) -> Option { + let separator = usize::from(!base.ends_with(b"/") && !path.is_empty()); + let length = base.len().checked_add(separator)?.checked_add(path.len())?; + if length > output.len() { + return None; + } + output[..base.len()].copy_from_slice(base); + if separator != 0 { + output[base.len()] = b'/'; + } + output[base.len() + separator..length].copy_from_slice(path); + Some(length) +} + +fn copy_c_string(address: usize, output: &mut [u8; PATH_CAPACITY]) -> Option { + if address == 0 { + return None; + } + let length = copy_from_process(address, output)?; + output[..length].iter().position(|byte| *byte == 0) +} + +fn copy_u64(address: usize) -> Option { + let mut bytes = [0; size_of::()]; + (copy_from_process(address, &mut bytes)? == bytes.len()).then(|| u64::from_ne_bytes(bytes)) +} + +fn copy_from_process(address: usize, output: &mut [u8]) -> Option { + if address == 0 || output.is_empty() { + return None; + } + let local = libc::iovec { iov_base: output.as_mut_ptr().cast(), iov_len: output.len() }; + let remote = libc::iovec { iov_base: address as *mut libc::c_void, iov_len: output.len() }; + // SAFETY: the local iovec is valid. process_vm_readv validates the remote + // address in the kernel, avoiding a preload-side fault on an EFAULT input. + let getpid = syscall_number(libc::SYS_getpid)?; + // SAFETY: getpid has no pointer arguments or caller-side preconditions. + let pid = unsafe { raw_syscall6(getpid, 0, 0, 0, 0, 0, 0) }; + let Ok(pid) = usize::try_from(pid) else { + return None; + }; + let process_vm_readv = syscall_number(libc::SYS_process_vm_readv)?; + // SAFETY: both iovec descriptors live through the raw syscall. + let result = unsafe { + raw_syscall6( + process_vm_readv, + pid, + std::ptr::from_ref(&local) as usize, + 1, + std::ptr::from_ref(&remote) as usize, + 1, + 0, + ) + }; + usize::try_from(result).ok() +} + +fn write_i32_decimal(value: libc::c_int, output: &mut [u8]) -> Option { + let negative = value < 0; + let mut magnitude = value.unsigned_abs(); + let mut reversed = [0; 10]; + let mut digits = 0; + loop { + reversed[digits] = b'0' + u8::try_from(magnitude % 10).ok()?; + digits += 1; + magnitude /= 10; + if magnitude == 0 { + break; + } + } + let length = digits + usize::from(negative); + if length > output.len() { + return None; + } + if negative { + output[0] = b'-'; + } + for index in 0..digits { + output[length - index - 1] = reversed[index]; + } + Some(length) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn joins_paths_without_allocating() { + let mut output = [0; PATH_CAPACITY]; + let length = join_path(b"/workspace", b"src/main.rs", &mut output).unwrap(); + assert_eq!(&output[..length], b"/workspace/src/main.rs"); + + let length = join_path(b"/", b"tmp", &mut output).unwrap(); + assert_eq!(&output[..length], b"/tmp"); + } + + #[test] + fn formats_all_fd_values() { + let mut output = [0; 16]; + let length = write_i32_decimal(libc::AT_FDCWD, &mut output).unwrap(); + assert_eq!(&output[..length], b"-100"); + let length = write_i32_decimal(libc::c_int::MIN, &mut output).unwrap(); + assert_eq!(&output[..length], b"-2147483648"); + } + + #[test] + fn maps_open_modes() { + assert_eq!(mode_from_flags(libc::O_RDONLY as usize), AccessMode::READ); + assert_eq!(mode_from_flags(libc::O_WRONLY as usize), AccessMode::WRITE); + assert_eq!(mode_from_flags(libc::O_RDWR as usize), AccessMode::READ | AccessMode::WRITE); + assert_eq!( + mode_from_flags((libc::O_RDONLY | libc::O_CREAT) as usize), + AccessMode::READ | AccessMode::WRITE + ); + assert_eq!( + mode_from_flags((libc::O_RDONLY | libc::O_TRUNC) as usize), + AccessMode::READ | AccessMode::WRITE + ); + } + + #[test] + fn nested_dispatch_cannot_access_shared_scratch() { + let mut guard = DispatchGuard::enter().unwrap(); + guard.scratch().path[0] = 42; + assert!(DispatchGuard::enter().is_none()); + assert_eq!(guard.scratch().path[0], 42); + drop(guard); + assert!(DispatchGuard::enter().is_some()); + } +} diff --git a/crates/fspy_preload_unix/src/accel/mod.rs b/crates/fspy_preload_unix/src/accel/mod.rs new file mode 100644 index 000000000..5cf47e515 --- /dev/null +++ b/crates/fspy_preload_unix/src/accel/mod.rs @@ -0,0 +1,33 @@ +//! Integration between filesystem access recording and syscall interception. +//! +//! `fspy_syscall_intercept` owns executable-memory management, assembly, and +//! text rewriting. This module supplies only the callback that understands +//! filesystem syscall arguments and records their paths before allowing the +//! intercepted syscall to use the seccomp-exempt gate. + +#![cfg_attr( + test, + expect( + dead_code, + reason = "the constructor is disabled in unit tests, so the registered callback is not reachable" + ) +)] + +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +mod dispatcher; + +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub use fspy_syscall_intercept::open_control_file; + +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub fn init(post_syscall_pc: u64) { + fspy_syscall_intercept::init(post_syscall_pc, dispatcher::try_dispatch); +} + +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub const fn init(_post_syscall_pc: u64) {} + +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub fn open_control_file(_path: &std::ffi::CStr) -> std::io::Result> { + Ok(None) +} diff --git a/crates/fspy_preload_unix/src/client/convert.rs b/crates/fspy_preload_unix/src/client/convert.rs index c42854b32..76a75f4b2 100644 --- a/crates/fspy_preload_unix/src/client/convert.rs +++ b/crates/fspy_preload_unix/src/client/convert.rs @@ -105,11 +105,22 @@ impl ToAccessMode for AccessMode { pub struct OpenFlags(pub c_int); impl ToAccessMode for OpenFlags { unsafe fn to_access_mode(self) -> AccessMode { - match self.0 & libc::O_ACCMODE { + let mode = match self.0 & libc::O_ACCMODE { libc::O_RDWR => AccessMode::READ | AccessMode::WRITE, libc::O_WRONLY => AccessMode::WRITE, _ => AccessMode::READ, - } + }; + #[cfg(target_os = "linux")] + let mode = { + let mut mode = mode; + if self.0 & (libc::O_CREAT | libc::O_TRUNC) != 0 + || self.0 & libc::O_TMPFILE == libc::O_TMPFILE + { + mode.insert(AccessMode::WRITE); + } + mode + }; + mode } } @@ -118,8 +129,9 @@ impl ToAccessMode for ModeStr { unsafe fn to_access_mode(self) -> AccessMode { // SAFETY: self.0 is a non-null pointer to a valid null-terminated C string, as guaranteed by the libc calling convention let mode_str = unsafe { CStr::from_ptr(self.0) }.to_bytes().as_bstr(); - let has_read = mode_str.contains(&b'r'); - let has_write = mode_str.contains(&b'w') || mode_str.contains(&b'a'); + let has_update = mode_str.contains(&b'+'); + let has_read = mode_str.contains(&b'r') || has_update; + let has_write = mode_str.contains(&b'w') || mode_str.contains(&b'a') || has_update; match (has_read, has_write) { (false, true) => AccessMode::WRITE, (true, true) => AccessMode::READ | AccessMode::WRITE, diff --git a/crates/fspy_preload_unix/src/client/mod.rs b/crates/fspy_preload_unix/src/client/mod.rs index aad7b46be..6c6ce7508 100644 --- a/crates/fspy_preload_unix/src/client/mod.rs +++ b/crates/fspy_preload_unix/src/client/mod.rs @@ -1,13 +1,18 @@ pub mod convert; pub mod raw_exec; +#[cfg(target_os = "linux")] +use std::ffi::CString; use std::{ ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, path::Path, sync::OnceLock, }; use convert::{ToAbsolutePath, ToAccessMode}; -use fspy_shared::ipc::{PathAccess, channel::Sender}; +use fspy_shared::ipc::{ + PathAccess, + channel::{ClaimFrameError, Sender, SenderFrame}, +}; use fspy_shared_unix::{ exec::ExecResolveConfig, payload::EncodedPayload, @@ -16,9 +21,14 @@ use fspy_shared_unix::{ use raw_exec::RawExec; use wincode::Serialize as _; +#[cfg(target_os = "linux")] +pub const ENCODED_PATH_CAPACITY: usize = libc::PATH_MAX as usize + 32; + pub struct Client { encoded_payload: EncodedPayload, ipc_sender: Option, + #[cfg(target_os = "linux")] + lock_file_path: Option, } // SAFETY: Client fields are only mutated during initialization in the ctor; after that, all access is read-only @@ -47,6 +57,14 @@ impl Client { let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { Ok(sender) => Some(sender), + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::NotFound + ) => + { + None + } Err(err) => { // this can happen if the process is started after the root target process has exited. // By that time the channel would have been closed in the receiver side. @@ -56,7 +74,45 @@ impl Client { } }; - Self { encoded_payload, ipc_sender } + #[cfg(target_os = "linux")] + let lock_file_path = ipc_sender.as_ref().map(|sender| { + CString::new(sender.lock_file_path().as_bytes()).expect("lock path contains NUL") + }); + + Self { + encoded_payload, + ipc_sender, + #[cfg(target_os = "linux")] + lock_file_path, + } + } + + fn claim_frame<'a>( + &self, + sender: &'a Sender, + frame_size: NonZeroUsize, + ) -> Result, ClaimFrameError> { + let _ = self; + #[cfg(target_os = "linux")] + if let Some(path) = &self.lock_file_path { + // SAFETY: `path` is the channel's exact internal lock-file path. + match unsafe { crate::accel::open_control_file(path) } { + Ok(Some(lock_file)) => { + // SAFETY: the helper opened this sender's exact lock path + // and transfers sole ownership of the handle. + return unsafe { sender.claim_frame_with_lock(frame_size, lock_file) }; + } + Ok(None) => {} + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ENOSYS) => + { + return Err(ClaimFrameError::Closed(error)); + } + Err(error) => return Err(ClaimFrameError::Lock(error)), + } + } + sender.claim_frame(frame_size) } fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) -> anyhow::Result<()> { @@ -64,6 +120,9 @@ impl Client { // ipc channel not available, skip sending return Ok(()); }; + if ipc_sender.is_lock_file(path) { + return Ok(()); + } let path_bytes = path.as_os_str().as_bytes(); if path_bytes.starts_with(b"/dev/") || (cfg!(target_os = "linux") @@ -78,9 +137,11 @@ impl Client { let frame_size = NonZeroUsize::new(serialized_size) .expect("fspy: encoded PathAccess should never be empty"); - let mut frame = ipc_sender - .claim_frame(frame_size) - .expect("fspy: failed to claim frame in shared memory"); + let mut frame = match self.claim_frame(ipc_sender, frame_size) { + Ok(frame) => frame, + Err(ClaimFrameError::Closed(_)) => return Ok(()), + Err(error) => return Err(error.into()), + }; let mut writer: &mut [u8] = &mut frame; PathAccess::serialize_into(&mut writer, &path_access)?; assert_eq!(writer.len(), 0); @@ -88,6 +149,63 @@ impl Client { Ok(()) } + /// Sends an already-resolved Unix path without allocating or panicking. + /// + /// The preload syscall dispatcher uses this only after copying the caller's + /// path through `process_vm_readv`. A false result keeps the syscall on the + /// seccomp path instead of risking an unreported allowlisted syscall. + #[cfg(target_os = "linux")] + #[cfg_attr( + test, + expect( + dead_code, + reason = "the constructor that registers the dispatcher is disabled in tests" + ) + )] + pub(crate) fn try_send_bytes( + &self, + mode: fspy_shared::ipc::AccessMode, + path_bytes: &[u8], + encoded: &mut [u8; ENCODED_PATH_CAPACITY], + ) -> bool { + let Some(ipc_sender) = &self.ipc_sender else { + return false; + }; + if accelerated_path_must_fallback(path_bytes) { + return false; + } + + let path = Path::new(OsStr::from_bytes(path_bytes)); + if ipc_sender.is_lock_file(path) { + return false; + } + let path_access = PathAccess { mode, path: path.into() }; + let Ok(serialized_size) = PathAccess::serialized_size(&path_access) else { + return false; + }; + let Ok(serialized_size) = usize::try_from(serialized_size) else { + return false; + }; + let Some(frame_size) = NonZeroUsize::new(serialized_size) else { + return false; + }; + if serialized_size > ENCODED_PATH_CAPACITY { + return false; + } + + // Encode before claiming a frame so an unexpected encoding failure + // cannot publish a malformed shared-memory frame. + let mut writer: &mut [u8] = &mut encoded[..serialized_size]; + if PathAccess::serialize_into(&mut writer, &path_access).is_err() || !writer.is_empty() { + return false; + } + let Ok(mut frame) = self.claim_frame(ipc_sender, frame_size) else { + return false; + }; + frame.copy_from_slice(&encoded[..serialized_size]); + true + } + pub unsafe fn handle_exec( &self, config: ExecResolveConfig, @@ -123,6 +241,11 @@ impl Client { } } +#[cfg(target_os = "linux")] +fn accelerated_path_must_fallback(path: &[u8]) -> bool { + path.starts_with(b"/dev/") || path.starts_with(b"/proc/") || path.starts_with(b"/sys/") +} + static CLIENT: OnceLock = OnceLock::new(); pub fn global_client() -> Option<&'static Client> { @@ -139,5 +262,23 @@ pub unsafe fn handle_open(path: impl ToAbsolutePath, mode: impl ToAccessMode) { #[cfg(not(test))] #[ctor::ctor(unsafe)] fn init_client() { - CLIENT.set(Client::from_env()).unwrap(); + let client = Client::from_env(); + #[cfg(target_os = "linux")] + let post_syscall_pc = client.encoded_payload.payload.seccomp_payload.post_syscall_pc(); + CLIENT.set(client).unwrap(); + #[cfg(target_os = "linux")] + crate::accel::init(post_syscall_pc); +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::accelerated_path_must_fallback; + + #[test] + fn accelerated_suppression_uses_seccomp_fallback() { + assert!(accelerated_path_must_fallback(b"/dev/null")); + assert!(accelerated_path_must_fallback(b"/proc/self/maps")); + assert!(accelerated_path_must_fallback(b"/sys/kernel")); + assert!(!accelerated_path_must_fallback(b"/workspace/file")); + } } diff --git a/crates/fspy_preload_unix/src/interceptions/open.rs b/crates/fspy_preload_unix/src/interceptions/open.rs index 8dca5b48f..ae7062b77 100644 --- a/crates/fspy_preload_unix/src/interceptions/open.rs +++ b/crates/fspy_preload_unix/src/interceptions/open.rs @@ -14,7 +14,7 @@ const fn has_mode_arg(o_flags: c_int) -> bool { return true; } #[cfg(target_os = "linux")] - if o_flags & libc::O_TMPFILE != 0 { + if o_flags & libc::O_TMPFILE == libc::O_TMPFILE { return true; } false diff --git a/crates/fspy_preload_unix/src/lib.rs b/crates/fspy_preload_unix/src/lib.rs index 42bf9e9cb..aec759a8a 100644 --- a/crates/fspy_preload_unix/src/lib.rs +++ b/crates/fspy_preload_unix/src/lib.rs @@ -3,6 +3,8 @@ // warning about unused features on those targets. #![cfg_attr(all(unix, not(target_env = "musl")), feature(c_variadic))] +#[cfg(all(target_os = "linux", not(target_env = "musl")))] +mod accel; #[cfg(all(unix, not(target_env = "musl")))] mod client; #[cfg(all(unix, not(target_env = "musl")))] diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index 48933414e..3309105fc 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -2,7 +2,10 @@ use std::{cell::SyncUnsafeCell, ffi::CStr, mem::MaybeUninit}; use fspy_detours_sys::DetourCopyPayloadToProcess; use fspy_shared::{ - ipc::{PathAccess, channel::Sender}, + ipc::{ + PathAccess, + channel::{ClaimFrameError, Sender, SenderWriteError}, + }, windows::{PAYLOAD_ID, Payload}, }; use winapi::{shared::minwindef::BOOL, um::winnt::HANDLE}; @@ -18,6 +21,14 @@ impl<'a> Client<'a> { let ipc_sender = match payload.channel_conf.sender() { Ok(sender) => Some(sender), + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::NotFound + ) => + { + None + } Err(err) => { // this can happen if the process is started after the root target process has exited. // By that time the channel would have been closed in the receiver side. @@ -40,7 +51,13 @@ impl<'a> Client<'a> { let Some(sender) = &self.ipc_sender else { return; }; - sender.write_encoded(&access).expect("failed to send path access"); + if sender.is_lock_native_path(access.path) { + return; + } + match sender.write_encoded(&access) { + Ok(()) | Err(SenderWriteError::Channel(ClaimFrameError::Closed(_))) => {} + Err(error) => panic!("failed to send path access: {error}"), + } } pub unsafe fn prepare_child_process(&self, child_handle: HANDLE) -> BOOL { diff --git a/crates/fspy_seccomp_unotify/src/payload/filter.rs b/crates/fspy_seccomp_unotify/src/payload/filter.rs index 5852aa7dc..355770e8c 100644 --- a/crates/fspy_seccomp_unotify/src/payload/filter.rs +++ b/crates/fspy_seccomp_unotify/src/payload/filter.rs @@ -1,6 +1,57 @@ use wincode::{SchemaRead, SchemaWrite}; -#[derive(Debug, SchemaWrite, SchemaRead, Clone, Copy)] +const BPF_LD_W_ABS: u16 = 0x20; +const BPF_JMP_JEQ_K: u16 = 0x15; +const BPF_JMP_JSET_K: u16 = 0x45; +const BPF_RET_K: u16 = 0x06; + +const SECCOMP_DATA_NR_OFFSET: u32 = 0; +const SECCOMP_DATA_ARCH_OFFSET: u32 = 4; +const SECCOMP_DATA_INSTRUCTION_POINTER_LOW_OFFSET: u32 = 8; +const SECCOMP_DATA_INSTRUCTION_POINTER_HIGH_OFFSET: u32 = 12; +const X32_SYSCALL_BIT: u32 = 0x4000_0000; + +/// Architectures supported by the fspy seccomp filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Architecture { + X86_64, + Aarch64, +} + +impl Architecture { + #[must_use] + pub const fn current() -> Self { + #[cfg(target_arch = "x86_64")] + { + Self::X86_64 + } + #[cfg(target_arch = "aarch64")] + { + Self::Aarch64 + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + panic!("unsupported seccomp architecture") + } + } + + pub(crate) const fn audit_value(self) -> u32 { + const AUDIT_ARCH_64BIT: u32 = 0x8000_0000; + const AUDIT_ARCH_LITTLE_ENDIAN: u32 = 0x4000_0000; + match self { + Self::X86_64 => 0x003e | AUDIT_ARCH_64BIT | AUDIT_ARCH_LITTLE_ENDIAN, + Self::Aarch64 => 0x00b7 | AUDIT_ARCH_64BIT | AUDIT_ARCH_LITTLE_ENDIAN, + } + } + + #[cfg(feature = "supervisor")] + pub(crate) const fn is_x32_syscall(self, syscall: i32) -> bool { + matches!(self, Self::X86_64) + && u32::from_ne_bytes(syscall.to_ne_bytes()) & X32_SYSCALL_BIT != 0 + } +} + +#[derive(Debug, SchemaWrite, SchemaRead, Clone, Copy, PartialEq, Eq)] pub struct CodableSockFilter { code: u16, jt: u8, @@ -8,6 +59,21 @@ pub struct CodableSockFilter { k: u32, } +impl CodableSockFilter { + const fn statement(code: u16, k: u32) -> Self { + Self { code, jt: 0, jf: 0, k } + } + + const fn jump(code: u16, k: u32, jt: u8, jf: u8) -> Self { + Self { code, jt, jf, k } + } + + #[must_use] + pub const fn parts(self) -> (u16, u8, u8, u32) { + (self.code, self.jt, self.jf, self.k) + } +} + #[cfg(feature = "supervisor")] impl From for CodableSockFilter { fn from(c_filter: seccompiler::sock_filter) -> Self { @@ -24,5 +90,71 @@ impl From for libc::sock_filter { } } -#[derive(SchemaWrite, SchemaRead, Debug, Clone)] +#[derive(SchemaWrite, SchemaRead, Debug, Clone, PartialEq, Eq)] pub struct Filter(pub(crate) Vec); + +impl Filter { + /// # Panics + /// + /// Panics if there are more than 255 monitored syscalls or a syscall number is negative. + #[must_use] + pub fn new( + architecture: Architecture, + monitored_syscalls: impl IntoIterator, + post_syscall_pc: u64, + ) -> Self { + let mut monitored_syscalls = monitored_syscalls.into_iter().collect::>(); + monitored_syscalls.sort_unstable(); + monitored_syscalls.dedup(); + + let mut instructions = Vec::with_capacity( + monitored_syscalls.len() + if architecture == Architecture::X86_64 { 13 } else { 11 }, + ); + instructions.extend([ + CodableSockFilter::statement(BPF_LD_W_ABS, SECCOMP_DATA_ARCH_OFFSET), + CodableSockFilter::jump(BPF_JMP_JEQ_K, architecture.audit_value(), 1, 0), + CodableSockFilter::statement(BPF_RET_K, libc::SECCOMP_RET_USER_NOTIF), + CodableSockFilter::statement(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + ]); + + if architecture == Architecture::X86_64 { + instructions.extend([ + CodableSockFilter::jump(BPF_JMP_JSET_K, X32_SYSCALL_BIT, 0, 1), + CodableSockFilter::statement(BPF_RET_K, libc::SECCOMP_RET_USER_NOTIF), + ]); + } + + for (index, syscall) in monitored_syscalls.iter().copied().enumerate() { + let jump_to_gate = u8::try_from(monitored_syscalls.len() - index) + .expect("too many monitored syscalls for a classic BPF jump"); + instructions.push(CodableSockFilter::jump( + BPF_JMP_JEQ_K, + u32::try_from(syscall).expect("syscall numbers must be non-negative"), + jump_to_gate, + 0, + )); + } + + let post_syscall_pc_low = u32::try_from(post_syscall_pc & u64::from(u32::MAX)).unwrap(); + let post_syscall_pc_high = u32::try_from(post_syscall_pc >> 32).unwrap(); + instructions.extend([ + CodableSockFilter::statement(BPF_RET_K, libc::SECCOMP_RET_ALLOW), + CodableSockFilter::statement(BPF_LD_W_ABS, SECCOMP_DATA_INSTRUCTION_POINTER_LOW_OFFSET), + CodableSockFilter::jump(BPF_JMP_JEQ_K, post_syscall_pc_low, 0, 2), + CodableSockFilter::statement( + BPF_LD_W_ABS, + SECCOMP_DATA_INSTRUCTION_POINTER_HIGH_OFFSET, + ), + CodableSockFilter::jump(BPF_JMP_JEQ_K, post_syscall_pc_high, 1, 0), + CodableSockFilter::statement(BPF_RET_K, libc::SECCOMP_RET_USER_NOTIF), + CodableSockFilter::statement(BPF_RET_K, libc::SECCOMP_RET_ALLOW), + ]); + + Self(instructions) + } + + #[must_use] + pub fn instructions(&self) -> &[CodableSockFilter] { + &self.0 + } +} diff --git a/crates/fspy_seccomp_unotify/src/payload/mod.rs b/crates/fspy_seccomp_unotify/src/payload/mod.rs index 6895bc55a..f5a6645e4 100644 --- a/crates/fspy_seccomp_unotify/src/payload/mod.rs +++ b/crates/fspy_seccomp_unotify/src/payload/mod.rs @@ -1,9 +1,17 @@ mod filter; -pub use filter::Filter; +pub use filter::{Architecture, Filter}; use wincode::{SchemaRead, SchemaWrite}; #[derive(Debug, SchemaWrite, SchemaRead, Clone)] pub struct SeccompPayload { pub(crate) ipc_path: Vec, pub(crate) filter: Filter, + pub(crate) post_syscall_pc: u64, +} + +impl SeccompPayload { + #[must_use] + pub const fn post_syscall_pc(&self) -> u64 { + self.post_syscall_pc + } } diff --git a/crates/fspy_seccomp_unotify/src/supervisor/mod.rs b/crates/fspy_seccomp_unotify/src/supervisor/mod.rs index b1aa0eb62..02af46d4c 100644 --- a/crates/fspy_seccomp_unotify/src/supervisor/mod.rs +++ b/crates/fspy_seccomp_unotify/src/supervisor/mod.rs @@ -17,23 +17,28 @@ use futures_util::{ pub use handler::SeccompNotifyHandler; use listener::NotifyListener; use passfd::tokio::FdPassingExt; -use seccompiler::{BpfProgram, SeccompAction, SeccompFilter}; use tokio::{ net::UnixListener, - sync::oneshot, + sync::{oneshot, watch}, task::{JoinHandle, JoinSet}, }; use tracing::{Level, span}; use crate::{ bindings::alloc::alloc_seccomp_notif_resp, - payload::{Filter, SeccompPayload}, + payload::{Architecture, Filter, SeccompPayload}, }; pub struct Supervisor { payload: SeccompPayload, cancel_tx: oneshot::Sender, - handling_loop_task: JoinHandle>>, + stop_handlers_tx: watch::Sender, + handling_loop_task: JoinHandle>, +} + +pub struct SupervisorOutcome { + pub handlers: Vec, + pub error: Option, } impl Supervisor { @@ -42,19 +47,138 @@ impl Supervisor { &self.payload } - /// Stops the supervisor and returns all handler instances. + /// Stops the supervisor and returns all handler instances plus the first + /// error that made their observations incomplete. /// /// # Panics /// Panics if the handling loop task has panicked. - /// - /// # Errors - /// Returns an error if any of the spawned handler tasks failed with an I/O error. - pub async fn stop(self) -> io::Result> { + pub async fn stop(self) -> SupervisorOutcome { + let _ = self.stop_handlers_tx.send(true); drop(self.cancel_tx); self.handling_loop_task.await.expect("handling loop task panicked") } } +fn record_first_error(first_error: &mut Option, error: io::Error) { + if first_error.is_none() { + *first_error = Some(error); + } +} + +async fn handle_notifications( + mut listener: NotifyListener, + mut handler: H, + expected_architecture: Architecture, + mut stop_rx: watch::Receiver, +) -> (H, Option) { + let mut first_error = None; + let mut resp_buf = alloc_seccomp_notif_resp(); + let expected_audit_architecture = expected_architecture.audit_value(); + + loop { + if *stop_rx.borrow() { + tokio::spawn(drain_notifications(listener)); + break; + } + let notify_result = { + let stop_future = stop_rx.changed(); + let notify_future = listener.next(); + pin_mut!(stop_future, notify_future); + match select(stop_future, notify_future).await { + Either::Left(_) => None, + Either::Right((notify_result, _)) => { + Some(notify_result.map(Option::<&libc::seccomp_notif>::copied)) + } + } + }; + let Some(notify_result) = notify_result else { + tokio::spawn(drain_notifications(listener)); + break; + }; + let notify = match notify_result { + Ok(Some(notify)) => notify, + Ok(None) => break, + Err(error) => { + tracing::warn!( + ?error, + "seccomp notification listener failed; tracing is incomplete" + ); + record_first_error(&mut first_error, error); + break; + } + }; + if *stop_rx.borrow() { + let request_id = notify.id; + tokio::spawn(async move { + let mut resp_buf = alloc_seccomp_notif_resp(); + let _ = listener.send_continue(request_id, &mut resp_buf); + drain_notifications(listener).await; + }); + break; + } + let _span = span!(Level::TRACE, "notify loop tick"); + if notify.data.arch != expected_audit_architecture { + let error = + io::Error::new(io::ErrorKind::Unsupported, "unsupported syscall architecture"); + if first_error.is_none() { + tracing::warn!( + request_id = notify.id, + architecture = notify.data.arch, + expected_architecture = expected_audit_architecture, + "unsupported syscall architecture; tracing is incomplete" + ); + } + record_first_error(&mut first_error, error); + } else if expected_architecture.is_x32_syscall(notify.data.nr) { + let error = io::Error::new(io::ErrorKind::Unsupported, "unsupported x32 syscall ABI"); + if first_error.is_none() { + tracing::warn!( + request_id = notify.id, + syscall = notify.data.nr, + "unsupported x32 syscall ABI; tracing is incomplete" + ); + } + record_first_error(&mut first_error, error); + } else if let Err(error) = handler.handle_notify(¬ify) { + tracing::warn!( + request_id = notify.id, + ?error, + "failed to decode seccomp notification; tracing is incomplete" + ); + record_first_error(&mut first_error, error); + } + let req_id = notify.id; + if let Err(error) = listener.send_continue(req_id, &mut resp_buf) { + tracing::warn!( + ?error, + "failed to continue seccomp notification; tracing is incomplete" + ); + record_first_error(&mut first_error, error); + break; + } + } + + (handler, first_error) +} + +async fn drain_notifications(mut listener: NotifyListener) { + let mut resp_buf = alloc_seccomp_notif_resp(); + loop { + let request_id = match listener.next().await { + Ok(Some(notify)) => notify.id, + Ok(None) => break, + Err(error) => { + tracing::warn!(?error, "detached seccomp notification drainer failed"); + break; + } + }; + if let Err(error) = listener.send_continue(request_id, &mut resp_buf) { + tracing::warn!(?error, "failed to continue detached seccomp notification"); + break; + } + } +} + /// Creates a new supervisor that listens for seccomp user notifications. /// /// # Panics @@ -62,68 +186,92 @@ impl Supervisor { /// /// # Errors /// Returns an error if the temporary IPC socket cannot be created. -pub fn supervise() -> io::Result> -{ +pub fn supervise( + post_syscall_pc: u64, +) -> io::Result> { let notify_listener = tempfile::Builder::new() .prefix("fspy_seccomp_notify") .make(|path| UnixListener::bind(path))?; - let seccomp_filter = SeccompFilter::new( - H::syscalls().iter().map(|sysno| (sysno.id().into(), vec![])).collect(), - SeccompAction::Allow, - SeccompAction::UserNotif, - std::env::consts::ARCH.try_into().unwrap(), - ) - .unwrap(); - - let bpf_filter = - Filter(BpfProgram::try_from(seccomp_filter).unwrap().into_iter().map(Into::into).collect()); + let bpf_filter = Filter::new( + Architecture::current(), + H::syscalls().iter().map(syscalls::Sysno::id), + post_syscall_pc, + ); + let expected_architecture = Architecture::current(); let payload = SeccompPayload { ipc_path: notify_listener.path().as_os_str().as_bytes().to_vec(), filter: bpf_filter, + post_syscall_pc, }; // The oneshot channel is used to cancel the accept loop. // The sender doesn't need to actually send anything. Drop is enough. let (cancel_tx, mut cancel_rx) = oneshot::channel::(); + let (stop_handlers_tx, stop_handlers_rx) = watch::channel(false); let handling_loop = async move { - let mut join_set: JoinSet> = JoinSet::new(); + let mut join_set = JoinSet::new(); + let mut first_error = None; loop { let accept_future = notify_listener.as_file().accept(); pin_mut!(accept_future); - let (incoming_stream, _) = match select(&mut cancel_rx, accept_future).await { + let incoming = match select(&mut cancel_rx, accept_future).await { Either::Left((Err(_), _)) => break, - Either::Right((incoming, _)) => incoming?, + Either::Right((incoming, _)) => incoming, + }; + let (incoming_stream, _) = match incoming { + Ok(incoming) => incoming, + Err(error) => { + record_first_error(&mut first_error, error); + break; + } + }; + let notify_fd = match incoming_stream.recv_fd().await { + Ok(notify_fd) => notify_fd, + Err(error) => { + record_first_error(&mut first_error, error); + continue; + } }; - let notify_fd = incoming_stream.recv_fd().await?; // SAFETY: `recv_fd` returns a valid file descriptor received via // Unix domain socket fd passing let notify_fd = unsafe { OwnedFd::from_raw_fd(notify_fd) }; - let mut listener = NotifyListener::try_from(notify_fd)?; - - let mut handler = H::default(); - let mut resp_buf = alloc_seccomp_notif_resp(); - - join_set.spawn(async move { - while let Some(notify) = listener.next().await? { - let _span = span!(Level::TRACE, "notify loop tick"); - // Errors on the supervisor side could be caused by a target process aborting. - // It shouldn't break the syscall handling loop as there might be target processes. - let _handle_result = handler.handle_notify(notify); - let req_id = notify.id; - listener.send_continue(req_id, &mut resp_buf)?; + let listener = match NotifyListener::try_from(notify_fd) { + Ok(listener) => listener, + Err(error) => { + record_first_error(&mut first_error, error); + continue; } - io::Result::Ok(handler) - }); + }; + + join_set.spawn(handle_notifications( + listener, + H::default(), + expected_architecture, + stop_handlers_rx.clone(), + )); } let mut handlers = Vec::::new(); - while let Some(handler) = join_set.join_next().await.transpose()? { - handlers.push(handler?); + while let Some(outcome) = join_set.join_next().await { + match outcome { + Ok((handler, error)) => { + handlers.push(handler); + if let Some(error) = error { + record_first_error(&mut first_error, error); + } + } + Err(error) => record_first_error(&mut first_error, io::Error::other(error)), + } } - Ok(handlers) + SupervisorOutcome { handlers, error: first_error } }; - Ok(Supervisor { payload, cancel_tx, handling_loop_task: tokio::spawn(handling_loop) }) + Ok(Supervisor { + payload, + cancel_tx, + stop_handlers_tx, + handling_loop_task: tokio::spawn(handling_loop), + }) } diff --git a/crates/fspy_seccomp_unotify/tests/arg_types.rs b/crates/fspy_seccomp_unotify/tests/arg_types.rs index 93c1c9740..55c18d19d 100644 --- a/crates/fspy_seccomp_unotify/tests/arg_types.rs +++ b/crates/fspy_seccomp_unotify/tests/arg_types.rs @@ -25,6 +25,41 @@ use test_log::test; use tokio::{process::Command, task::spawn_blocking, time::timeout}; use tracing::{Level, span, trace}; +#[cfg(target_arch = "x86_64")] +core::arch::global_asm!( + ".global fspy_test_openat_gate", + "fspy_test_openat_gate:", + "movq %rcx, %r10", + "movq $257, %rax", + "syscall", + ".global fspy_test_openat_gate_post_syscall", + "fspy_test_openat_gate_post_syscall:", + "retq", + options(att_syntax) +); + +#[cfg(target_arch = "aarch64")] +core::arch::global_asm!( + ".global fspy_test_openat_gate", + "fspy_test_openat_gate:", + "mov x8, #56", + "svc #0", + ".global fspy_test_openat_gate_post_syscall", + "fspy_test_openat_gate_post_syscall:", + "ret", +); + +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +unsafe extern "C" { + fn fspy_test_openat_gate( + dirfd: libc::c_int, + path: *const libc::c_char, + flags: libc::c_int, + mode: libc::mode_t, + ) -> libc::c_long; + static fspy_test_openat_gate_post_syscall: u8; +} + #[derive(Debug, PartialEq, Eq, Clone)] enum Syscall { Openat { at_dir: OsString, path: Option }, @@ -47,11 +82,12 @@ impl SyscallRecorder { impl_handler!(SyscallRecorder: openat,); async fn run_in_pre_exec( + post_syscall_pc: u64, mut f: impl FnMut() -> io::Result<()> + Send + Sync + 'static, ) -> Result, Box> { Ok(timeout(Duration::from_secs(5), async move { let mut cmd = Command::new("/bin/echo"); - let supervisor = supervise::()?; + let supervisor = supervise::(post_syscall_pc)?; let payload = supervisor.payload().clone(); @@ -77,7 +113,11 @@ async fn run_in_pre_exec( assert!(exit_status.success()); - let recorders = supervisor.stop().await?; + let outcome = supervisor.stop().await; + if let Some(error) = outcome.error { + return Err(error); + } + let recorders = outcome.handlers; trace!("{} recorders awaited", recorders.len()); let syscalls = recorders.into_iter().flat_map(|recorder| recorder.0); @@ -88,7 +128,7 @@ async fn run_in_pre_exec( #[test(tokio::test)] async fn fd_and_path() -> Result<(), Box> { - let syscalls = run_in_pre_exec(|| { + let syscalls = run_in_pre_exec(0, || { set_current_dir("/")?; let home_fd = openat(AT_FDCWD, c"/home", OFlag::O_PATH, Mode::empty())?; let _ = openat(home_fd, c"open_at_home", OFlag::O_RDONLY, Mode::empty()); @@ -108,11 +148,48 @@ async fn fd_and_path() -> Result<(), Box> { Ok(()) } +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +#[test(tokio::test)] +async fn exact_gate_pc_bypasses_notification() -> Result<(), Box> { + let gate_path = CString::new("/fspy-gate-bypassed")?; + let notified_path = CString::new("/fspy-gate-notified")?; + let post_syscall_pc = + u64::try_from((&raw const fspy_test_openat_gate_post_syscall).addr()).unwrap(); + let syscalls = run_in_pre_exec(post_syscall_pc, move || { + // SAFETY: the assembly gate implements the Linux openat syscall ABI and receives a valid + // C string for the duration of the call. + let _ = unsafe { + fspy_test_openat_gate( + libc::AT_FDCWD, + gate_path.as_ptr(), + libc::O_RDONLY, + libc::mode_t::default(), + ) + }; + let _ = openat(AT_FDCWD, notified_path.as_c_str(), OFlag::O_RDONLY, Mode::empty()); + Ok(()) + }) + .await?; + + assert!(!syscalls.iter().any(|syscall| matches!( + syscall, + Syscall::Openat { path: Some(path), .. } if path == "/fspy-gate-bypassed" + ))); + assert_contains!( + syscalls, + &Syscall::Openat { + at_dir: current_dir().unwrap().into(), + path: Some("/fspy-gate-notified".into()), + } + ); + Ok(()) +} + #[tokio::test] async fn path_long() -> Result<(), Box> { let long_path = b"a".repeat(30000); let long_path_cstr = CString::new(long_path.as_slice()).unwrap(); - let syscalls = run_in_pre_exec(move || { + let syscalls = run_in_pre_exec(0, move || { let _ = openat(AT_FDCWD, long_path_cstr.as_c_str(), OFlag::O_RDONLY, Mode::empty()); Ok(()) }) @@ -131,7 +208,7 @@ async fn path_long() -> Result<(), Box> { async fn path_overflow() -> Result<(), Box> { let long_path = b"a".repeat(40000); let long_path_cstr = CString::new(long_path.as_slice()).unwrap(); - let syscalls = run_in_pre_exec(move || { + let syscalls = run_in_pre_exec(0, move || { let _ = openat(AT_FDCWD, long_path_cstr.as_c_str(), OFlag::O_RDONLY, Mode::empty()); Ok(()) }) diff --git a/crates/fspy_seccomp_unotify/tests/filter.rs b/crates/fspy_seccomp_unotify/tests/filter.rs new file mode 100644 index 000000000..0b12db585 --- /dev/null +++ b/crates/fspy_seccomp_unotify/tests/filter.rs @@ -0,0 +1,192 @@ +#![cfg(target_os = "linux")] + +use fspy_seccomp_unotify::payload::{Architecture, Filter}; + +const BPF_LD_W_ABS: u16 = 0x20; +const BPF_JMP_JEQ_K: u16 = 0x15; +const BPF_JMP_JSET_K: u16 = 0x45; +const BPF_RET_K: u16 = 0x06; +const X32_SYSCALL_BIT: i32 = 0x4000_0000; + +fn evaluate(filter: &Filter, syscall: i32, architecture: u32, instruction_pointer: u64) -> u32 { + let mut accumulator = 0; + let mut pc = 0; + loop { + let (code, jump_true, jump_false, operand) = filter.instructions()[pc].parts(); + match code { + BPF_LD_W_ABS => { + accumulator = match operand { + 0 => u32::from_ne_bytes(syscall.to_ne_bytes()), + 4 => architecture, + 8 => u32::try_from(instruction_pointer & u64::from(u32::MAX)).unwrap(), + 12 => u32::try_from(instruction_pointer >> 32).unwrap(), + _ => panic!("unexpected seccomp_data offset: {operand}"), + }; + pc += 1; + } + BPF_JMP_JEQ_K | BPF_JMP_JSET_K => { + let matches = match code { + BPF_JMP_JEQ_K => accumulator == operand, + BPF_JMP_JSET_K => accumulator & operand != 0, + _ => unreachable!(), + }; + let jump = if matches { jump_true } else { jump_false }; + pc += usize::from(jump) + 1; + } + BPF_RET_K => return operand, + _ => panic!("unexpected BPF opcode: {code:#x}"), + } + } +} + +const fn audit_arch(architecture: Architecture) -> u32 { + match architecture { + Architecture::X86_64 => 0xc000_003e, + Architecture::Aarch64 => 0xc000_00b7, + } +} + +const fn compat_audit_arch(architecture: Architecture) -> u32 { + match architecture { + Architecture::X86_64 => 0x4000_0003, + Architecture::Aarch64 => 0x4000_0028, + } +} + +#[test] +fn filter_shape_checks_architecture_syscalls_and_both_pc_halves() { + const GATE_PC: u64 = 0x1122_3344_5566_7788; + + for architecture in [Architecture::X86_64, Architecture::Aarch64] { + let filter = Filter::new(architecture, [257, 56], GATE_PC); + let instructions = filter.instructions(); + + let expected_len = if architecture == Architecture::X86_64 { 15 } else { 13 }; + assert_eq!(instructions.len(), expected_len); + assert_eq!(instructions[0].parts(), (BPF_LD_W_ABS, 0, 0, 4)); + assert_eq!(instructions[1].parts(), (BPF_JMP_JEQ_K, 1, 0, audit_arch(architecture))); + assert_eq!(instructions[2].parts(), (BPF_RET_K, 0, 0, libc::SECCOMP_RET_USER_NOTIF)); + assert!(instructions.iter().any(|instruction| instruction.parts().3 == 8)); + assert!(instructions.iter().any(|instruction| instruction.parts().3 == 12)); + } +} + +#[test] +fn x86_64_x32_syscalls_always_notify() { + const MONITORED: i32 = 257; + const UNMONITORED: i32 = 1; + const GATE_PC: u64 = 0x1122_3344_5566_7788; + + let architecture = Architecture::X86_64; + let filter = Filter::new(architecture, [MONITORED], GATE_PC); + + for syscall in [MONITORED, UNMONITORED] { + let x32_syscall = X32_SYSCALL_BIT | syscall; + assert_eq!( + evaluate(&filter, x32_syscall, audit_arch(architecture), GATE_PC), + libc::SECCOMP_RET_USER_NOTIF + ); + assert_eq!( + evaluate(&filter, x32_syscall, audit_arch(architecture), GATE_PC ^ 1), + libc::SECCOMP_RET_USER_NOTIF + ); + } +} + +#[test] +fn aarch64_does_not_treat_x32_syscall_bit_specially() { + const GATE_PC: u64 = 0x1122_3344_5566_7788; + + let architecture = Architecture::Aarch64; + let filter = Filter::new(architecture, [257], GATE_PC); + + assert_eq!( + evaluate(&filter, X32_SYSCALL_BIT | 1, audit_arch(architecture), GATE_PC ^ 1), + libc::SECCOMP_RET_ALLOW + ); +} + +#[test] +fn filter_actions_cover_first_middle_and_last_monitored_syscalls() { + const GATE_PC: u64 = 0x1122_3344_5566_7788; + + for architecture in [Architecture::X86_64, Architecture::Aarch64] { + let audit_arch = audit_arch(architecture); + let filter = Filter::new(architecture, [437, 56, 257], GATE_PC); + + for syscall in [56, 257, 437] { + assert_eq!( + evaluate(&filter, syscall, audit_arch, GATE_PC ^ 1), + libc::SECCOMP_RET_USER_NOTIF + ); + assert_eq!(evaluate(&filter, syscall, audit_arch, GATE_PC), libc::SECCOMP_RET_ALLOW); + } + } +} + +#[test] +fn filter_actions_support_maximum_monitored_syscall_list() { + const GATE_PC: u64 = 0x1122_3344_5566_7788; + + for architecture in [Architecture::X86_64, Architecture::Aarch64] { + let audit_arch = audit_arch(architecture); + let filter = Filter::new(architecture, 0..255, GATE_PC); + + for syscall in [0, 127, 254] { + assert_eq!( + evaluate(&filter, syscall, audit_arch, GATE_PC ^ 1), + libc::SECCOMP_RET_USER_NOTIF + ); + assert_eq!(evaluate(&filter, syscall, audit_arch, GATE_PC), libc::SECCOMP_RET_ALLOW); + } + assert_eq!(evaluate(&filter, 255, audit_arch, GATE_PC ^ 1), libc::SECCOMP_RET_ALLOW); + } +} + +#[test] +fn filter_actions_match_the_universal_gate_contract() { + const MONITORED: i32 = 257; + const UNMONITORED: i32 = 1; + const GATE_PC: u64 = 0x1122_3344_5566_7788; + + for architecture in [Architecture::X86_64, Architecture::Aarch64] { + let audit_arch = audit_arch(architecture); + let filter = Filter::new(architecture, [MONITORED], GATE_PC); + + assert_eq!(evaluate(&filter, MONITORED, audit_arch, GATE_PC), libc::SECCOMP_RET_ALLOW); + assert_eq!( + evaluate(&filter, MONITORED, audit_arch, GATE_PC ^ 1), + libc::SECCOMP_RET_USER_NOTIF + ); + assert_eq!( + evaluate(&filter, MONITORED, audit_arch, GATE_PC ^ (1 << 32)), + libc::SECCOMP_RET_USER_NOTIF + ); + assert_eq!( + evaluate(&filter, UNMONITORED, audit_arch, GATE_PC ^ 1), + libc::SECCOMP_RET_ALLOW + ); + } +} + +#[test] +fn compat_architecture_mismatch_notifies_instead_of_killing() { + const MONITORED: i32 = 257; + const UNMONITORED: i32 = 1; + const GATE_PC: u64 = 0x1122_3344_5566_7788; + + for architecture in [Architecture::X86_64, Architecture::Aarch64] { + let filter = Filter::new(architecture, [MONITORED], GATE_PC); + let compat_arch = compat_audit_arch(architecture); + + // TODO: Mark tracing incomplete when the supervisor receives this notification. + assert_eq!( + evaluate(&filter, MONITORED, compat_arch, GATE_PC), + libc::SECCOMP_RET_USER_NOTIF + ); + assert_eq!( + evaluate(&filter, UNMONITORED, compat_arch, GATE_PC), + libc::SECCOMP_RET_USER_NOTIF + ); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index eb0738129..0cf7318cf 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -2,7 +2,17 @@ mod shm_io; -use std::{env::temp_dir, fs::File, io, mem::MaybeUninit, ops::Deref, path::PathBuf, sync::Arc}; +use std::{ + borrow::Cow, + env::temp_dir, + ffi::OsStr, + fs::File, + io, + mem::{ManuallyDrop, MaybeUninit}, + ops::{Deref, DerefMut}, + path::PathBuf, + sync::Arc, +}; use shared_memory::{Shmem, ShmemConf}; pub use shm_io::FrameMut; @@ -11,7 +21,7 @@ use tracing::debug; use uuid::Uuid; use wincode::{ SchemaRead, SchemaWrite, - config::Config, + config::{Config, DefaultConfig}, error::{ReadResult, WriteResult}, io::{Reader, Writer}, }; @@ -108,44 +118,189 @@ impl ChannelConf { { conf = conf.allow_raw(true); } - let shm = conf.open().map_err(io::Error::other)?; + let shm = match conf.open().map_err(io::Error::other) { + Ok(shm) => shm, + Err(error) => { + let _ = lock_file.unlock(); + return Err(error); + } + }; + lock_file.unlock()?; // SAFETY: `shm` is a freshly opened shared memory region with valid pointer and size. - // Exclusive write access is ensured by the shared file lock held by this sender. + // Individual writes acquire the shared file lock before accessing it. let writer = unsafe { ShmWriter::new(shm) }; - Ok(Sender { writer, lock_file, lock_file_path: self.lock_file_path.clone() }) + Ok(Sender { writer, lock_file_path: self.lock_file_path.clone() }) } } pub struct Sender { writer: ShmWriter, lock_file_path: Box, - lock_file: File, } -impl Drop for Sender { - fn drop(&mut self) { - if let Err(err) = self.lock_file.unlock() { - debug!("Failed to unlock the shared IPC lock {:?}: {}", self.lock_file_path, err); - } +#[derive(Debug, thiserror::Error)] +pub enum ClaimFrameError { + #[error("The channel receiver has started collecting or was dropped")] + Closed(#[source] io::Error), + #[error("Failed to acquire the channel write lock")] + Lock(#[source] io::Error), + #[error("Not enough space remains in the shared-memory channel")] + InsufficientSpace, +} + +#[derive(Debug, thiserror::Error)] +pub enum SenderWriteError { + #[error(transparent)] + Channel(#[from] ClaimFrameError), + #[error(transparent)] + Encode(#[from] shm_io::WriteEncodedError), +} + +impl Sender { + #[must_use] + pub fn lock_file_path(&self) -> Cow<'_, OsStr> { + self.lock_file_path.to_cow_os_str() + } + + #[must_use] + pub fn is_lock_file(&self, path: &std::path::Path) -> bool { + path.as_os_str() == self.lock_file_path.to_cow_os_str() + } + + #[must_use] + pub fn is_lock_native_path(&self, path: &super::NativePath) -> bool { + path.strip_path_prefix(self.lock_file_path.to_cow_os_str(), |stripped| { + stripped.is_ok_and(|stripped| stripped.as_os_str().is_empty()) + }) + } + + /// Claims a frame while holding the receiver exclusion lock. + /// + /// # Errors + /// + /// Returns an error if the receiver has started collecting, the lock cannot + /// be acquired, or the channel has insufficient space. + pub fn claim_frame( + &self, + frame_size: std::num::NonZeroUsize, + ) -> Result, ClaimFrameError> { + let lock_file = self.open_lock_file()?; + // SAFETY: `open_lock_file` opens this sender's exact lock path and + // transfers the only handle ownership into the returned frame. + unsafe { self.claim_frame_with_lock(frame_size, lock_file) } + } + + /// Claims a frame using an independently opened lock-file handle. + /// + /// # Errors + /// + /// Returns an error if the receiver has started collecting, the lock cannot + /// be acquired, or the channel has insufficient space. + /// + /// # Safety + /// + /// `lock_file` must be a newly opened handle for this sender's exact lock + /// file. No alias may unlock it before the returned frame is dropped. + pub unsafe fn claim_frame_with_lock( + &self, + frame_size: std::num::NonZeroUsize, + lock_file: File, + ) -> Result, ClaimFrameError> { + Self::lock_for_write(&lock_file)?; + let Some(frame) = self.writer.claim_frame(frame_size) else { + let _ = lock_file.unlock(); + return Err(ClaimFrameError::InsufficientSpace); + }; + Ok(SenderFrame { + frame: ManuallyDrop::new(frame), + lock_file, + lock_file_path: &self.lock_file_path, + }) + } + + /// Encodes a value while holding the receiver exclusion lock. + /// + /// # Errors + /// + /// Returns an error if the receiver has started reading, encoding fails, or + /// the shared-memory channel has insufficient space. + pub fn write_encoded>( + &self, + value: &T, + ) -> Result<(), SenderWriteError> { + let lock_file = self.open_lock_file()?; + Self::lock_for_write(&lock_file)?; + let result = self.writer.write_encoded(value).map_err(SenderWriteError::from); + let unlock_result = lock_file + .unlock() + .map_err(|error| SenderWriteError::Channel(ClaimFrameError::Lock(error))); + result?; + unlock_result + } + + fn open_lock_file(&self) -> Result { + File::open(self.lock_file_path.to_cow_os_str()).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound + || (cfg!(unix) && error.raw_os_error() == Some(38)) + { + ClaimFrameError::Closed(error) + } else { + ClaimFrameError::Lock(error) + } + }) + } + + fn lock_for_write(lock_file: &File) -> Result<(), ClaimFrameError> { + lock_file.try_lock_shared().map_err(|error| { + let error: io::Error = error.into(); + if error.kind() == io::ErrorKind::WouldBlock { + ClaimFrameError::Closed(error) + } else { + ClaimFrameError::Lock(error) + } + }) } } -impl Deref for Sender { - type Target = ShmWriter; +pub struct SenderFrame<'a> { + frame: ManuallyDrop>, + lock_file: File, + lock_file_path: &'a NativeStr, +} + +impl Deref for SenderFrame<'_> { + type Target = [u8]; fn deref(&self) -> &Self::Target { - &self.writer + &self.frame[..] + } +} + +impl DerefMut for SenderFrame<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.frame[..] + } +} + +impl Drop for SenderFrame<'_> { + fn drop(&mut self) { + // Complete the frame before releasing the receiver exclusion lock. + // SAFETY: `frame` is dropped exactly once here. + unsafe { ManuallyDrop::drop(&mut self.frame) }; + if let Err(err) = self.lock_file.unlock() { + debug!("Failed to unlock the shared IPC lock {:?}: {}", self.lock_file_path, err); + } } } #[expect( clippy::non_send_fields_in_send_ty, - reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to" + reason = "`Sender` acquires a shared file lock around each write, so `shm` can be safely written to" )] -/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. +/// SAFETY: `Sender` acquires a shared file lock around each write, so `shm` can be safely written to. unsafe impl Send for Sender {} -/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. +/// SAFETY: `Sender` acquires a shared file lock around each write, so `shm` can be safely written to. unsafe impl Sync for Sender {} /// The unique receiver side of an IPC channel. @@ -218,7 +373,13 @@ impl<'a> Deref for ReceiverLockGuard<'a> { #[cfg(test)] mod tests { - use std::{num::NonZeroUsize, str::from_utf8}; + use std::{ + num::NonZeroUsize, + str::from_utf8, + sync::mpsc::{self, RecvTimeoutError}, + thread, + time::Duration, + }; use bstr::B; use subprocess_test::command_for_fn; @@ -258,6 +419,47 @@ mod tests { assert_eq!(B(&output.stdout), B("false")); } + #[test] + fn existing_sender_does_not_block_receiver_lock() { + let (conf, receiver) = channel(100).unwrap(); + let sender = conf.sender().unwrap(); + sender.claim_frame(NonZeroUsize::new(3).unwrap()).unwrap().copy_from_slice(b"old"); + + let lock = receiver.lock().unwrap(); + assert_eq!(lock.iter_frames().collect::>(), vec![b"old"]); + assert!(matches!( + sender.claim_frame(NonZeroUsize::new(3).unwrap()), + Err(ClaimFrameError::Closed(_)) + )); + } + + #[test] + fn overlapping_frames_from_one_sender_hold_independent_locks() { + let (conf, receiver) = channel(100).unwrap(); + let sender = conf.sender().unwrap(); + let mut first = sender.claim_frame(NonZeroUsize::new(3).unwrap()).unwrap(); + first.copy_from_slice(b"one"); + let mut second = sender.claim_frame(NonZeroUsize::new(3).unwrap()).unwrap(); + second.copy_from_slice(b"two"); + + let (tx, rx) = mpsc::channel(); + let reader = thread::spawn(move || { + let lock = receiver.lock().unwrap(); + let frames = lock.iter_frames().map(<[u8]>::to_vec).collect::>(); + tx.send(frames).unwrap(); + }); + + assert_eq!(rx.recv_timeout(Duration::from_millis(50)), Err(RecvTimeoutError::Timeout)); + drop(first); + assert_eq!(rx.recv_timeout(Duration::from_millis(50)), Err(RecvTimeoutError::Timeout)); + drop(second); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)).unwrap(), + vec![b"one".to_vec(), b"two".to_vec()] + ); + reader.join().unwrap(); + } + #[test] #[expect(clippy::print_stdout, reason = "test diagnostics")] fn forbid_new_senders_after_receiver_dropped() { diff --git a/crates/fspy_shared_unix/Cargo.toml b/crates/fspy_shared_unix/Cargo.toml index 38b9301bb..1912f898e 100644 --- a/crates/fspy_shared_unix/Cargo.toml +++ b/crates/fspy_shared_unix/Cargo.toml @@ -14,12 +14,9 @@ fspy_shared = { workspace = true } nix = { workspace = true, features = ["fs"] } stackalloc = { workspace = true } -[dev-dependencies] - [target.'cfg(target_os = "linux")'.dependencies] elf = { workspace = true } fspy_seccomp_unotify = { workspace = true, features = ["target"] } -memmap2 = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] phf = { workspace = true } diff --git a/crates/fspy_shared_unix/src/spawn/linux/mod.rs b/crates/fspy_shared_unix/src/spawn/linux/mod.rs index d3197da00..a0c4517e8 100644 --- a/crates/fspy_shared_unix/src/spawn/linux/mod.rs +++ b/crates/fspy_shared_unix/src/spawn/linux/mod.rs @@ -1,21 +1,13 @@ -#[cfg(not(target_env = "musl"))] -use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _, path::Path}; - use fspy_seccomp_unotify::{payload::SeccompPayload, target::install_target}; -#[cfg(not(target_env = "musl"))] -use memmap2::Mmap; #[cfg(not(target_env = "musl"))] +use crate::exec::append_path_env; use crate::{ - elf, - exec::{append_path_env, ensure_env}, - open_exec::open_executable, -}; -use crate::{ - exec::Exec, + exec::{Exec, ensure_env}, payload::{EncodedPayload, PAYLOAD_ENV_NAME}, }; +#[cfg(not(target_env = "musl"))] const LD_PRELOAD: &str = "LD_PRELOAD"; pub struct PreExec(SeccompPayload); @@ -33,31 +25,20 @@ impl PreExec { pub fn handle_exec( command: &mut Exec, encoded_payload: &EncodedPayload, + install_listener: bool, ) -> nix::Result> { - // On musl targets, LD_PRELOAD is not available (cdylib not supported). - // Always use seccomp-based tracking instead. #[cfg(not(target_env = "musl"))] { - let executable_fd = open_executable(Path::new(OsStr::from_bytes(&command.program)))?; - // SAFETY: The file descriptor is valid and we only read from the mapping. - let executable_mmap = unsafe { Mmap::map(&executable_fd) }.map_err(|io_error| { - nix::Error::try_from(io_error).unwrap_or(nix::Error::UnknownErrno) - })?; - if elf::is_dynamically_linked_to_libc(executable_mmap)? { - // Append (don't overwrite) so a user-provided LD_PRELOAD keeps - // working. fspy's shim goes last so user preloads that - // short-circuit a libc call stay invisible to fspy — what the - // OS actually executed is what we want to record. - append_path_env( - &mut command.envs, - LD_PRELOAD, - encoded_payload.payload.preload_path.as_os_str().as_bytes(), - ); - ensure_env(&mut command.envs, PAYLOAD_ENV_NAME, &encoded_payload.encoded_string)?; - return Ok(None); - } + use std::os::unix::ffi::OsStrExt as _; + + // Static targets ignore LD_PRELOAD but must carry it to dynamic descendants. + append_path_env( + &mut command.envs, + LD_PRELOAD, + encoded_payload.payload.preload_path.as_os_str().as_bytes(), + ); } + ensure_env(&mut command.envs, PAYLOAD_ENV_NAME, &encoded_payload.encoded_string)?; - command.envs.retain(|(name, _)| name != LD_PRELOAD && name != PAYLOAD_ENV_NAME); - Ok(Some(PreExec(encoded_payload.payload.seccomp_payload.clone()))) + Ok(install_listener.then(|| PreExec(encoded_payload.payload.seccomp_payload.clone()))) } diff --git a/crates/fspy_shared_unix/src/spawn/macos.rs b/crates/fspy_shared_unix/src/spawn/macos.rs index 88bd7d0a4..79cfb5d4a 100644 --- a/crates/fspy_shared_unix/src/spawn/macos.rs +++ b/crates/fspy_shared_unix/src/spawn/macos.rs @@ -27,6 +27,7 @@ impl PreExec { pub fn handle_exec( command: &mut Exec, encoded_payload: &EncodedPayload, + _install_listener: bool, ) -> nix::Result> { const DYLD_INSERT_LIBRARIES: &[u8] = b"DYLD_INSERT_LIBRARIES"; diff --git a/crates/fspy_shared_unix/src/spawn/mod.rs b/crates/fspy_shared_unix/src/spawn/mod.rs index e48125772..0540b08bc 100644 --- a/crates/fspy_shared_unix/src/spawn/mod.rs +++ b/crates/fspy_shared_unix/src/spawn/mod.rs @@ -35,10 +35,35 @@ use crate::{ /// /// Panics if the current working directory cannot be determined when converting a relative path to absolute. pub fn handle_exec( + command: &mut Exec, + config: ExecResolveConfig, + encoded_payload: &EncodedPayload, + on_path_access: impl FnMut(AccessMode, &Path), +) -> nix::Result> { + handle_exec_inner(command, config, encoded_payload, on_path_access, false) +} + +/// Prepares the root command and, on Linux, returns the action that installs its +/// single seccomp listener before exec. +/// +/// # Errors +/// +/// Returns the same errors as [`handle_exec`]. +pub fn handle_root_exec( + command: &mut Exec, + config: ExecResolveConfig, + encoded_payload: &EncodedPayload, + on_path_access: impl FnMut(AccessMode, &Path), +) -> nix::Result> { + handle_exec_inner(command, config, encoded_payload, on_path_access, true) +} + +fn handle_exec_inner( command: &mut Exec, config: ExecResolveConfig, encoded_payload: &EncodedPayload, mut on_path_access: impl FnMut(AccessMode, &Path), + install_listener: bool, ) -> nix::Result> { let mut on_path_access = |mode: AccessMode, path: &Path| { if path.is_absolute() { @@ -52,5 +77,5 @@ pub fn handle_exec( command.resolve(&mut on_path_access, config)?; on_path_access(AccessMode::READ, Path::new(OsStr::from_bytes(&command.program))); - os_specific::handle_exec(command, encoded_payload) + os_specific::handle_exec(command, encoded_payload, install_listener) } diff --git a/crates/fspy_syscall_intercept/Cargo.toml b/crates/fspy_syscall_intercept/Cargo.toml new file mode 100644 index 000000000..185a1aafc --- /dev/null +++ b/crates/fspy_syscall_intercept/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fspy_syscall_intercept" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[lib] +doctest = false + +[target.'cfg(all(target_os = "linux", not(target_env = "musl"), any(target_arch = "aarch64", target_arch = "x86_64")))'.dependencies] +libc = { workspace = true } +object = { workspace = true } + +[target.'cfg(all(target_os = "linux", not(target_env = "musl"), target_arch = "x86_64"))'.dependencies] +iced-x86 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/fspy_syscall_intercept/README.md b/crates/fspy_syscall_intercept/README.md new file mode 100644 index 000000000..08c2a678c --- /dev/null +++ b/crates/fspy_syscall_intercept/README.md @@ -0,0 +1,11 @@ +## fspy_syscall_intercept + +Linux x86-64 and AArch64 startup-only interception for selected raw syscall instructions. A caller supplies the exact post-syscall PC permitted by its seccomp filter and a callback that decides whether each intercepted syscall may use that gate. Declined calls execute at a separate non-allowlisted syscall gate so the seccomp listener remains the correctness fallback. + +The patcher conservatively rewrites raw syscall instructions only in monitored open/stat/access/getdents/exec function symbols in ELF images already present at startup. It requires exactly one current thread and only reads non-empty function symbols from readable, non-writable executable `PT_LOAD` mappings; each small patch site is revalidated against the live mapping immediately before it is changed. x86-64 accepts only a small set of complete post-syscall compare/test patterns and rejects known incoming direct branches and relocation targets. AArch64 requires an aligned `svc #0` in the entry basic block and an in-range branch. Raw `mmap`, `mprotect`, and `munmap` calls are used while patching. Generated gates and trampolines transition from writable to executable and are never writable and executable together. + +The internal dispatcher preserves `errno`, invokes the registered callback with an architecture-neutral `SyscallContext`, and selects the allowlisted or fallback gate from the callback result. `open_control_file` uses the allowlisted gate after successful initialization, while `raw_syscall6` always executes at a non-allowlisted PC. Setting `FSPY_DISABLE_ACCELERATION` disables initialization. + +x86-64 preserves the full XCR0 user state with XSAVE/XRSTOR, clears the complete XSAVE header before saving, and disables interception if XSAVE is unavailable or its runtime area exceeds 4 KiB. AArch64 disables interception when SVE, SME, or an active BTI-guarded mapping is detected. ENDBR64 and BTI pads cover generated indirect targets. Missing symbols, code outside a function's provable entry basic block, branch-range or mapping failures, later `dlopen` images, and callback declines all retain the fallback path. + +This is a trusted-workload design, not a stop-the-world binary rewriter. The `/proc/self/task` check is inherently TOCTOU: a cooperating workload must not create a thread between the check and the final patch. ELF file comparison, relocation inspection, and branch scanning can also race hostile file replacement, ptrace, or concurrent code mutation. A direct edge originating outside every available function symbol and lacking relocation metadata remains unknowable; the x86-64 pattern allowlist limits, but cannot eliminate, that trusted-input assumption. A signal, cancellation, or fatal fault while a text page is temporarily non-executable can still terminate the process. A failed final `mprotect` restores original bytes and retries original protections, but recovery cannot be guaranteed if the kernel rejects that second protection change. Later `dlopen` mappings are intentionally not patched. diff --git a/crates/fspy_syscall_intercept/src/arch/aarch64.rs b/crates/fspy_syscall_intercept/src/arch/aarch64.rs new file mode 100644 index 000000000..9c00bb5fd --- /dev/null +++ b/crates/fspy_syscall_intercept/src/arch/aarch64.rs @@ -0,0 +1,623 @@ +use core::arch::{asm, naked_asm}; + +use super::patcher::PatchSite; +use crate::dispatcher::SyscallContext; + +/* +Linux's AArch64 raw syscall convention is separate from the AAPCS64 function +calling convention: + + x8 syscall number + x0-x5 arguments 0 through 5 + x0 result on return (including a negative -errno value) + +`svc #0` raises a synchronous exception from userspace; the kernel reads those +registers, performs the syscall, and resumes at the instruction following the +SVC. It does not behave like a function call: it neither writes the link +register x30 nor consumes a stack return address. The interception path below +must therefore make its ordinary Rust call and its generated branches appear, +to the resumed program, as though only the SVC had run. + +AArch64 instructions are fixed-width 32-bit words. These byte strings are the +little-endian encodings copied into generated executable mappings. `bti c` is +the valid indirect-call landing pad for a gate entered with BLR; `ret` branches +to x30. +*/ +pub const LANDING_PAD: &[u8] = &[0x5f, 0x24, 0x03, 0xd5]; // bti c +pub const ALLOWED_GATE_INSTRUCTION: &[u8] = &[0x01, 0x00, 0x00, 0xd4]; // svc #0 +pub const RETURN_INSTRUCTION: &[u8] = &[0xc0, 0x03, 0x5f, 0xd6]; // ret +pub const SYSCALL_INSTRUCTION_LENGTH: usize = 4; +#[cfg(test)] +pub const MAX_BRANCH_DISTANCE: usize = 128 * 1024 * 1024 - 4; + +const SVC_ZERO: u32 = 0xd400_0001; +const BTI_J: u32 = 0xd503_249f; +const HWCAP_SVE: libc::c_ulong = 1 << 22; +const HWCAP2_BTI: libc::c_ulong = 1 << 17; +const HWCAP2_SME: libc::c_ulong = 1 << 23; + +pub fn runtime_supported() -> bool { + /* + Saving q0-q31 covers the fixed-width 128-bit FP/Advanced SIMD state, but it + does not cover SVE's scalable Z and predicate registers or SME's streaming + mode and ZA state. A callback compiled for a machine exposing either + extension may legally disturb that additional state. Since its size and + save protocol cannot be represented by the fixed frame below, interception + is disabled rather than returning with partially restored register state. + + BTI is enforced per executable mapping. The generated indirect targets do + contain suitable BTI instructions, but patching an existing BTI-guarded + mapping also has to preserve that mapping's guarded-page property across + protection changes. The patcher records ordinary R/W/X protection only, so + reject the process if smaps reports any `bt` mapping instead of silently + weakening or incorrectly restoring its BTI policy. + + Pointer authentication (PAC) needs no analogous state save. A possibly + signed x30 is treated as opaque data: it is parked before BL/BLR overwrite + x30 and restored after the temporary calls. The original function later + authenticates it with its original SP restored; this code never attempts to + sign or authenticate the caller's link register itself. + */ + // SAFETY: getauxval reads the process auxiliary vector and has no pointer + // arguments. Missing entries return zero. + let hwcap = unsafe { libc::getauxval(libc::AT_HWCAP) }; + // SAFETY: as above, for the second capability word. + let hwcap2 = unsafe { libc::getauxval(libc::AT_HWCAP2) }; + if hwcap & HWCAP_SVE != 0 || hwcap2 & HWCAP2_SME != 0 { + return false; + } + if hwcap2 & HWCAP2_BTI != 0 { + let Ok(smaps) = std::fs::read("/proc/self/smaps") else { + return false; + }; + if has_bti_guarded_mapping(&smaps) { + return false; + } + } + true +} + +fn has_bti_guarded_mapping(smaps: &[u8]) -> bool { + smaps.split(|byte| *byte == b'\n').any(|line| { + line.strip_prefix(b"VmFlags:") + .is_some_and(|flags| flags.split(|byte| *byte == b' ').any(|flag| flag == b"bt")) + }) +} + +#[repr(C)] +pub struct DispatchContext { + /* + `dispatch_entry` passes its SP directly as `&DispatchContext`. The C layout + makes these fields coincide with the first nine saved 64-bit registers: + x0-x7 followed by x8. Linux consumes only x0-x5 as arguments, but retaining + x6/x7 keeps x8, the syscall number, at its natural register-save offset. + */ + arg0: usize, + arg1: usize, + arg2: usize, + arg3: usize, + arg4: usize, + arg5: usize, + _arg6: usize, + _arg7: usize, + syscall_number: usize, +} + +pub const fn syscall_context(context: &DispatchContext) -> SyscallContext { + SyscallContext::new( + context.syscall_number, + [context.arg0, context.arg1, context.arg2, context.arg3, context.arg4, context.arg5], + ) +} + +#[unsafe(naked)] +unsafe extern "C" fn fallback_gate() -> isize { + /* + BLR enters this tiny raw-syscall gate, so `bti c` satisfies guarded indirect + call semantics where BTI is active. SVC returns to the following RET, and + RET uses the x30 link written by BLR to get back to `dispatch_entry`. + */ + naked_asm!("bti c", "svc #0", "ret"); +} + +pub fn fallback_gate_address() -> usize { + fallback_gate as *const () as usize +} + +#[unsafe(naked)] +unsafe extern "C" fn dispatch_entry() { + /* + The site trampoline reaches this function with BR, not BL. Its first + instruction has already pushed the caller's x16/x17 pair, x16 now points to + that trampoline's return stub, x17 was used to hold this function's address, + and x30 is still the interrupted function's link register. + + x16 and x17 are AAPCS64's IP0/IP1 intra-procedure-call scratch registers. + They are ideal for routing through the trampoline, but the original values + still have to be restored because an SVC instruction itself does not consume + them. x30 is the link register: each BL/BLR below overwrites it, so the + interrupted value is explicitly saved. x19-x29 are callee-saved by AAPCS64; + the assembly does not modify them and `choose_gate` must preserve them as an + ordinary extern "C" callee. x18 is platform-defined/caller-clobbered here and + is saved explicitly. + + Both the trampoline's 16-byte push and this 704-byte allocation are + multiples of 16. Thus SP remains 16-byte aligned, as AAPCS64 requires at the + call to Rust and at every public call boundary. + + Stack layout after `sub sp, sp, #704` (D is the resulting SP): + + high addresses + D + 720 interrupted program's SP + D + 712 original x17 \ + D + 704 original x16 } trampoline frame (16 bytes) + D + 192 q0-q31 } qN is at D + 192 + 16*N + (512 bytes, through D + 703) + D + 176 alignment/padding } 16 bytes + D + 168 FPSR \ + D + 160 FPCR } 8 bytes each + D + 152 NZCV / + D + 144 per-site return-stub address (incoming x16) + D + 136 original x30 + D + 128 x18 + D + 0 x0-x15 xN is at D + 8*N + (through D + 127) + low addresses + + The q-register block preserves all 128 bits of every FP/Advanced SIMD + register, including more than the normal function ABI promises for q8-q15. + That stronger save is necessary because a real syscall leaves vector state + intact while the Rust callback may use any caller-clobbered vector register. + NZCV holds the integer condition flags; FPCR controls FP modes and rounding; + FPSR holds cumulative FP exception/status bits. Those are similarly restored + before executing the selected gate so callback arithmetic is unobservable. + + End-to-end control flow is: + + patched SVC site + | direct B + v + trampoline: save x16/x17, load return stub and dispatch_entry + | BR x17 + v + dispatch_entry: save state -> choose_gate(context) -> restore state + | BLR selected gate + v + gate: BTI C -> SVC #0 -> RET + | syscall result is now in x0 + v + dispatch_entry: restore x30, discard its frame, BR return stub + | + v + return stub: BTI J -> restore x16/x17 -> B instruction after old SVC + + No saved x0 is reloaded after the gate: the kernel's x0 result must reach the + original continuation unchanged. + */ + naked_asm!( + // Accept the jump used by the trampoline (and a call if another valid + // entry path uses one), then reserve the fixed dispatch frame. + "bti jc", + "sub sp, sp, #704", + // Snapshot general registers needed both for syscall transparency and + // for the C-layout DispatchContext passed to Rust. + "stp x0, x1, [sp, #0]", + "stp x2, x3, [sp, #16]", + "stp x4, x5, [sp, #32]", + "stp x6, x7, [sp, #48]", + "stp x8, x9, [sp, #64]", + "stp x10, x11, [sp, #80]", + "stp x12, x13, [sp, #96]", + "stp x14, x15, [sp, #112]", + "stp x18, x30, [sp, #128]", + "str x16, [sp, #144]", + // System-register reads capture condition and FP control/status state + // before the Rust call is allowed to change it. + "mrs x9, nzcv", + "str x9, [sp, #152]", + "mrs x9, fpcr", + "str x9, [sp, #160]", + "mrs x9, fpsr", + "str x9, [sp, #168]", + // Preserve the complete fixed-width vector register file. The 16-byte + // padding before this block keeps every Q-register slot aligned. + "stp q0, q1, [sp, #192]", + "stp q2, q3, [sp, #224]", + "stp q4, q5, [sp, #256]", + "stp q6, q7, [sp, #288]", + "stp q8, q9, [sp, #320]", + "stp q10, q11, [sp, #352]", + "stp q12, q13, [sp, #384]", + "stp q14, q15, [sp, #416]", + "stp q16, q17, [sp, #448]", + "stp q18, q19, [sp, #480]", + "stp q20, q21, [sp, #512]", + "stp q22, q23, [sp, #544]", + "stp q24, q25, [sp, #576]", + "stp q26, q27, [sp, #608]", + "stp q28, q29, [sp, #640]", + "stp q30, q31, [sp, #672]", + // x0 is the first AAPCS64 function argument. choose_gate sees the saved + // x0-x8 block as DispatchContext and returns a gate address in x0. + "mov x0, sp", + "bl {choose_gate}", + "mov x16, x0", + // Undo every callback-visible vector and status change before entering + // the gate. x17 is scratch while writing the system registers. + "ldp q0, q1, [sp, #192]", + "ldp q2, q3, [sp, #224]", + "ldp q4, q5, [sp, #256]", + "ldp q6, q7, [sp, #288]", + "ldp q8, q9, [sp, #320]", + "ldp q10, q11, [sp, #352]", + "ldp q12, q13, [sp, #384]", + "ldp q14, q15, [sp, #416]", + "ldp q16, q17, [sp, #448]", + "ldp q18, q19, [sp, #480]", + "ldp q20, q21, [sp, #512]", + "ldp q22, q23, [sp, #544]", + "ldp q24, q25, [sp, #576]", + "ldp q26, q27, [sp, #608]", + "ldp q28, q29, [sp, #640]", + "ldp q30, q31, [sp, #672]", + "ldr x17, [sp, #160]", + "msr fpcr, x17", + "ldr x17, [sp, #168]", + "msr fpsr, x17", + // Restore syscall inputs and all other explicitly saved GPRs. x16 keeps + // the selected gate; x17 remains temporary until the return stub later + // restores the interrupted x16/x17 pair. + "ldp x0, x1, [sp, #0]", + "ldp x2, x3, [sp, #16]", + "ldp x4, x5, [sp, #32]", + "ldp x6, x7, [sp, #48]", + "ldp x8, x9, [sp, #64]", + "ldp x10, x11, [sp, #80]", + "ldp x12, x13, [sp, #96]", + "ldp x14, x15, [sp, #112]", + "ldr x18, [sp, #128]", + "ldr x17, [sp, #152]", + "msr nzcv, x17", + // BLR supplies the gate's RET address in x30. After SVC returns, retain + // its x0 result, recover the interrupted x30 and return-stub pointer, + // then remove only the 704-byte dispatch frame. + "blr x16", + "ldr x30, [sp, #136]", + "ldr x16, [sp, #144]", + "add sp, sp, #704", + "br x16", + choose_gate = sym super::dispatcher::choose_gate, + ); +} + +pub fn dispatch_entry_address() -> usize { + dispatch_entry as *const () as usize +} + +/// Executes a raw syscall at a PC that is intentionally not allowlisted. +/// +/// # Safety +/// The caller must uphold the pointer and argument requirements of `number`. +#[inline(never)] +pub unsafe fn raw_syscall6( + number: usize, + arg0: usize, + arg1: usize, + arg2: usize, + arg3: usize, + arg4: usize, + arg5: usize, +) -> isize { + let result: isize; + /* + Rust's explicit-register operands are the binding between function + parameters and the Linux raw syscall ABI. `inlateout("x0")` expresses the + dual role of x0: it carries arg0 into SVC and the unprocessed kernel result + out. x1-x5 are input-only arguments and x8 carries the syscall number. No + libc errno translation occurs here. `nostack` is valid because this assembly + neither changes SP nor addresses a compiler-created stack slot. + + This helper deliberately contains its own SVC rather than using the mapped + allowlisted gate. Internal mapping/protection operations can therefore issue + a raw syscall without recursively passing through the interception route. + */ + // SAFETY: the caller supplies the syscall-specific argument validity. The + // operands match the Linux AArch64 raw syscall ABI. + unsafe { + asm!( + "svc #0", + inlateout("x0") arg0 => result, + in("x1") arg1, + in("x2") arg2, + in("x3") arg3, + in("x4") arg4, + in("x5") arg5, + in("x8") number, + options(nostack), + ); + } + result +} + +/// Executes a raw syscall through a caller-provided gate. +/// +/// # Safety +/// The gate must implement the fspy raw syscall gate ABI, and the caller must +/// uphold the pointer and argument requirements of `number`. +#[expect(clippy::too_many_arguments, reason = "mirrors the six-argument raw syscall ABI")] +pub unsafe fn raw_syscall6_at( + gate: usize, + number: usize, + arg0: usize, + arg1: usize, + arg2: usize, + arg3: usize, + arg4: usize, + arg5: usize, +) -> isize { + let result: isize; + /* + This variant binds the same x8/x0-x5 syscall registers but performs an + AAPCS64 indirect call through x16 (IP0), the fspy gate convention. BLR writes + the address of the next instruction to x30 so the gate's RET can return; + declaring x30 as a late output tells the compiler that its old value does + not survive the inline assembly. The selected gate is responsible for + preserving every register not named by this raw gate ABI. + */ + // SAFETY: the caller guarantees the gate and syscall arguments are valid. + unsafe { + asm!( + "blr x16", + in("x16") gate, + inlateout("x0") arg0 => result, + in("x1") arg1, + in("x2") arg2, + in("x3") arg3, + in("x4") arg4, + in("x5") arg5, + in("x8") number, + lateout("x30") _, + ); + } + result +} + +pub fn collect_sites(function_address: usize, bytes: &[u8]) -> Vec { + /* + Every AArch64 instruction is four-byte aligned and four bytes long, so a + complete decoder is unnecessary to find the exact `svc #0` word. Scanning + stops at the first control transfer: only the entry basic block is known to + be instructions, while bytes after it could be an unreachable literal pool + or jump-table data that happens to equal the SVC opcode. + */ + if !function_address.is_multiple_of(4) { + return Vec::new(); + } + let mut sites = Vec::new(); + for (index, instruction) in bytes.chunks_exact(4).enumerate() { + let Ok(instruction) = instruction.try_into() else { + break; + }; + let instruction = u32::from_le_bytes(instruction); + if instruction == SVC_ZERO { + sites.push(PatchSite { + address: function_address + index * 4, + overwrite_length: 4, + displaced: Vec::new(), + restore_protection: libc::PROT_READ | libc::PROT_EXEC, + }); + } else if is_control_flow(instruction) { + // As on x86-64, only the entry basic block is treated as provable + // code. Fixed-width decoding proves boundaries within that block. + break; + } + } + sites +} + +pub const fn collect_known_direct_targets(_function_address: usize, _bytes: &[u8]) -> Vec { + Vec::new() +} + +const fn is_control_flow(instruction: u32) -> bool { + // B/BL, CBZ/CBNZ, TBZ/TBNZ, B.cond, BR/BLR/RET/ERET/DRPS, and exception + // generation. SVC #0 is handled before this predicate. + instruction & 0x7c00_0000 == 0x1400_0000 + || instruction & 0x7e00_0000 == 0x3400_0000 + || instruction & 0x7e00_0000 == 0x3600_0000 + || instruction & 0xff00_0010 == 0x5400_0000 + || instruction & 0xfe00_0000 == 0xd600_0000 + || instruction & 0xff00_0000 == 0xd400_0000 +} + +pub const fn trampoline_size(_site: &PatchSite) -> usize { + 48 +} + +pub fn emit_trampoline(output: &mut [u8], address: usize, site: &PatchSite) -> Option { + /* + One four-byte SVC is replaced by one four-byte direct branch, so there is no + displaced neighboring instruction to replay. Each generated 48-byte record + has code followed by naturally aligned absolute-address literals: + + offset contents + +0x00 STP x16, x17, [sp, #-16]! save scratch pair + +0x04 LDR x16, [PC-relative] address + 0x10 + +0x08 LDR x17, [PC-relative] dispatch_entry address + +0x0c BR x17 enter shared dispatcher + +0x10 BTI J return-stub landing pad + +0x14 LDP x16, x17, [sp], #16 restore pair and SP + +0x18 B site + 4 resume after original SVC + +0x1c padding + +0x20 64-bit return-stub literal (address + 0x10) + +0x28 64-bit dispatch_entry literal + + The entry branch from patched code and the final continuation branch are + direct, so they do not require BTI landing pads. BR into `dispatch_entry` + requires its `bti jc`; BR back into this per-site stub requires `bti j`. + */ + if !site.displaced.is_empty() { + return None; + } + let output = output.get_mut(..48)?; + let return_stub = address.checked_add(16)?; + write_instruction(output, 0, 0xa9bf_47f0); // stp x16, x17, [sp, #-16]! + write_instruction(output, 4, 0x5800_00f0); // ldr x16, return literal + write_instruction(output, 8, 0x5800_0111); // ldr x17, dispatch literal + write_instruction(output, 12, 0xd61f_0220); // br x17 + write_instruction(output, 16, BTI_J); + write_instruction(output, 20, 0xa8c1_47f0); // ldp x16, x17, [sp], #16 + write_instruction( + output, + 24, + encode_branch(address + 24, site.address + SYSCALL_INSTRUCTION_LENGTH)?, + ); + output[32..40].copy_from_slice(&u64::try_from(return_stub).ok()?.to_le_bytes()); + output[40..48].copy_from_slice(&u64::try_from(dispatch_entry_address()).ok()?.to_le_bytes()); + Some(48) +} + +pub fn encode_site_branch(site: &PatchSite, trampoline: usize) -> Option> { + Some(encode_branch(site.address, trampoline)?.to_le_bytes().to_vec()) +} + +fn write_instruction(output: &mut [u8], offset: usize, instruction: u32) { + // AArch64 Linux is little-endian here; emit complete instruction words so + // no partially encoded immediate or host-endian representation leaks out. + output[offset..offset + 4].copy_from_slice(&instruction.to_le_bytes()); +} + +fn encode_branch(from: usize, to: usize) -> Option { + /* + The unconditional `B` encoding is: + + 31 26 25 0 + +----------------+--------------------------------+ + | 0b000101 | signed imm26 in instruction words | + +----------------+--------------------------------+ + + The architectural PC is the address of the branch instruction. Since the + signed immediate is shifted left by two, targets must be four-byte aligned + and the byte displacement range is -2^27 through 2^27 - 4 (roughly + +/-128 MiB). After checking that range, shifting and masking retains the + 26-bit two's-complement representation for both forward and backward + branches; OR with 0x14000000 supplies the opcode without setting the BL link + bit. The nearby-mapping allocator retries until every site has such a + representable branch. + */ + let displacement = i64::try_from(to).ok()? - i64::try_from(from).ok()?; + if displacement % 4 != 0 || !(-(1 << 27)..(1 << 27)).contains(&displacement) { + return None; + } + let immediate = u32::try_from((displacement >> 2) & 0x03ff_ffff).ok()?; + Some(0x1400_0000 | immediate) +} + +pub unsafe fn clear_cache(start: usize, end: usize) { + /* + Writing instruction bytes through a data-cache view does not by itself make + them visible to instruction fetch on every AArch64 CPU. CTR_EL0 describes + both minimum cache-line sizes and whether explicit maintenance is needed: + + - IDC (bit 28) clear: clean each D-cache line by VA to the point of + unification with `dc cvau`. + - DIC (bit 29) clear: invalidate each I-cache line by VA to the point of + unification with `ic ivau`. + + D-cache and I-cache line sizes may differ. Each loop therefore derives its + own `4 << log2_words` byte size and aligns the beginning of the half-open + [start, end) range down to that boundary. The first inner-shareable DSB + completes and orders the data-side work before instruction invalidation. The + final DSB waits for invalidation to complete, and ISB discards stale + prefetched instructions so subsequent execution observes the new code. + */ + let cache_type: usize; + // SAFETY: CTR_EL0 is readable from EL0 on Linux. + unsafe { + asm!("mrs {value}, ctr_el0", value = out(reg) cache_type, options(nostack, preserves_flags)); + }; + + if cache_type & (1 << 28) == 0 { + let line_size = 4usize << ((cache_type >> 16) & 0xf); + let mut address = start & !(line_size - 1); + while address < end { + // SAFETY: dc cvau accepts any virtual address in the generated range. + unsafe { + asm!("dc cvau, {address}", address = in(reg) address, options(nostack, preserves_flags)); + }; + address += line_size; + } + } + // SAFETY: required ordering between data writes and instruction invalidation. + unsafe { asm!("dsb ish", options(nostack, preserves_flags)) }; + if cache_type & (1 << 29) == 0 { + let line_size = 4usize << (cache_type & 0xf); + let mut address = start & !(line_size - 1); + while address < end { + // SAFETY: ic ivau accepts any virtual address in the generated range. + unsafe { + asm!("ic ivau, {address}", address = in(reg) address, options(nostack, preserves_flags)); + }; + address += line_size; + } + } + // SAFETY: complete invalidation before executing newly written instructions. + unsafe { + asm!("dsb ish", "isb", options(nostack, preserves_flags)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_only_aligned_svc_zero() { + let bytes = [ + 0x1f, 0x20, 0x03, 0xd5, // nop + 0x01, 0x00, 0x00, 0xd4, // svc #0 + 0x21, 0x00, 0x00, 0xd4, // svc #1 + ]; + let sites = collect_sites(0x1000, &bytes); + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].address, 0x1004); + assert!(collect_sites(0x1001, &bytes).is_empty()); + + let branch_then_svc = [ + 0x01, 0x00, 0x00, 0x14, // b +4 + 0x01, 0x00, 0x00, 0xd4, // svc #0, not in the entry block + ]; + assert!(collect_sites(0x1000, &branch_then_svc).is_empty()); + } + + #[test] + fn branch_range_is_checked() { + assert!(encode_branch(0x1000, 0x2000).is_some()); + assert!(encode_branch(0x1000, 0x1002).is_none()); + assert!(encode_branch(0, 128 * 1024 * 1024).is_none()); + } + + #[test] + fn detects_bti_guarded_smaps_flag() { + assert!(has_bti_guarded_mapping(b"VmFlags: rd ex mr mw me bt\n")); + assert!(!has_bti_guarded_mapping(b"VmFlags: rd ex mr mw me\n")); + } + + #[test] + fn trampoline_marks_indirect_targets() { + let site = PatchSite { + address: 0x1000, + overwrite_length: 4, + displaced: Vec::new(), + restore_protection: libc::PROT_READ | libc::PROT_EXEC, + }; + let address = 0x2000; + let mut trampoline = vec![0; trampoline_size(&site)]; + assert_eq!(emit_trampoline(&mut trampoline, address, &site), Some(48)); + assert_eq!(u32::from_le_bytes(trampoline[16..20].try_into().unwrap()), BTI_J); + assert_eq!( + u64::from_le_bytes(trampoline[32..40].try_into().unwrap()), + u64::try_from(address + 16).unwrap() + ); + } +} diff --git a/crates/fspy_syscall_intercept/src/arch/x86_64.rs b/crates/fspy_syscall_intercept/src/arch/x86_64.rs new file mode 100644 index 000000000..61f100233 --- /dev/null +++ b/crates/fspy_syscall_intercept/src/arch/x86_64.rs @@ -0,0 +1,689 @@ +use core::arch::{ + asm, naked_asm, + x86_64::{__cpuid, __cpuid_count}, +}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use iced_x86::{Code, Decoder, DecoderOptions, FlowControl, OpKind}; + +use super::patcher::PatchSite; +use crate::dispatcher::SyscallContext; + +/* + * Linux's x86-64 raw syscall convention is similar to, but importantly not the + * same as, the SysV function-call convention: + * + * value register on entry register on return + * syscall number RAX RAX (result or -errno) + * arguments 0..2 RDI, RSI, RDX + * argument 3 R10 + * arguments 4..5 R8, R9 + * + * A normal SysV call would put argument 3 in RCX. SYSCALL cannot do that: + * hardware copies the address immediately after SYSCALL into RCX and copies + * the caller's RFLAGS into R11 while entering the kernel. Consequently Linux + * declares RCX and R11 clobbered by every syscall. Unlike CALL, SYSCALL does + * not push either value on the user stack. The interception path below uses + * those two already-clobbered registers to carry per-site return metadata, then + * recreates the RCX/R11 values that the original SYSCALL would have produced. + */ + +/* + * Every generated indirect target starts with ENDBR64. On CPUs enforcing CET + * Indirect Branch Tracking, an indirect CALL or JMP may land only on ENDBR64; + * without CET it behaves as a harmless instruction. The other byte strings + * form the architecture-specific body of the exact-address allowed gate: + * + * ENDBR64; SYSCALL; RET + * + * Keeping these as explicit bytes also lets the patcher verify syscall sites + * and construct the gate without relying on an assembler at runtime. + */ +pub const LANDING_PAD: &[u8] = &[0xf3, 0x0f, 0x1e, 0xfa]; // endbr64 +pub const ALLOWED_GATE_INSTRUCTION: &[u8] = &[0x0f, 0x05]; // syscall +pub const RETURN_INSTRUCTION: &[u8] = &[0xc3]; // ret +pub const SYSCALL_INSTRUCTION_LENGTH: usize = 2; +#[cfg(test)] +pub const MAX_BRANCH_DISTANCE: usize = i32::MAX as usize; + +/* + * XSAVE uses a standard-format memory image: + * + * byte 0..511 legacy x87/SSE state + * byte 512..575 64-byte XSAVE header + * byte 576.. enabled extended components, at CPUID-defined offsets + * + * The dispatch frame reserves 128 bytes for GPRs and bookkeeping, followed by + * a 64-byte-aligned, conservatively bounded XSAVE image. Runtime detection + * rejects machines whose XCR0-enabled user state needs more than that image. + */ +const MAX_XSAVE_SIZE: usize = 4 * 1024; +const XSAVE_OFFSET: usize = 128; +const XSAVE_HEADER_OFFSET: usize = XSAVE_OFFSET + 512; +const DISPATCH_FRAME_SIZE: usize = XSAVE_OFFSET + MAX_XSAVE_SIZE; +const CPUID_XSAVE: u32 = 1 << 26; +const CPUID_OSXSAVE: u32 = 1 << 27; + +static FSPY_ACCEL_XSAVE_MASK: AtomicU64 = AtomicU64::new(0); + +pub fn runtime_supported() -> bool { + /* + * CPUID establishes that both the processor and the kernel support the + * XSAVE mechanism. XGETBV then returns the process-visible XCR0 component + * mask used by XSAVE/XRSTOR. x87 and SSE are mandatory here because their + * legacy area and MXCSR are part of the state the Rust callback may alter. + * CPUID leaf 0x0d reports the required size for exactly this XCR0 mask. + */ + let maximum_leaf = __cpuid(0).eax; + if maximum_leaf < 0x0d { + return false; + } + let features = __cpuid(1).ecx; + if features & (CPUID_XSAVE | CPUID_OSXSAVE) != CPUID_XSAVE | CPUID_OSXSAVE { + return false; + } + + let mask_low: u32; + let mask_high: u32; + // SAFETY: OSXSAVE confirms that XGETBV is enabled by the kernel. + unsafe { + asm!( + "xgetbv", + in("ecx") 0u32, + out("eax") mask_low, + out("edx") mask_high, + options(nostack, nomem), + ); + } + let mask = u64::from(mask_low) | (u64::from(mask_high) << 32); + if mask & 0b11 != 0b11 { + return false; + } + // Subleaf 0 reports the size for the exact user state enabled in XCR0. + let xsave = __cpuid_count(0x0d, 0); + let Ok(required_size) = usize::try_from(xsave.ebx) else { + return false; + }; + if !xsave_size_supported(required_size) { + return false; + } + FSPY_ACCEL_XSAVE_MASK.store(mask, Ordering::Relaxed); + true +} + +const fn xsave_size_supported(required_size: usize) -> bool { + required_size >= 576 && required_size <= MAX_XSAVE_SIZE +} + +#[repr(C)] +pub struct DispatchContext { + /* + * These fields deliberately match the first seven qwords of the assembly + * dispatch frame. `repr(C)` makes passing RSP directly to `choose_gate` + * well-defined: no assembly-only offsets leak into the Rust callback API. + */ + syscall_number: usize, + arg0: usize, + arg1: usize, + arg2: usize, + arg3: usize, + arg4: usize, + arg5: usize, +} + +pub const fn syscall_context(context: &DispatchContext) -> SyscallContext { + SyscallContext::new( + context.syscall_number, + [context.arg0, context.arg1, context.arg2, context.arg3, context.arg4, context.arg5], + ) +} + +/* + * There are two fixed syscall gates. The allowed gate is mapped once so its + * SYSCALL ends at the exact post-SYSCALL PC trusted by the process's seccomp + * policy. This fallback gate is ordinary code at a separate, stable address + * and is intentionally not allowlisted. Both execute the same raw syscall; + * choosing a gate changes only the PC from which the kernel observes it. The + * dispatcher returns one of these two addresses, never a callback-provided + * branch destination. + * + * Fallback is called indirectly, so it also needs ENDBR64 for CET IBT. + */ +#[unsafe(naked)] +unsafe extern "C" fn fallback_gate() -> isize { + naked_asm!("endbr64", "syscall", "ret"); +} + +pub fn fallback_gate_address() -> usize { + fallback_gate as *const () as usize +} + +#[unsafe(naked)] +unsafe extern "C" fn dispatch_entry() { + /* + * Let S be RSP at the original syscall site and let D be RSP while calling + * `choose_gate`. The trampoline jumps here rather than calling, so S has no + * synthetic return address. Stack addresses are laid out as follows: + * + * higher addresses + * + * S original RSP + * S-128 .. S-1 untouched SysV red zone + * S-136 saved original RFLAGS + * S-144 saved original R12 <- R12 during dispatch + * ... up to 63 bytes of alignment padding + * D+4224 end of dispatch frame (64-byte aligned) + * D+128 .. D+4223 4096-byte XSAVE image + * D+80 .. D+127 scratch/padding + * D+72 selected gate address + * D+64 original post-SYSCALL PC (incoming RCX) + * D+56 per-site return stub (incoming R11) + * D+0 .. D+48 syscall number and six arguments + * D RSP passed to `choose_gate` + * + * lower addresses + * + * The 144-byte move passes completely below the 128-byte red zone before + * writing persistent metadata. PUSHFQ/POP capture flags without consuming + * a GPR; for a POP memory operand based on RSP, x86 computes the effective + * address after restoring RSP, so `[rsp + 8]` becomes S-136. R12 then keeps + * this small metadata frame reachable while RSP is realigned for XSAVE. + */ + naked_asm!( + /* + * The trampoline reaches this function through an indirect RIP-relative + * jump, making the entry point subject to CET IBT validation. + */ + "endbr64", + /* Preserve the red zone, flags, and the R12 frame-anchor register. */ + "lea rsp, [rsp - 144]", + "pushfq", + "pop qword ptr [rsp + 8]", + "mov qword ptr [rsp], r12", + "mov r12, rsp", + /* XSAVE requires 64-byte alignment; reserve the complete fixed frame. */ + "and rsp, -64", + "lea rsp, [rsp - {dispatch_frame_size}]", + /* + * Materialize DispatchContext at frame offset zero. R11 and RCX are not + * syscall inputs: the per-site trampoline has filled those naturally + * clobbered registers with its return stub and original continuation. + */ + "mov qword ptr [rsp], rax", + "mov qword ptr [rsp + 8], rdi", + "mov qword ptr [rsp + 16], rsi", + "mov qword ptr [rsp + 24], rdx", + "mov qword ptr [rsp + 32], r10", + "mov qword ptr [rsp + 40], r8", + "mov qword ptr [rsp + 48], r9", + "mov qword ptr [rsp + 56], r11", + "mov qword ptr [rsp + 64], rcx", + /* + * XSAVE writes state-presence bits but does not promise to initialize + * every reserved header byte. Clear the whole 64-byte header first so + * XRSTOR sees standard format, zero reserved fields, and only the state + * bits produced by this XSAVE rather than stale stack contents. + */ + "xor eax, eax", + "mov qword ptr [rsp + {xsave_header_offset}], rax", + "mov qword ptr [rsp + {xsave_header_offset} + 8], rax", + "mov qword ptr [rsp + {xsave_header_offset} + 16], rax", + "mov qword ptr [rsp + {xsave_header_offset} + 24], rax", + "mov qword ptr [rsp + {xsave_header_offset} + 32], rax", + "mov qword ptr [rsp + {xsave_header_offset} + 40], rax", + "mov qword ptr [rsp + {xsave_header_offset} + 48], rax", + "mov qword ptr [rsp + {xsave_header_offset} + 56], rax", + /* + * XSAVE64 consumes a 64-bit component mask in EDX:EAX. Saving every + * XCR0-enabled user component protects vector, floating-point, and + * other extended state from arbitrary Rust callback code. GPRs and + * RFLAGS are outside XSAVE and are handled explicitly in this frame. + */ + "mov rax, qword ptr [rip + {xsave_mask}]", + "mov rdx, rax", + "shr rdx, 32", + "xsave64 [rsp + {xsave_offset}]", + /* + * The SysV ABI requires a clear direction flag on function entry, even + * though intercepted code may have had DF set. `choose_gate` receives a + * pointer to the context, preserves libc's thread-local errno around + * the user callback, and returns either the fixed allowed gate or the + * fixed fallback gate. The callback runs before the raw syscall, so its + * own libc activity must not leak an errno change to the intercepted + * caller. Original RFLAGS, including DF, are restored before the gate. + */ + "cld", + "mov rdi, rsp", + "call {choose_gate}", + /* Save the decision before EAX/EDX are reused as the XRSTOR mask. */ + "mov qword ptr [rsp + 72], rax", + "mov rax, qword ptr [rip + {xsave_mask}]", + "mov rdx, rax", + "shr rdx, 32", + "xrstor64 [rsp + {xsave_offset}]", + /* + * Rebuild the raw syscall register file. The saved return-stub address + * replaces the no-longer-needed syscall-number slot after RAX has been + * restored. R12 remains the anchor for flags and final stack unwinding. + */ + "mov r11, qword ptr [rsp + 72]", + "mov rax, qword ptr [rsp]", + "mov rdi, qword ptr [rsp + 8]", + "mov rsi, qword ptr [rsp + 16]", + "mov rdx, qword ptr [rsp + 24]", + "mov r10, qword ptr [rsp + 32]", + "mov r8, qword ptr [rsp + 40]", + "mov r9, qword ptr [rsp + 48]", + "mov rcx, qword ptr [rsp + 56]", + "mov qword ptr [rsp], rcx", + "push qword ptr [r12 + 8]", + "popfq", + /* + * CALL supplies the RET address expected by either gate. The actual + * SYSCALL then replaces RCX with the PC inside that gate and R11 with + * flags. Before leaving dispatch, substitute the original site's + * post-SYSCALL PC and saved flags, which are the values native execution + * would have exposed in those two ABI-clobbered registers. The indirect + * jump uses the return-stub address parked at D+0 and preserves the raw + * result in RAX and the restored flags. + */ + "call r11", + "mov rcx, qword ptr [rsp + 64]", + "mov r11, qword ptr [r12 + 8]", + "jmp qword ptr [rsp]", + choose_gate = sym super::dispatcher::choose_gate, + xsave_mask = sym FSPY_ACCEL_XSAVE_MASK, + xsave_offset = const XSAVE_OFFSET, + xsave_header_offset = const XSAVE_HEADER_OFFSET, + dispatch_frame_size = const DISPATCH_FRAME_SIZE, + ); +} + +pub fn dispatch_entry_address() -> usize { + dispatch_entry as *const () as usize +} + +/// Executes a raw syscall at a PC that is intentionally not allowlisted. +/// +/// # Safety +/// The caller must uphold the pointer and argument requirements of `number`. +#[inline(never)] +pub unsafe fn raw_syscall6( + number: usize, + arg0: usize, + arg1: usize, + arg2: usize, + arg3: usize, + arg4: usize, + arg5: usize, +) -> isize { + let result: isize; + /* + * Explicit register operands make this a raw Linux syscall rather than a C + * call: RAX is both number and result; RDI, RSI, RDX, R10, R8, and R9 hold + * the six arguments. RCX and R11 are late outputs because hardware destroys + * their incoming values as described above. A negative RAX is returned + * unchanged for the caller to interpret as -errno; this helper never writes + * libc's thread-local errno. + */ + // SAFETY: the caller supplies the syscall-specific argument validity. The + // clobbers match the Linux x86-64 raw syscall ABI. + unsafe { + asm!( + "syscall", + inlateout("rax") number => result, + in("rdi") arg0, + in("rsi") arg1, + in("rdx") arg2, + in("r10") arg3, + in("r8") arg4, + in("r9") arg5, + lateout("rcx") _, + lateout("r11") _, + options(nostack), + ); + } + result +} + +/// Executes a raw syscall through a caller-provided gate. +/// +/// # Safety +/// The gate must implement the fspy raw syscall gate ABI, and the caller must +/// uphold the pointer and argument requirements of `number`. +#[expect(clippy::too_many_arguments, reason = "mirrors the six-argument raw syscall ABI")] +pub unsafe fn raw_syscall6_at( + gate: usize, + number: usize, + arg0: usize, + arg1: usize, + arg2: usize, + arg3: usize, + arg4: usize, + arg5: usize, +) -> isize { + let result: isize; + /* + * The gate ABI has the same register file as `raw_syscall6`, plus its entry + * address in R11. CALL is required because every gate ends in RET. R11 is + * therefore `inlateout`: it carries the target before CALL and is clobbered + * by SYSCALL before the gate returns. RCX is the other hardware clobber. + * This form cannot claim `nostack`, since CALL temporarily pushes a return + * address even though the gate's RET balances it. + */ + // SAFETY: the caller guarantees the gate and syscall arguments are valid. + unsafe { + asm!( + "call r11", + inlateout("r11") gate => _, + inlateout("rax") number => result, + in("rdi") arg0, + in("rsi") arg1, + in("rdx") arg2, + in("r10") arg3, + in("r8") arg4, + in("r9") arg5, + lateout("rcx") _, + ); + } + result +} + +/* + * x86 instructions have variable length, but the shortest convenient patch is + * a five-byte `E9 rel32` jump. A SYSCALL is only two bytes, so a valid site must + * also overwrite one complete following instruction. That instruction is + * "displaced": its original bytes execute later in the trampoline before + * control returns to the first untouched instruction. + * + * Relocation is deliberately narrow. The displaced instruction must be a + * position-independent RAX test/comparison with ordinary fallthrough, and no + * known direct branch may target its old address. RIP-relative memory operands + * or relative control flow would change meaning after copying. Decoding stops + * at the first non-SYSCALL control transfer because, without a full CFG, only + * the function's entry basic block is provably instruction bytes rather than + * an embedded jump table or data. + */ +pub fn collect_sites(function_address: usize, bytes: &[u8]) -> Vec { + let mut decoder = Decoder::with_ip( + 64, + bytes, + u64::try_from(function_address).unwrap_or(0), + DecoderOptions::NONE, + ); + let mut instructions = Vec::new(); + while decoder.can_decode() { + let instruction = decoder.decode(); + if instruction.is_invalid() || instruction.len() == 0 { + break; + } + let flow_control = instruction.flow_control(); + instructions.push(instruction); + // Only the entry basic block is provably code without constructing a + // complete CFG. Stopping at its first control transfer avoids treating + // jump tables or inline data later in the symbol as instructions. + if flow_control != FlowControl::Next && instruction.code() != Code::Syscall { + break; + } + } + + let mut sites = Vec::new(); + for (index, instruction) in instructions.iter().enumerate() { + if instruction.code() != Code::Syscall { + continue; + } + let Ok(address) = usize::try_from(instruction.ip()) else { + continue; + }; + let Some(start) = address.checked_sub(function_address) else { + continue; + }; + let Some(displaced) = instructions.get(index + 1) else { + continue; + }; + if displaced.flow_control() != FlowControl::Next || displaced.is_ip_rel_memory_operand() { + continue; + } + let overwrite_length = instruction.len() + displaced.len(); + if overwrite_length < 5 { + continue; + } + let Some(end) = start.checked_add(overwrite_length) else { + continue; + }; + if end > bytes.len() || start + instruction.len() > end { + continue; + } + let displaced_bytes = &bytes[start + instruction.len()..end]; + if !is_supported_displaced_pattern(displaced_bytes) + || has_incoming_direct_target( + function_address, + bytes, + address + instruction.len(), + address + overwrite_length, + ) + { + continue; + } + sites.push(PatchSite { + address, + overwrite_length, + displaced: displaced_bytes.to_vec(), + restore_protection: libc::PROT_READ | libc::PROT_EXEC, + }); + } + sites +} + +fn is_supported_displaced_pattern(bytes: &[u8]) -> bool { + /* + * These encodings are `test rax, rax`, `cmp rax, imm32`, and + * `cmp rax, imm8`. They are common post-syscall result checks, contain no + * address-dependent operand, and reproduce their original flags when run + * from the trampoline. + */ + matches!(bytes, [0x48, 0x85, 0xc0]) + || matches!(bytes, [0x48, 0x3d, _, _, _, _]) + || matches!(bytes, [0x48, 0x83, 0xf8, _]) +} + +fn has_incoming_direct_target( + function_address: usize, + bytes: &[u8], + displaced_start: usize, + displaced_end: usize, +) -> bool { + collect_known_direct_targets(function_address, bytes) + .into_iter() + .any(|target| (displaced_start..displaced_end).contains(&target)) +} + +pub fn collect_known_direct_targets(function_address: usize, bytes: &[u8]) -> Vec { + /* + * Start a decoder at every byte, not only at known instruction boundaries. + * This intentionally over-approximates possible direct branch targets: a + * false positive merely declines acceleration, while missing a real entry + * into displaced bytes could make the patch jump into the middle of `E9`. + */ + bytes + .iter() + .enumerate() + .filter_map(|(offset, _)| { + let mut decoder = Decoder::with_ip( + 64, + &bytes[offset..], + u64::try_from(function_address + offset).ok()?, + DecoderOptions::NONE, + ); + let instruction = decoder.decode(); + if instruction.is_invalid() + || !matches!( + instruction.op0_kind(), + OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64 + ) + { + return None; + } + usize::try_from(instruction.near_branch_target()).ok() + }) + .collect() +} + +pub const fn trampoline_size(site: &PatchSite) -> usize { + 59 + site.displaced.len() +} + +pub fn emit_trampoline(output: &mut [u8], address: usize, site: &PatchSite) -> Option { + /* + * Each site gets the following private trampoline. Numbers at left are byte + * offsets from `address`; `disp` is the copied following instruction. + * + * 0 movabs r11, return_stub + * 10 movabs rcx, original_site + 2 + * 20 jmp qword ptr [rip + 0] indirect absolute dispatch jump + * 26 dq dispatch_entry + * 34 return_stub: ENDBR64 + * 38 lea rsp, [r12 + 144] restore original stack pointer + * 46 mov r12, [rsp - 144] restore original R12 + * 54 disp recreate overwritten instruction + * . jmp rel32 continuation resume after the patched range + * + * R11 and RCX are ideal metadata carriers because native SYSCALL already + * clobbers both. The dispatcher saves their metadata, executes the selected + * gate, and jumps indirectly to `return_stub`; ENDBR64 at offset 34 makes + * that jump legal under CET IBT. R12 remains a private frame anchor until + * the stub restores both RSP and the caller's original R12. + * + * The RIP-indirect jump at offset 20 embeds a full 64-bit dispatch address, + * avoiding rel32 range limits without consuming another live register. + */ + let required = trampoline_size(site); + let output = output.get_mut(..required)?; + let return_stub = address.checked_add(34)?; + + output[..2].copy_from_slice(&[0x49, 0xbb]); // movabs r11, return_stub + output[2..10].copy_from_slice(&u64::try_from(return_stub).ok()?.to_ne_bytes()); + output[10..12].copy_from_slice(&[0x48, 0xb9]); // movabs rcx, original post-syscall PC + output[12..20].copy_from_slice( + &u64::try_from(site.address.checked_add(SYSCALL_INSTRUCTION_LENGTH)?).ok()?.to_ne_bytes(), + ); + output[20..26].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]); // jmp [rip] + output[26..34].copy_from_slice(&u64::try_from(dispatch_entry_address()).ok()?.to_ne_bytes()); + output[34..38].copy_from_slice(LANDING_PAD); + output[38..46].copy_from_slice(&[0x49, 0x8d, 0xa4, 0x24, 0x90, 0, 0, 0]); + output[46..54].copy_from_slice(&[0x4c, 0x8b, 0xa4, 0x24, 0x70, 0xff, 0xff, 0xff]); + + /* + * The final E9 displacement is signed and measured from the end of its own + * five-byte encoding. Copying only an i32 succeeds exactly when the target + * lies in x86-64's +/-2 GiB near-branch range. The continuation is the first + * instruction after the whole overwritten region, not merely after the + * original two-byte SYSCALL. + */ + let displaced_end = 54 + site.displaced.len(); + output[54..displaced_end].copy_from_slice(&site.displaced); + let continuation = site.address.checked_add(site.overwrite_length)?; + let jump_end = address.checked_add(displaced_end + 5)?; + let displacement = i64::try_from(continuation).ok()? - i64::try_from(jump_end).ok()?; + output[displaced_end] = 0xe9; + output[displaced_end + 1..displaced_end + 5] + .copy_from_slice(&i32::try_from(displacement).ok()?.to_ne_bytes()); + Some(required) +} + +pub fn encode_site_branch(site: &PatchSite, trampoline: usize) -> Option> { + /* + * Replace the SYSCALL and displaced instruction with one `E9 rel32`. As + * above, rel32 is relative to the end of the five-byte jump and must fit a + * signed i32. Any bytes beyond those five still belong to the overwritten + * instruction boundary, so NOP padding prevents stale instruction suffixes + * from being executed if control falls through unexpectedly. + */ + let next_instruction = site.address.checked_add(5)?; + let displacement = i64::try_from(trampoline).ok()? - i64::try_from(next_instruction).ok()?; + let displacement = i32::try_from(displacement).ok()?; + let mut patch = vec![0x90; site.overwrite_length]; + *patch.first_mut()? = 0xe9; + patch[1..5].copy_from_slice(&displacement.to_ne_bytes()); + Some(patch) +} + +pub const unsafe fn clear_cache(_start: usize, _end: usize) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_relocatable_displaced_instructions() { + let safe = [0x0f, 0x05, 0x48, 0x85, 0xc0, 0xc3]; + let sites = collect_sites(0x1000, &safe); + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].overwrite_length, 5); + assert_eq!(sites[0].displaced, [0x48, 0x85, 0xc0]); + + let relative_call = [0x0f, 0x05, 0xe8, 0, 0, 0, 0]; + assert!(collect_sites(0x2000, &relative_call).is_empty()); + + let rip_relative = [0x0f, 0x05, 0x48, 0x8b, 0x05, 0, 0, 0, 0]; + assert!(collect_sites(0x3000, &rip_relative).is_empty()); + + let broad_but_relocatable = [0x0f, 0x05, 0x48, 0x89, 0xc7]; + assert!(collect_sites(0x4000, &broad_but_relocatable).is_empty()); + + let incoming_branch = + [0x0f, 0x05, 0x48, 0x85, 0xc0, 0xc3, 0x90, 0xe9, 0xf6, 0xff, 0xff, 0xff]; + assert!(collect_sites(0x5000, &incoming_branch).is_empty()); + } + + #[test] + fn branch_patch_preserves_full_instruction_boundary() { + let site = PatchSite { + address: 0x1000, + overwrite_length: 7, + displaced: vec![0x48, 0x3d, 1, 2, 3], + restore_protection: libc::PROT_READ | libc::PROT_EXEC, + }; + let patch = encode_site_branch(&site, 0x2000).unwrap(); + assert_eq!(patch.len(), 7); + assert_eq!(patch[0], 0xe9); + assert_eq!(&patch[5..], &[0x90, 0x90]); + } + + #[test] + fn trampoline_carries_native_post_syscall_state() { + let site = PatchSite { + address: 0x1000, + overwrite_length: 5, + displaced: vec![0x48, 0x85, 0xc0], + restore_protection: libc::PROT_READ | libc::PROT_EXEC, + }; + let address = 0x2000; + let mut trampoline = vec![0; trampoline_size(&site)]; + assert_eq!(emit_trampoline(&mut trampoline, address, &site), Some(trampoline.len())); + + assert_eq!( + u64::from_ne_bytes(trampoline[2..10].try_into().unwrap()), + u64::try_from(address + 34).unwrap() + ); + assert_eq!( + u64::from_ne_bytes(trampoline[12..20].try_into().unwrap()), + u64::try_from(site.address + SYSCALL_INSTRUCTION_LENGTH).unwrap() + ); + assert_eq!(&trampoline[34..38], LANDING_PAD); + + let jump = 54 + site.displaced.len(); + assert_eq!(trampoline[jump], 0xe9); + let displacement = i32::from_ne_bytes(trampoline[jump + 1..jump + 5].try_into().unwrap()); + let target = i64::try_from(address + jump + 5).unwrap() + i64::from(displacement); + assert_eq!(target, i64::try_from(site.address + site.overwrite_length).unwrap()); + } + + #[test] + fn xsave_size_is_conservatively_bounded() { + assert!(!xsave_size_supported(575)); + assert!(xsave_size_supported(576)); + assert!(xsave_size_supported(MAX_XSAVE_SIZE)); + assert!(!xsave_size_supported(MAX_XSAVE_SIZE + 1)); + } +} diff --git a/crates/fspy_syscall_intercept/src/dispatcher.rs b/crates/fspy_syscall_intercept/src/dispatcher.rs new file mode 100644 index 000000000..92861e667 --- /dev/null +++ b/crates/fspy_syscall_intercept/src/dispatcher.rs @@ -0,0 +1,60 @@ +use crate::{CALLBACK, allowed_gate, arch}; + +/* + * Handwritten assembly cannot call the consumer's Rust callback directly: each + * architecture first saves registers in its own stack layout. `choose_gate` is + * the shared C-ABI bridge. It converts that private layout into the small, + * architecture-neutral `SyscallContext`, invokes the callback, and returns one + * of two code addresses: + * + * true -> the exact-PC gate that the caller's seccomp filter allows + * false -> a different gate that remains visible to seccomp + * + * The assembly restores the original syscall registers after this function + * returns and then calls the selected address. Consequently this function + * decides only where the syscall executes; it never performs the syscall or + * changes its arguments itself. + */ + +/// Architecture-neutral snapshot of a raw syscall number and its arguments. +#[derive(Clone, Copy, Debug)] +pub struct SyscallContext { + syscall_number: usize, + arguments: [usize; 6], +} + +impl SyscallContext { + pub(crate) const fn new(syscall_number: usize, arguments: [usize; 6]) -> Self { + Self { syscall_number, arguments } + } + + /// Returns the Linux syscall number. + #[must_use] + pub const fn syscall_number(&self) -> usize { + self.syscall_number + } + + /// Returns the six raw syscall argument registers. + #[must_use] + pub const fn arguments(&self) -> [usize; 6] { + self.arguments + } +} + +/// Chooses the exact-PC allowlisted gate only when the registered callback has +/// completed its bookkeeping. The assembly entry preserves raw syscall state. +pub extern "C" fn choose_gate(context: &arch::DispatchContext) -> usize { + let fallback = arch::fallback_gate_address(); + let Some(callback) = CALLBACK.get() else { + return fallback; + }; + let context = arch::syscall_context(context); + // SAFETY: glibc exposes a valid pointer to this thread's errno value. + let errno = unsafe { libc::__errno_location() }; + // SAFETY: the pointer remains valid for the duration of this call. + let saved_errno = unsafe { errno.read() }; + let allowed = callback(&context); + // SAFETY: bookkeeping must not change the raw syscall caller's errno. + unsafe { errno.write(saved_errno) }; + if allowed { allowed_gate().unwrap_or(fallback) } else { fallback } +} diff --git a/crates/fspy_syscall_intercept/src/lib.rs b/crates/fspy_syscall_intercept/src/lib.rs new file mode 100644 index 000000000..33a29a830 --- /dev/null +++ b/crates/fspy_syscall_intercept/src/lib.rs @@ -0,0 +1,225 @@ +//! Conservative startup-only interception for raw Linux syscall instructions. +//! +//! The caller remains responsible for a correctness fallback. A site is +//! intercepted only when it is inside a bounded ELF function symbol, can reach +//! a private trampoline, and satisfies the architecture-specific safety checks. + +#![cfg(all( + target_os = "linux", + not(target_env = "musl"), + any(target_arch = "aarch64", target_arch = "x86_64") +))] +mod dispatcher; +mod patcher; + +#[cfg(target_arch = "aarch64")] +#[path = "arch/aarch64.rs"] +mod arch; +#[cfg(target_arch = "x86_64")] +#[path = "arch/x86_64.rs"] +mod arch; + +use std::{ + ffi::CStr, + fs::File, + os::fd::FromRawFd as _, + sync::{ + OnceLock, + atomic::{AtomicUsize, Ordering}, + }, +}; + +pub use dispatcher::SyscallContext; + +/// Decides whether an intercepted syscall may use the allowlisted gate. +pub type Callback = fn(&SyscallContext) -> bool; + +static ALLOWED_GATE: AtomicUsize = AtomicUsize::new(0); +static CALLBACK: OnceLock = OnceLock::new(); + +fn allowed_gate() -> Option { + let address = ALLOWED_GATE.load(Ordering::Relaxed); + (address != 0).then_some(address) +} + +/// Opens a control file through the exact seccomp-allowlisted syscall gate. +/// +/// Returns `Ok(None)` when interception was not initialized on this process. +/// +/// # Safety +/// +/// The path must identify tracer control state whose access is intentionally +/// omitted from tracing. Using this for workload files makes tracing incomplete. +/// +/// # Errors +/// +/// Returns the `openat` error or an error converting its result to an owned +/// file descriptor. +pub unsafe fn open_control_file(path: &CStr) -> std::io::Result> { + let Some(gate) = allowed_gate() else { + return Ok(None); + }; + let number = usize::try_from(libc::SYS_openat).map_err(std::io::Error::other)?; + let dirfd = usize::from_ne_bytes(i64::from(libc::AT_FDCWD).to_ne_bytes()); + let flags = usize::try_from(libc::O_RDONLY | libc::O_CLOEXEC).map_err(std::io::Error::other)?; + // SAFETY: `path` is a valid C string, and `gate` was installed by `init`. + let result = unsafe { + arch::raw_syscall6_at(gate, number, dirfd, path.as_ptr() as usize, flags, 0, 0, 0) + }; + if result < 0 { + let errno = i32::try_from(-result).unwrap_or(libc::EIO); + return Err(std::io::Error::from_raw_os_error(errno)); + } + let fd = i32::try_from(result).map_err(std::io::Error::other)?; + // SAFETY: a nonnegative successful openat result is a newly owned fd. + Ok(Some(unsafe { File::from_raw_fd(fd) })) +} + +/// Installs the exact-PC gate and patches supported syscall sites at startup. +/// +/// The callback returns `true` only when the syscall may use the allowlisted +/// gate. Returning `false` executes it at the separate fallback gate instead. +pub fn init(post_syscall_pc: u64, callback: Callback) { + if std::env::var_os("FSPY_DISABLE_ACCELERATION").is_some() { + return; + } + if CALLBACK.get().is_some() { + return; + } + if !has_single_thread() || !arch::runtime_supported() { + return; + } + let Ok(post_syscall_pc) = usize::try_from(post_syscall_pc) else { + return; + }; + // SAFETY: the exact-address mapping is private and is not published until + // it has transitioned from writable to executable. + let Some(gate) = (unsafe { map_allowed_gate(post_syscall_pc) }) else { + return; + }; + if CALLBACK.set(callback).is_err() { + return; + } + ALLOWED_GATE.store(gate, Ordering::Relaxed); + patcher::patch_initial_elf_mappings(); +} + +/// Executes a raw syscall at a PC that is intentionally not allowlisted. +/// +/// # Safety +/// +/// The caller must uphold the pointer and argument requirements of `number`. +#[inline(never)] +#[must_use] +pub unsafe fn raw_syscall6( + number: usize, + arg0: usize, + arg1: usize, + arg2: usize, + arg3: usize, + arg4: usize, + arg5: usize, +) -> isize { + // SAFETY: the caller supplies the syscall-specific argument validity. + unsafe { arch::raw_syscall6(number, arg0, arg1, arg2, arg3, arg4, arg5) } +} + +unsafe fn map_allowed_gate(post_syscall_pc: usize) -> Option { + let page_size = usize::try_from( + // SAFETY: `sysconf` has no pointer preconditions. + unsafe { libc::sysconf(libc::_SC_PAGESIZE) }, + ) + .ok() + .filter(|size| size.is_power_of_two())?; + let instruction = arch::ALLOWED_GATE_INSTRUCTION; + let instruction_address = post_syscall_pc.checked_sub(instruction.len())?; + let entry_address = instruction_address.checked_sub(arch::LANDING_PAD.len())?; + let page_address = post_syscall_pc & !(page_size - 1); + let page_end = page_address.checked_add(page_size)?; + if entry_address < page_address + || post_syscall_pc.checked_add(arch::RETURN_INSTRUCTION.len())? > page_end + { + return None; + } + + // SAFETY: MAP_FIXED_NOREPLACE cannot replace an existing mapping at the PC + // trusted by seccomp; the requested page is private and anonymous. + let mapping = unsafe { + libc::mmap( + page_address as *mut libc::c_void, + page_size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANONYMOUS | libc::MAP_FIXED_NOREPLACE, + -1, + 0, + ) + }; + if mapping == libc::MAP_FAILED || mapping as usize != page_address { + if mapping != libc::MAP_FAILED { + // SAFETY: `mapping` was returned by the successful mmap above. + unsafe { libc::munmap(mapping, page_size) }; + } + return None; + } + + // SAFETY: all destinations were bounds-checked against the writable page. + unsafe { + std::ptr::copy_nonoverlapping( + arch::LANDING_PAD.as_ptr(), + entry_address as *mut u8, + arch::LANDING_PAD.len(), + ); + std::ptr::copy_nonoverlapping( + instruction.as_ptr(), + instruction_address as *mut u8, + instruction.len(), + ); + std::ptr::copy_nonoverlapping( + arch::RETURN_INSTRUCTION.as_ptr(), + post_syscall_pc as *mut u8, + arch::RETURN_INSTRUCTION.len(), + ); + arch::clear_cache(entry_address, post_syscall_pc + arch::RETURN_INSTRUCTION.len()); + } + // SAFETY: the mapping is valid and page-aligned; dropping write permission + // before adding execute permission keeps the generated gate W^X. + if unsafe { libc::mprotect(mapping, page_size, libc::PROT_READ | libc::PROT_EXEC) } != 0 { + // SAFETY: `mapping` still names the page created above. + unsafe { libc::munmap(mapping, page_size) }; + return None; + } + Some(entry_address) +} + +fn has_single_thread() -> bool { + let Ok(tasks) = std::fs::read_dir("/proc/self/task") else { + return false; + }; + let mut count = 0; + for task in tasks { + if task.is_err() { + return false; + } + count += 1; + if count > 1 { + return false; + } + } + count == 1 +} + +#[cfg(test)] +mod tests { + #[test] + fn configured_pc_can_contain_gate_and_return() { + let post_syscall_pc = 0x0000_0001_0000_0008usize; + let page_start = post_syscall_pc & !4095; + assert!( + post_syscall_pc + - super::arch::ALLOWED_GATE_INSTRUCTION.len() + - super::arch::LANDING_PAD.len() + >= page_start + ); + assert!(post_syscall_pc + super::arch::RETURN_INSTRUCTION.len() <= page_start + 4096); + } +} diff --git a/crates/fspy_syscall_intercept/src/patcher.rs b/crates/fspy_syscall_intercept/src/patcher.rs new file mode 100644 index 000000000..11f2f5b73 --- /dev/null +++ b/crates/fspy_syscall_intercept/src/patcher.rs @@ -0,0 +1,690 @@ +/* + * This module rewrites selected syscall instructions that are already mapped + * into the process. No object file on disk is modified. The high-level flow is: + * + * 1. Ask the dynamic loader for every ELF image currently in memory. + * 2. Read each image's ELF file to find named functions that may issue one of + * the filesystem syscalls understood by the caller. + * 3. Let the architecture module identify instruction sequences that can be + * replaced without splitting an instruction or changing copied code. + * 4. Allocate a new executable region near the original instructions and + * write one trampoline per accepted site into it. + * 5. Replace the original syscall with a short branch to its trampoline. + * + * A trampoline is a small generated function. It saves enough machine state to + * call the shared dispatcher, executes the syscall through either the allowed + * or fallback gate selected by that dispatcher, replays any instruction bytes + * displaced by the branch, and then resumes the original function. + * + * ELF files describe addresses relative to an image. ASLR chooses a different + * base address when the image is loaded; `load_bias + symbol.address()` converts + * an ELF symbol address into the live process address. Program headers describe + * mapped segments, while symbols and relocations help prove that replacing a + * byte range will not strand a known branch target in the middle of the patch. + * + * Code pages are normally readable and executable, not writable. Installation + * follows W^X discipline: generated trampolines are written as RW and changed + * to RX; original text is changed from RX to RW only for the copy itself and is + * immediately restored to RX. Raw mapping syscalls are used so patching libc's + * own wrappers cannot remove execute permission from code the patcher is using. + */ + +use std::{ + ffi::{CStr, OsStr}, + os::unix::ffi::OsStrExt as _, +}; + +use object::{ + Object as _, ObjectSection as _, ObjectSymbol, Relocation, RelocationTarget, SymbolKind, +}; + +use super::arch; + +#[derive(Clone, Debug)] +pub struct PatchSite { + /// Live address at which the original syscall instruction begins. + pub address: usize, + /// Number of original bytes replaced by the branch instruction. + pub overwrite_length: usize, + /// Complete instructions after the syscall that must run in the trampoline. + pub displaced: Vec, + /// Page protection restored after writing the replacement branch. + pub restore_protection: libc::c_int, +} + +#[derive(Clone, Copy)] +struct ExecutableSegment { + start: usize, + end: usize, + protection: libc::c_int, +} + +struct Image { + load_bias: usize, + path: Vec, + segments: Vec, +} + +pub fn patch_initial_elf_mappings() { + // `dl_iterate_phdr` runs the callback while loader metadata is stable. Copy + // only addresses and paths here; parsing files and generating code happens + // after the callback returns, outside the loader callback. + let mut images = Vec::new(); + // SAFETY: the callback copies every borrowed loader value into `images` + // before returning. It never unwinds across the C boundary. + unsafe { + libc::dl_iterate_phdr( + Some(collect_image), + std::ptr::from_mut(&mut images).cast::(), + ); + } + for image in images { + patch_image(&image); + } +} + +unsafe extern "C" fn collect_image( + info: *mut libc::dl_phdr_info, + _size: libc::size_t, + data: *mut libc::c_void, +) -> libc::c_int { + if info.is_null() || data.is_null() { + return 0; + } + // SAFETY: dl_iterate_phdr provides valid pointers for the callback duration. + let info = unsafe { &*info }; + // SAFETY: `data` was created from `&mut Vec` in the caller. + let images = unsafe { &mut *data.cast::>() }; + let phnum = info.dlpi_phnum as usize; + if info.dlpi_phdr.is_null() { + return 0; + } + // SAFETY: the loader reports `dlpi_phnum` valid program headers. + let headers = unsafe { std::slice::from_raw_parts(info.dlpi_phdr, phnum) }; + let Ok(load_bias) = usize::try_from(info.dlpi_addr) else { + return 0; + }; + let mut segments = Vec::new(); + for header in headers { + if header.p_type != libc::PT_LOAD + || header.p_flags & libc::PF_X == 0 + || header.p_flags & libc::PF_R == 0 + || header.p_flags & libc::PF_W != 0 + { + continue; + } + let Ok(virtual_address) = usize::try_from(header.p_vaddr) else { + continue; + }; + let Ok(memory_size) = usize::try_from(header.p_memsz) else { + continue; + }; + let Some(start) = load_bias.checked_add(virtual_address) else { + continue; + }; + let Some(end) = start.checked_add(memory_size) else { + continue; + }; + if start == end { + continue; + } + let protection = libc::PROT_READ | libc::PROT_EXEC; + segments.push(ExecutableSegment { start, end, protection }); + } + if segments.is_empty() { + return 0; + } + + // Never rewrite the preload DSO itself: it contains the fallback gate and + // dispatcher, and recursive patching would invalidate the escape path. + let own_code = arch::dispatch_entry_address(); + if segments.iter().any(|segment| (segment.start..segment.end).contains(&own_code)) { + return 0; + } + + let path = if info.dlpi_name.is_null() { + b"/proc/self/exe".to_vec() + } else { + // SAFETY: dlpi_name is a loader-owned C string for this callback. + let name = unsafe { CStr::from_ptr(info.dlpi_name) }.to_bytes(); + if name.is_empty() { b"/proc/self/exe".to_vec() } else { name.to_vec() } + }; + images.push(Image { load_bias, path, segments }); + 0 +} + +fn patch_image(image: &Image) { + let Ok(file_bytes) = std::fs::read(OsStr::from_bytes(&image.path)) else { + return; + }; + let Ok(file) = object::File::parse(file_bytes.as_slice()) else { + return; + }; + if !file.is_little_endian() || !target_architecture_matches(file.architecture()) { + return; + } + let mut known_targets = collect_relocation_targets(&file, image.load_bias); + known_targets.extend(collect_known_code_targets(&file, &file_bytes, image)); + known_targets.sort_unstable(); + known_targets.dedup(); + + let mut sites = Vec::new(); + for symbol in file.symbols().chain(file.dynamic_symbols()) { + if symbol.kind() != SymbolKind::Text + || !symbol.is_definition() + || symbol.size() == 0 + || !symbol.name_bytes().is_ok_and(is_monitored_symbol) + { + continue; + } + let Ok(symbol_address) = usize::try_from(symbol.address()) else { + continue; + }; + let Ok(symbol_size) = usize::try_from(symbol.size()) else { + continue; + }; + let Some(start) = image.load_bias.checked_add(symbol_address) else { + continue; + }; + let Some(end) = start.checked_add(symbol_size) else { + continue; + }; + let Some(segment) = + image.segments.iter().find(|segment| start >= segment.start && end <= segment.end) + else { + continue; + }; + let Some(bytes) = symbol_file_bytes(&file, &file_bytes, &symbol) else { + continue; + }; + let mut function_sites = arch::collect_sites(start, bytes); + function_sites.retain(|site| { + let Some(displaced_start) = site.address.checked_add(arch::SYSCALL_INSTRUCTION_LENGTH) + else { + return false; + }; + let Some(displaced_end) = site.address.checked_add(site.overwrite_length) else { + return false; + }; + !known_targets.iter().any(|target| (displaced_start..displaced_end).contains(target)) + }); + for site in &mut function_sites { + site.restore_protection = segment.protection; + } + sites.extend(function_sites); + } + + sites.sort_unstable_by_key(|site| site.address); + sites.dedup_by_key(|site| site.address); + let mut previous_end = 0; + sites.retain(|site| { + let Some(end) = site.address.checked_add(site.overwrite_length) else { + return false; + }; + if site.address < previous_end { + return false; + } + previous_end = end; + true + }); + if sites.is_empty() { + return; + } + install_patches(&sites); +} + +fn is_monitored_symbol(name: &[u8]) -> bool { + matches!( + name, + b"open" + | b"open64" + | b"openat" + | b"openat64" + | b"__open" + | b"__open64" + | b"__open_2" + | b"__open64_2" + | b"__open_nocancel" + | b"__open64_nocancel" + | b"__openat_2" + | b"__openat64_2" + | b"access" + | b"faccessat" + | b"stat" + | b"lstat" + | b"fstatat" + | b"statx" + | b"getdents" + | b"getdents64" + | b"__getdents" + | b"__getdents64" + | b"execve" + | b"execveat" + | b"fspy_test_accelerated_openat" + ) || name.starts_with(b"__xstat") + || name.starts_with(b"__lxstat") + || name.starts_with(b"__fxstatat") +} + +fn collect_known_code_targets( + file: &object::File<'_>, + file_bytes: &[u8], + image: &Image, +) -> Vec { + let mut targets = file + .symbols() + .chain(file.dynamic_symbols()) + .filter(ObjectSymbol::is_definition) + .filter_map(|symbol| image.load_bias.checked_add(usize::try_from(symbol.address()).ok()?)) + .collect::>(); + for symbol in file.symbols().chain(file.dynamic_symbols()) { + if symbol.kind() != SymbolKind::Text || !symbol.is_definition() || symbol.size() == 0 { + continue; + } + let Ok(symbol_address) = usize::try_from(symbol.address()) else { + continue; + }; + let Ok(symbol_size) = usize::try_from(symbol.size()) else { + continue; + }; + let Some(start) = image.load_bias.checked_add(symbol_address) else { + continue; + }; + let Some(end) = start.checked_add(symbol_size) else { + continue; + }; + if !image.segments.iter().any(|segment| start >= segment.start && end <= segment.end) { + continue; + } + let Some(bytes) = symbol_file_bytes(file, file_bytes, &symbol) else { + continue; + }; + targets.extend(arch::collect_known_direct_targets(start, bytes)); + } + targets +} + +fn collect_relocation_targets(file: &object::File<'_>, load_bias: usize) -> Vec { + let mut targets = Vec::new(); + for section in file.sections() { + for (_, relocation) in section.relocations() { + if let Some(target) = relocation_target(file, load_bias, &relocation) { + targets.push(target); + } + } + } + if let Some(relocations) = file.dynamic_relocations() { + for (_, relocation) in relocations { + if let Some(target) = relocation_target(file, load_bias, &relocation) { + targets.push(target); + } + } + } + targets.sort_unstable(); + targets.dedup(); + targets +} + +fn relocation_target( + file: &object::File<'_>, + load_bias: usize, + relocation: &Relocation, +) -> Option { + let base = match relocation.target() { + RelocationTarget::Symbol(index) => file.symbol_by_index(index).ok()?.address(), + RelocationTarget::Section(index) => file.section_by_index(index).ok()?.address(), + RelocationTarget::Absolute => 0, + _ => return None, + }; + let target = base.checked_add_signed(relocation.addend())?; + load_bias.checked_add(usize::try_from(target).ok()?) +} + +fn symbol_file_bytes<'data, S>( + file: &object::File<'data>, + file_bytes: &'data [u8], + symbol: &S, +) -> Option<&'data [u8]> +where + S: ObjectSymbol<'data>, +{ + let section_index = symbol.section_index()?; + let Ok(section) = file.section_by_index(section_index) else { + return None; + }; + let (section_file_offset, section_file_size) = section.file_range()?; + let symbol_section_offset = symbol.address().checked_sub(section.address())?; + let symbol_section_end = symbol_section_offset.checked_add(symbol.size())?; + if symbol_section_end > section_file_size { + return None; + } + let file_start = section_file_offset.checked_add(symbol_section_offset)?; + let file_end = file_start.checked_add(symbol.size())?; + let Ok(file_start) = usize::try_from(file_start) else { + return None; + }; + let Ok(file_end) = usize::try_from(file_end) else { + return None; + }; + file_bytes.get(file_start..file_end) +} + +fn target_architecture_matches(architecture: object::Architecture) -> bool { + #[cfg(target_arch = "aarch64")] + return architecture == object::Architecture::Aarch64; + #[cfg(target_arch = "x86_64")] + return architecture == object::Architecture::X86_64; +} + +fn install_patches(sites: &[PatchSite]) { + /* + * All trampolines for one image share a newly allocated region: + * + * [ trampoline 0 ][ padding ][ trampoline 1 ] ... [ page padding ] + * + * The branch instruction available at an original site has a limited + * displacement. `map_near_sites` therefore tries candidate mappings around + * the image and accepts one only if every site can encode a branch to its + * assigned trampoline. Generation is completed while the region is RW; + * only after every trampoline and site-branch byte sequence is valid does + * the region become RX and installation begin. + */ + let Some(page_size) = page_size() else { + return; + }; + let mut offsets = Vec::with_capacity(sites.len()); + let mut used = 0usize; + for site in sites { + used = align_up(used, 16); + offsets.push(used); + let Some(next) = used.checked_add(arch::trampoline_size(site)) else { + return; + }; + used = next; + } + let Some(mapping_size) = align_up_checked(used, page_size) else { + return; + }; + let Some(first_site) = sites.first() else { + return; + }; + let Some(last_site) = sites.last() else { + return; + }; + let Some(mapping) = map_near_sites( + first_site.address, + last_site.address, + mapping_size, + page_size, + sites, + &offsets, + ) else { + return; + }; + + let mut site_patches = Vec::with_capacity(sites.len()); + for (site, offset) in sites.iter().zip(&offsets) { + let trampoline = mapping + offset; + // SAFETY: the mapping is writable and `offset + size` was included in + // the checked `used` total above. + let output = unsafe { + std::slice::from_raw_parts_mut(trampoline as *mut u8, arch::trampoline_size(site)) + }; + if arch::emit_trampoline(output, trampoline, site).is_none() { + // SAFETY: this mapping was created by map_near_sites. + unsafe { raw_munmap(mapping as *mut libc::c_void, mapping_size) }; + return; + } + let Some(patch) = arch::encode_site_branch(site, trampoline) else { + // SAFETY: this mapping was created by map_near_sites. + unsafe { raw_munmap(mapping as *mut libc::c_void, mapping_size) }; + return; + }; + site_patches.push(patch); + } + + // SAFETY: the emitted range is within the writable trampoline mapping. + unsafe { arch::clear_cache(mapping, mapping + used) }; + // SAFETY: transition the private mapping from RW to RX; it is never RWX. + if unsafe { + raw_mprotect(mapping as *mut libc::c_void, mapping_size, libc::PROT_READ | libc::PROT_EXEC) + } != 0 + { + // SAFETY: this mapping was created by map_near_sites. + unsafe { raw_munmap(mapping as *mut libc::c_void, mapping_size) }; + return; + } + + for (site, patch) in sites.iter().zip(site_patches) { + if !patch_site(site, &patch, page_size) { + // Already-installed sites remain valid and conservative. Stopping + // avoids touching more text after an unexpected mprotect failure. + break; + } + } +} + +fn map_near_sites( + first: usize, + last: usize, + mapping_size: usize, + page_size: usize, + sites: &[PatchSite], + offsets: &[usize], +) -> Option { + let after = align_up_checked(last.checked_add(page_size)?, page_size)?; + let before = first.checked_sub(mapping_size + page_size).map(|value| value & !(page_size - 1)); + let step = 1024 * 1024; + + for attempt in 0..256usize { + for hint in [ + after.checked_add(attempt.checked_mul(step)?)?, + before.and_then(|base| base.checked_sub(attempt.checked_mul(step)?)).unwrap_or(0), + ] { + if hint == 0 { + continue; + } + // A non-fixed hint cannot replace an existing mapping. Its actual + // result is accepted only after every architecture branch checks. + // SAFETY: mmap receives no borrowed pointers, and a non-fixed hint + // cannot replace an existing target mapping. + let mapping = unsafe { + raw_mmap( + hint as *mut libc::c_void, + mapping_size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANONYMOUS, + -1, + 0, + ) + }; + if mapping == libc::MAP_FAILED { + continue; + } + let address = mapping as usize; + let in_range = sites.iter().zip(offsets).all(|(site, offset)| { + address + .checked_add(*offset) + .is_some_and(|trampoline| arch::encode_site_branch(site, trampoline).is_some()) + }); + if in_range { + return Some(address); + } + // SAFETY: the mapping was returned by mmap above. + unsafe { raw_munmap(mapping, mapping_size) }; + } + } + None +} + +fn patch_site(site: &PatchSite, patch: &[u8], page_size: usize) -> bool { + const MAX_PATCH_SIZE: usize = 32; + + if patch.len() != site.overwrite_length || patch.len() > MAX_PATCH_SIZE { + return false; + } + // Discovery used immutable ELF bytes and may race file replacement or + // process mutation. Re-read this small live range immediately before + // changing page permissions and require it to match the expected syscall + // plus displaced instructions. + let mut original = [0; MAX_PATCH_SIZE]; + // SAFETY: PF_R was required and the patch range was checked inside the + // readable executable segment while collecting the site. + unsafe { + std::ptr::copy_nonoverlapping( + site.address as *const u8, + original.as_mut_ptr(), + patch.len(), + ); + } + let instruction_length = arch::ALLOWED_GATE_INSTRUCTION.len(); + if patch.len() != instruction_length + site.displaced.len() + || original[..instruction_length] != *arch::ALLOWED_GATE_INSTRUCTION + || original[instruction_length..patch.len()] != site.displaced + { + return false; + } + let page_start = site.address & !(page_size - 1); + let Some(site_end) = site.address.checked_add(site.overwrite_length) else { + return false; + }; + let Some(page_end) = align_up_checked(site_end, page_size) else { + return false; + }; + let length = page_end - page_start; + // Remove execute permission while writing to preserve W^X. + // SAFETY: the page-aligned range belongs to the executable PT_LOAD mapping + // that was bounds-checked while collecting this site. + if unsafe { + raw_mprotect(page_start as *mut libc::c_void, length, libc::PROT_READ | libc::PROT_WRITE) + } != 0 + { + return false; + } + // SAFETY: mprotect made the complete patch range writable. + unsafe { + std::ptr::copy_nonoverlapping(patch.as_ptr(), site.address as *mut u8, patch.len()); + arch::clear_cache(site.address, site_end); + } + // SAFETY: restore the PT_LOAD protection recorded by the loader scan. + if unsafe { raw_mprotect(page_start as *mut libc::c_void, length, site.restore_protection) } + == 0 + { + return true; + } + + // A failed final transition should leave the mapping writable. Restore the + // original instructions before retrying the original protection so a + // caller never observes a half-installed branch if recovery succeeds. + // SAFETY: the first mprotect succeeded, and a failed mprotect leaves the + // prior protection in place. + unsafe { + std::ptr::copy_nonoverlapping(original.as_ptr(), site.address as *mut u8, patch.len()); + arch::clear_cache(site.address, site_end); + } + // SAFETY: best-effort retry of the original segment protection after the + // original bytes have been restored. + let _ = + unsafe { raw_mprotect(page_start as *mut libc::c_void, length, site.restore_protection) }; + false +} + +fn page_size() -> Option { + usize::try_from( + // SAFETY: `sysconf` has no pointer preconditions. + unsafe { libc::sysconf(libc::_SC_PAGESIZE) }, + ) + .ok() + .filter(|size| size.is_power_of_two()) +} + +unsafe fn raw_mmap( + address: *mut libc::c_void, + length: usize, + protection: libc::c_int, + flags: libc::c_int, + fd: libc::c_int, + offset: libc::off_t, +) -> *mut libc::c_void { + let Ok(number) = usize::try_from(libc::SYS_mmap) else { + return libc::MAP_FAILED; + }; + // SAFETY: the caller supplies mmap-compatible arguments. + let result = unsafe { + arch::raw_syscall6( + number, + address as usize, + length, + usize::try_from(protection).unwrap_or_default(), + usize::try_from(flags).unwrap_or_default(), + usize::from_ne_bytes(i64::from(fd).to_ne_bytes()), + usize::from_ne_bytes(offset.to_ne_bytes()), + ) + }; + if (-4095..0).contains(&result) { libc::MAP_FAILED } else { result as *mut libc::c_void } +} + +unsafe fn raw_mprotect( + address: *mut libc::c_void, + length: usize, + protection: libc::c_int, +) -> libc::c_int { + let Ok(number) = usize::try_from(libc::SYS_mprotect) else { + return -1; + }; + // SAFETY: the caller supplies a mapped, page-aligned range. + let result = unsafe { + arch::raw_syscall6( + number, + address as usize, + length, + usize::try_from(protection).unwrap_or_default(), + 0, + 0, + 0, + ) + }; + if result < 0 { -1 } else { libc::c_int::try_from(result).unwrap_or(-1) } +} + +unsafe fn raw_munmap(address: *mut libc::c_void, length: usize) -> libc::c_int { + let Ok(number) = usize::try_from(libc::SYS_munmap) else { + return -1; + }; + // SAFETY: the caller supplies a mapping previously returned by mmap. + let result = unsafe { arch::raw_syscall6(number, address as usize, length, 0, 0, 0, 0) }; + if result < 0 { -1 } else { libc::c_int::try_from(result).unwrap_or(-1) } +} + +const fn align_up(value: usize, alignment: usize) -> usize { + (value + alignment - 1) & !(alignment - 1) +} + +fn align_up_checked(value: usize, alignment: usize) -> Option { + Some(value.checked_add(alignment - 1)? & !(alignment - 1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_alignment_rejects_overflow() { + assert_eq!(align_up_checked(1, 4096), Some(4096)); + assert_eq!(align_up_checked(4096, 4096), Some(4096)); + assert_eq!(align_up_checked(usize::MAX, 4096), None); + } + + #[test] + fn branch_limit_constant_matches_architecture_encoder() { + let site = PatchSite { + address: 0x1_0000_0000, + overwrite_length: if cfg!(target_arch = "x86_64") { 5 } else { 4 }, + displaced: Vec::new(), + restore_protection: libc::PROT_READ | libc::PROT_EXEC, + }; + assert!( + arch::encode_site_branch(&site, site.address + arch::MAX_BRANCH_DISTANCE).is_some() + ); + } +} diff --git a/crates/vite_powershell/src/lib.rs b/crates/vite_powershell/src/lib.rs index 2819cdf35..52e1b7a2d 100644 --- a/crates/vite_powershell/src/lib.rs +++ b/crates/vite_powershell/src/lib.rs @@ -27,9 +27,10 @@ use vite_path::{AbsolutePath, AbsolutePathBuf}; pub const POWERSHELL_PREFIX: &[&str] = &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; -/// Cached location of the `PowerShell` host. Prefers cross-platform -/// `pwsh.exe` when present, falling back to the Windows built-in -/// `powershell.exe`. Returns `None` on non-Windows or when neither host +/// Cached location of the `PowerShell` host. +/// +/// Prefers cross-platform `pwsh.exe` when present, falling back to the Windows +/// built-in `powershell.exe`. Returns `None` on non-Windows or when neither host /// is on `PATH`. /// /// Cached as `Arc` so callers that want shared ownership diff --git a/crates/vite_task/src/session/execute/cache_update.rs b/crates/vite_task/src/session/execute/cache_update.rs index 0cf4df1c5..bd13b7dcb 100644 --- a/crates/vite_task/src/session/execute/cache_update.rs +++ b/crates/vite_task/src/session/execute/cache_update.rs @@ -39,6 +39,15 @@ struct TrackingOutcome { type TrackedEnvValues = BTreeMap>; type TrackedEnvQueryValues = BTreeMap>; +#[cfg(fspy)] +fn reject_incomplete_tracing( + tracing_error: &mut Option, +) -> Option<(CacheUpdateStatus, Option)> { + tracing_error + .take() + .map(|_| (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), None)) +} + /// Decide whether the finished run may be cached, and store it if so. /// /// Every outcome returns a `(status, error)` pair for the caller's single @@ -54,7 +63,7 @@ pub(super) async fn update_cache( workspace_root: &Arc, cache_dir: &AbsolutePath, state: CacheState<'_>, - outcome: &ChildOutcome, + outcome: &mut ChildOutcome, reports: Option<&Reports>, duration: Duration, cancelled: bool, @@ -89,6 +98,11 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None); } + #[cfg(fspy)] + if let Some(rejection) = reject_incomplete_tracing(&mut outcome.tracing_error) { + return rejection; + } + let fspy_outcome = observe_fspy( outcome, metadata, @@ -411,6 +425,24 @@ mod tests { use vite_path::{AbsolutePath, RelativePathBuf}; use super::normalize_ignored_paths; + #[cfg(fspy)] + use super::reject_incomplete_tracing; + #[cfg(fspy)] + use crate::session::event::{CacheNotUpdatedReason, CacheUpdateStatus}; + + #[cfg(fspy)] + #[test] + fn incomplete_tracing_disables_cache_update() { + let mut tracing_error = Some(std::io::Error::other("bad notification")); + let (status, error) = reject_incomplete_tracing(&mut tracing_error).unwrap(); + + assert!(tracing_error.is_none()); + assert!(matches!( + status, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled) + )); + assert!(error.is_none()); + } #[test] fn normalize_ignored_paths_cleans_relative_components() { diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 9f5438de1..cf4ca5d1e 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -446,7 +446,7 @@ async fn run( let child_work = Box::pin(run_child(child, sinks, None, fast_fail_token.clone())); (child_work.await, None) }; - let outcome = wait_result.map_err(Report::failed)?; + let mut outcome = wait_result.map_err(Report::failed)?; let duration = start.elapsed(); // Extract reports, or short-circuit when the IPC server failed. An Err @@ -480,7 +480,7 @@ async fn run( workspace_root, cache_dir, state, - &outcome, + &mut outcome, reports.as_ref(), duration, cancelled, diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 2e1dc049b..854737a4e 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -44,6 +44,9 @@ pub struct ChildOutcome { /// Raw fspy accesses. `Some` iff `fspy` was `true` at spawn time. #[cfg(fspy)] pub path_accesses: Option, + /// An error that made fspy's observations incomplete. + #[cfg(fspy)] + pub tracing_error: Option, } /// Spawn a command with the requested fspy and stdio configuration. @@ -147,6 +150,7 @@ where Ok(ChildOutcome { exit_status: termination.status, path_accesses: Some(termination.path_accesses), + tracing_error: termination.tracing_error, }) } .boxed_local(); @@ -191,6 +195,8 @@ fn spawn_tokio( exit_status, #[cfg(fspy)] path_accesses: None, + #[cfg(fspy)] + tracing_error: None, }) } .boxed_local();