Skip to content

czeti/asm_tracer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🔬 asm_tracer

Kernel-level syscall interception for x86-64 Linux - in Rust.

Rust Platform Kernel License: MIT unsafe: yes Security Policy


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.


✦ Why This Exists

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

✦ Ethical Use & Legal Notice

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.md for the full policy, responsible disclosure process, and a known sharp-edges inventory.


✦ Key Features

  • Raw x86-64 syscall stubs - hand-written NASM assembly, 0 through 6 arguments, no libc, no glibc ABI noise
  • Dual interception modes - SECCOMP_RET_SIGSYS for in-process tracing; SECCOMP_RET_USER_NOTIF for cross-process supervision that survives execve
  • Argument rewriting - modify rdi, rsi, rdx, r10, r8, r9 before 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 - TracedChild kills and reaps on drop; no zombie processes

✦ Architecture

┌─────────────────────────────────────────────────────────┐
│  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     │
└─────────────────────────────────────────────────────────┘

✦ Requirements

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 binutils

✦ Quick Start

Add to your Cargo.toml:

[dependencies]
asm_tracer_core = { path = "crates/core" }
exec             = { path = "crates/exec" }
syscall          = { path = "crates/syscall" }

Example 1 - Intercept write(2) and suppress it

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());
}

Example 2 - Read frozen child memory during a syscall

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");
}

Example 3 - Synthetic error injection

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
    }
}

Example 4 - In-process tracing (SIGSYS path, no child required)

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());
}

✦ API Reference

syscall crate

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

asm_tracer_core crate

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

exec crate

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

TrappedSyscall

pub struct TrappedSyscall {
    pub nr: i64,       // syscall number
    pub args: [i64; 6], // rdi, rsi, rdx, r10, r8, r9
}

Hook return convention

Return Effect
Some(n) Suppress syscall; kernel returns n to the caller
None Allow syscall through (with any arg modifications applied)

✦ How It Works

The SIGSYS Path (in-process)

  1. PR_SET_NO_NEW_PRIVS locks the process from gaining new privileges
  2. A classic BPF program is installed via prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...)
  3. For each trapped syscall, the kernel delivers SIGSYS with full ucontext_t
  4. The signal handler reads rax/rdi/rsi/rdx/r10/r8/r9 from uc_mcontext.gregs
  5. It enqueues into the lock-free ring buffer (no allocation, no lock)
  6. If a hook returns Some(v), RAX is set to v and execution continues after the syscall
  7. If the hook returns None, RIP is decremented by 2 to re-execute the syscall instruction

Limitation: Does not survive execve. Use the USER_NOTIF path for traced children.

The USER_NOTIF Path (cross-process)

  1. The child installs a SECCOMP_RET_USER_NOTIF filter before execve
  2. The notification fd is passed to the parent via a Unix socket (SCM_RIGHTS)
  3. The parent's notification thread calls SECCOMP_IOCTL_NOTIF_RECV (blocking)
  4. On intercept: the child is frozen by the kernel until the parent calls SECCOMP_IOCTL_NOTIF_SEND
  5. While frozen, the parent can safely call process_vm_readv on the child's address space
  6. The parent either sends FLAG_CONTINUE (allow) or an explicit error/val (synthetic return)
  7. The filter survives execve because the fd lives in the parent

✦ Security Considerations

This library is intentionally low-level. Some things worth understanding:

  • PR_SET_NO_NEW_PRIVS is 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, no Mutex, no println!.
  • process_vm_readv requires matching UID or CAP_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.

✦ Crate Structure

.
├── 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

✦ Building

# Standard build
cargo build --release

# Run all tests (requires Linux x86-64 + kernel ≥ 5.0)
cargo test

# Run benchmarks
cargo bench

The build.rs in the syscall crate automatically:

  1. Checks for nasm and ar in PATH
  2. Assembles asm/syscall.asm into a 64-bit ELF object
  3. Archives it into libsyscall.a
  4. Instructs Cargo to link it statically

✦ Use Cases

  • 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 execve boundaries 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

✦ Limitations

  • 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_readv requires privilege - either matching UID, CAP_SYS_PTRACE, or a permissive ptrace_scope.
  • The hook is sync - if your hook blocks, the child is frozen for the duration.

✦ Contributing

Issues, PRs, and security disclosures welcome. If you're adding architecture support, the surface area is:

  • asm/syscall.asm - new stubs
  • handler.rs - gregs offsets for the new arch
  • filter.rs - NR_OFFSET in seccomp_data (same on all Linux arches, so likely fine)

✦ License

MIT.


Built with love.

About

Linux syscall tracing and supervision in Rust. Trap syscalls with seccomp, inspect/modify via hooks, and supervise child processes.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors