Kernel-level syscall interception for x86-64 Linux - in Rust.
Process calls write(2)
│
▼
seccomp filter intercepts
│
├─► SECCOMP_RET_USER_NOTIF ──► parent supervisor fd
│ │ │
│ │ hook(TrappedSyscall)
│ │ │
│ │◄─────── continue ────────┤
│ │◄────── synthetic ret ────┘
│ │
▼ ▼
syscall executes OR kernel returns your fake value
asm_tracer gives you a programmable chokepoint between any process and the Linux kernel. Intercept, modify, suppress, or synthesise the return value of any syscall - before the kernel sees it. Inspect a frozen child process's memory with zero-copy reads while it's mid-syscall. All from safe Rust.
Every existing solution is either too high (strace), too fragile (LD_PRELOAD), or too coupled to libc (ptrace wrappers). asm_tracer is none of those things.
| Tool | Survives execve |
Zero libc | Arg rewrite | Synthetic return | Memory read |
|---|---|---|---|---|---|
strace |
✅ | ❌ | ❌ | ❌ | ❌ |
ptrace |
✅ | ❌ | ✅ | ✅ | ✅ |
LD_PRELOAD |
❌ | ❌ | ✅ | ✅ | ❌ |
seccomp+SIGSYS |
❌ | ✅ | ✅ | ✅ | ❌ |
| asm_tracer | ✅ | ✅ | ✅ | ✅ | ✅ |
This library is intended for authorized security research, defensive tooling, sandboxing, and controlled software testing. It is not a toy.
Using this tool against systems you do not own, or do not have explicit written permission to test, is illegal under the Computer Fraud and Abuse Act (US), the Computer Misuse Act (UK), and equivalent law in most jurisdictions.
The authors accept no liability for misuse. See
SECURITY.mdfor the full policy, responsible disclosure process, and a known sharp-edges inventory.
- Raw x86-64 syscall stubs - hand-written NASM assembly, 0 through 6 arguments, no libc, no glibc ABI noise
- Dual interception modes -
SECCOMP_RET_SIGSYSfor in-process tracing;SECCOMP_RET_USER_NOTIFfor cross-process supervision that survivesexecve - Argument rewriting - modify
rdi,rsi,rdx,r10,r8,r9before the kernel sees them - Synthetic return values - suppress a syscall entirely and return any value you choose
- Frozen memory reads - read a child's address space via
process_vm_readv(2)while it is suspended mid-syscall; the kernel holds the child until you reply - Lock-free ring buffer - async-signal-safe SPSC queue for zero-allocation syscall recording in signal context
- Panic-safe hooks - a panicking hook unwinds without freezing the child
- Drop-kills-child -
TracedChildkills and reaps on drop; no zombie processes
┌─────────────────────────────────────────────────────────┐
│ crate: syscall │
│ asm/syscall.asm → libsyscall.a │
│ raw_syscall{0..6} → syscall{0..6} → safe wrappers │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ crate: asm_tracer_core │
│ filter.rs - BPF program generation + installation │
│ handler.rs - SIGSYS handler, ring buffer, hook mgmt │
│ lib.rs - enable_tracing(), drain_trapped_syscalls()│
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ crate: exec │
│ child.rs - fork/exec, SCM_RIGHTS fd passing, │
│ notification thread, TracedChild API │
└─────────────────────────────────────────────────────────┘
| Requirement | Minimum |
|---|---|
| Linux kernel | 5.0 (for SECCOMP_RET_USER_NOTIF) |
| Architecture | x86-64 only |
| Rust toolchain | stable |
| NASM | any recent version |
GNU ar |
any (binutils) |
# Debian/Ubuntu
sudo apt install nasm binutilsAdd to your Cargo.toml:
[dependencies]
asm_tracer_core = { path = "crates/core" }
exec = { path = "crates/exec" }
syscall = { path = "crates/syscall" }use asm_tracer_core::TrappedSyscall;
use exec::spawn_traced;
fn hook(syscall: &TrappedSyscall) -> Option<i64> {
// Return the byte count - child thinks it wrote; kernel did nothing.
Some(syscall.args[2])
}
fn main() {
let child = spawn_traced(
"/bin/echo",
&["echo", "hello"],
&[1], // SYS_write
Some(hook),
).expect("spawn failed");
let status = child.wait().expect("wait failed");
println!("child exited: {}", libc::WEXITSTATUS(status));
let syscalls = child.drain_syscalls();
println!("intercepted {} write(2) calls", syscalls.len());
}use std::sync::mpsc;
use asm_tracer_core::TrappedSyscall;
use exec::spawn_traced;
fn main() {
let (tx, rx) = mpsc::channel::<(usize, usize)>();
let (done_tx, done_rx) = mpsc::channel::<()>();
let hook = move |syscall: &TrappedSyscall| {
if syscall.args[0] == 1 { // stdout
// args[1] = child VA of buffer, args[2] = length
tx.send((syscall.args[1] as usize, syscall.args[2] as usize)).unwrap();
done_rx.recv().unwrap(); // block until parent is done reading
}
None // let the real syscall through
};
let child = spawn_traced("/bin/echo", &["echo", "hello"], &[1], Some(hook))
.expect("spawn failed");
// Child is now frozen inside write(2). Safe to read its address space.
let (addr, len) = rx.recv_timeout(std::time::Duration::from_secs(1)).unwrap();
let data = child.read_memory(addr, len).expect("read_memory failed");
println!("child buffer contents: {:?}", std::str::from_utf8(&data));
// → Ok("hello\n")
done_tx.send(()).unwrap(); // release the child
child.wait().expect("wait failed");
}fn hook(syscall: &TrappedSyscall) -> Option<i64> {
if syscall.args[0] == 1 { // writes to stdout
Some(-(libc::EBADF as i64)) // "bad file descriptor", sir
} else {
None // everything else passes through
}
}use asm_tracer_core::{enable_tracing, drain_trapped_syscalls, TrappedSyscall};
fn hook(syscall: &mut TrappedSyscall) -> Option<i64> {
// Called from signal context - must be async-signal-safe
Some(-(libc::ENOSYS as i64)) // suppress all trapped syscalls
}
fn main() {
enable_tracing(&[39], Some(hook)).expect("failed"); // trap getpid
// ... your program runs; trapped syscalls are queued
let calls = drain_trapped_syscalls();
println!("trapped {} getpid calls", calls.len());
}| Function | Description |
|---|---|
syscall{0..6}(num, ...) |
Raw syscall wrappers returning Result<i64, SyscallError> |
open / close / read / write |
Type-safe file I/O |
mmap / munmap |
Memory mapping |
ptrace / prctl / kill |
Process control |
wait4 |
Child reaping |
process_vm_readv |
Cross-process memory read |
| Function | Description |
|---|---|
enable_tracing(syscalls, hook) |
Install SIGSYS handler + seccomp filter |
install_filter(syscalls) |
Install SIGSYS-only filter (no handler) |
install_filter_with_notifs(syscalls) |
Install USER_NOTIF filter, returns supervisor fd |
drain_trapped_syscalls() |
Drain the global ring buffer |
| Item | Description |
|---|---|
spawn_traced(path, argv, syscalls, hook) |
Fork + exec with USER_NOTIF interception |
TracedChild::wait() |
Wait for child, returns raw status |
TracedChild::kill(sig) |
Send signal to child |
TracedChild::drain_syscalls() |
Drain syscalls captured from this child |
TracedChild::read_memory(addr, len) |
Read child VA (only valid while child is frozen) |
TracedChild::pid() |
Raw PID |
pub struct TrappedSyscall {
pub nr: i64, // syscall number
pub args: [i64; 6], // rdi, rsi, rdx, r10, r8, r9
}| Return | Effect |
|---|---|
Some(n) |
Suppress syscall; kernel returns n to the caller |
None |
Allow syscall through (with any arg modifications applied) |
PR_SET_NO_NEW_PRIVSlocks the process from gaining new privileges- A classic BPF program is installed via
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...) - For each trapped syscall, the kernel delivers
SIGSYSwith fullucontext_t - The signal handler reads
rax/rdi/rsi/rdx/r10/r8/r9fromuc_mcontext.gregs - It enqueues into the lock-free ring buffer (no allocation, no lock)
- If a hook returns
Some(v), RAX is set tovand execution continues after the syscall - If the hook returns
None, RIP is decremented by 2 to re-execute thesyscallinstruction
Limitation: Does not survive execve. Use the USER_NOTIF path for traced children.
- The child installs a
SECCOMP_RET_USER_NOTIFfilter beforeexecve - The notification fd is passed to the parent via a Unix socket (
SCM_RIGHTS) - The parent's notification thread calls
SECCOMP_IOCTL_NOTIF_RECV(blocking) - On intercept: the child is frozen by the kernel until the parent calls
SECCOMP_IOCTL_NOTIF_SEND - While frozen, the parent can safely call
process_vm_readvon the child's address space - The parent either sends
FLAG_CONTINUE(allow) or an expliciterror/val(synthetic return) - The filter survives
execvebecause the fd lives in the parent
This library is intentionally low-level. Some things worth understanding:
PR_SET_NO_NEW_PRIVSis irreversible. Once set on a process, it cannot be unset. Traced children inherit it.- The hook runs in signal context (SIGSYS path). It must be async-signal-safe: no
malloc, noMutex, noprintln!. process_vm_readvrequires matching UID orCAP_SYS_PTRACE. Your supervisor must have appropriate privileges.- Synthetic return values bypass the kernel. If you suppress
open()and return fd=3, the child will try to use fd 3. Ensure it's validity. - The ring buffer is SPSC. In multi-threaded programs invoking the same trapped syscalls, entries may interleave. This is noted in the docs and is a known limitation.
.
├── crates/
│ ├── syscall/ # Raw syscall bindings
│ │ ├── asm/syscall.asm # Hand-written x86-64 syscall stubs
│ │ ├── build.rs # Assembles + archives libsyscall.a
│ │ ├── src/lib.rs # syscall{0..6} wrappers + extern linkage
│ │ └── src/safe.rs # Type-safe syscall wrappers
│ │
│ ├── core/ # Interception engine
│ │ ├── src/filter.rs # BPF filter construction + installation
│ │ ├── src/handler.rs # SIGSYS handler, ring buffer
│ │ └── src/lib.rs # Public API + CoreError
│ │
│ └── exec/ # Child process management
│ ├── src/child.rs # fork/exec, SCM_RIGHTS, TracedChild
│ └── src/lib.rs
│
├── tests/
│ ├── syscall_tests.rs # syscall crate integration tests
│ ├── core_tests.rs # core tracing engine tests
│ ├── exec_tests.rs # child spawning + hook tests
│ └── integration.rs # full-stack tests
│
└── benches/
└── tracer_bench.rs # criterion benchmarks
# Standard build
cargo build --release
# Run all tests (requires Linux x86-64 + kernel ≥ 5.0)
cargo test
# Run benchmarks
cargo benchThe build.rs in the syscall crate automatically:
- Checks for
nasmandarin PATH - Assembles
asm/syscall.asminto a 64-bit ELF object - Archives it into
libsyscall.a - Instructs Cargo to link it statically
- Syscall fuzzing - intercept, mutate, and observe how programs respond to unexpected kernel return values
- Sandbox enforcement - block or redirect dangerous syscalls in untrusted child processes
- Dynamic binary analysis - trace syscall sequences across
execveboundaries without modifying the target - Fault injection - synthesise
ENOMEM,ENOSPC, or any errno to test error-handling paths - API hooking - redirect file opens, network calls, or memory maps without touching the binary
- Forensics / EDR - record syscall traces with full argument capture, frozen memory snapshots
- x86-64 Linux only. The assembly stubs, register offsets, and BPF constants are all architecture-specific.
- Kernel ≥ 5.0 required for
SECCOMP_RET_USER_NOTIF. The SIGSYS path works on older kernels. - SPSC ring buffer - not designed for multi-producer signal contexts.
process_vm_readvrequires privilege - either matching UID,CAP_SYS_PTRACE, or a permissiveptrace_scope.- The hook is sync - if your hook blocks, the child is frozen for the duration.
Issues, PRs, and security disclosures welcome. If you're adding architecture support, the surface area is:
asm/syscall.asm- new stubshandler.rs-gregsoffsets for the new archfilter.rs-NR_OFFSETinseccomp_data(same on all Linux arches, so likely fine)
MIT.