From 10db79b7c6b0cdf60362e82d923d5621c1cf2e3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 05:21:20 +0000 Subject: [PATCH] Split detection from transport behind a Backend trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraith's detection model — provenance, W^X, stack-integrity, and the exploitation-chain correlator — was interleaved with the ptrace reap loop in tracer.rs, so the eBPF backend the README already names as the near-zero-overhead production path had nowhere to attach. Extract the transport-agnostic detection core into src/engine.rs: - Engine owns the per-address-space cached memory map, the per-process live stats, the Detector, the run Summary, and the enforce-on-CRITICAL policy. It captures a neutral SyscallEntry register snapshot and, in inspect(), refreshes the map, runs detection, and returns the enforcement Action to carry out — without ever issuing a ptrace call. - Backend is the trait a transport implements (drive()). Tracer becomes the first Backend: it keeps only what is ptrace-specific — the waitpid loop, register reads, thread entry/exit phase, and the enforcement *mechanism* (rewriting the syscall number, SIGKILLing the tree). - Summary, ProcStat, and Reporter move to engine.rs and are re-exported from tracer.rs, so wraith::tracer::{...} paths stay source-compatible. Behavior is unchanged: the map dirty/refresh timing, syscall counting on a lost getregs, the CRITICAL-only enforcement gate, and the fatal-fault signal path are all preserved. Full suite (37 unit + 5 bin + 10 integration) passes and clippy is clean; a live run still reports benign as clean and shellcode-sim as an EXPLOITATION CHAIN. An eBPF backend can now implement Backend and reuse the entire Engine, swapping only how a syscall stop is obtained. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FiEuoW9Kpfw6Gv1d3pCXgs --- README.md | 18 +- src/engine.rs | 370 ++++++++++++++++++++++++++++++++ src/lib.rs | 7 +- src/tracer.rs | 583 +++++++++++++++----------------------------------- 4 files changed, 566 insertions(+), 412 deletions(-) create mode 100644 src/engine.rs diff --git a/README.md b/README.md index 16d93be..9043061 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,8 @@ carry the smallest supply chain you can manage. The engine links only `nix` and ├─ syscalls.rs the syscall table Wraith cares about ├─ detect.rs the invariants + the exploitation-chain correlator ├─ event.rs detection events + their JSONL form - ├─ tracer.rs the ptrace engine (spawn/attach/scan, thread-following, enforcement) + ├─ engine.rs the transport-agnostic detection core (Backend trait, Engine) + ├─ tracer.rs the ptrace Backend (spawn/attach/scan, thread-following, enforcement) ├─ ui.rs the live terminal dashboard (--ui), hand-rolled ANSI └─ bin/ ├─ wraith.rs the CLI sensor @@ -251,6 +252,15 @@ The tracer adds no syscall of its own on the hot path beyond the unavoidable `getregs`, and re-reads `/proc//maps` only when a memory operation could have changed it. +**Transport-agnostic core.** Detection is split from transport behind a +`Backend` trait. The `ptrace` tracer is the first backend: it captures each +syscall-entry into a neutral register snapshot and hands it to the shared +`Engine`, which owns the cached memory map, the per-process stats, the detector, +and the enforce-on-CRITICAL policy — and never issues a `ptrace` call itself. +The same `Engine` drives any transport unchanged, so the planned eBPF backend +(below) reuses the entire detection model and only swaps how a syscall stop is +obtained. + **Thread-following.** Real targets — network daemons, request handlers, fuzz harnesses — are multithreaded, and an exploit can fire from any thread. Wraith follows every `clone`/`fork`/`vfork` the target makes and inspects syscalls @@ -270,7 +280,11 @@ claim to be a finished EDR. (network daemons, parsers, fuzz targets), not the whole system. The production path is the same logic on **eBPF** (`tracepoint/raw_syscalls` + a page-provenance map) for near-zero overhead — the detection model is - transport-agnostic by design. + transport-agnostic by design, and now lives behind a `Backend` trait + (`src/engine.rs`) so that eBPF backend slots in beside the `ptrace` one + without touching detection. (eBPF observes rather than stops, so it would be + the low-overhead *observe* backend; `ptrace` stays for lossless capture and + `--block`/`--kill`, which need the tracee held at syscall entry.) - **Attach vs. pre-existing threads.** `wraith run` and `wraith attach` follow every thread and child the target spawns *after* tracing begins (via `PTRACE_O_TRACECLONE`/`FORK`/`VFORK`). When attaching to an already-running diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..250157c --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,370 @@ +//! The transport-agnostic detection core. +//! +//! Wraith's value is in *what* it checks at each syscall — provenance, W^X, +//! stack integrity, the exploitation-chain correlator — not in *how* the +//! syscall stop is obtained. [`Engine`] owns everything on the first side of +//! that line: the [`Detector`], the per-address-space cached memory map, the +//! per-process live [`ProcStat`]s, the running [`Summary`], and the decision of +//! whether a confirmed exploitation should be enforced. +//! +//! A *transport* — the `ptrace` engine today, an eBPF backend tomorrow — is a +//! [`Backend`]. It captures a [`SyscallEntry`] however it can (a `ptrace` +//! `getregs`, an eBPF `sys_enter` tracepoint) and feeds it to +//! [`Engine::inspect`], which runs detection and hands back the enforcement +//! [`Action`] to carry out. The *mechanism* of enforcement (rewriting the +//! syscall, killing the tree) stays with the backend, because only it knows how +//! to touch its tracees; the *policy* (fire only on a CRITICAL verdict) lives +//! here so every backend shares it. Because the engine never issues a single +//! `ptrace` call itself, the same detection logic drives any transport +//! unchanged — the model is transport-agnostic by design. + +use std::collections::HashMap; +use std::fs; +use std::io; +use std::time::{Duration, Instant}; + +use crate::detect::{Config, Detector, Enforcement, SyscallCtx}; +use crate::event::{Event, Severity}; +use crate::maps::MemoryMap; +use crate::syscalls; + +/// A cached memory map for one address space (one thread-group), plus a flag +/// set whenever any thread in the group runs a memory-management syscall that +/// could have changed it. Threads share memory, so one thread's `mmap` +/// invalidates the whole group's view. +struct AddrSpace { + map: Option, + dirty: bool, +} + +impl AddrSpace { + fn new() -> Self { + AddrSpace { map: None, dirty: true } + } +} + +/// A raw syscall-entry register snapshot, captured by whatever transport is in +/// use. `rip` points just past the two-byte `syscall` instruction (as the +/// hardware leaves it); the engine rewinds it to the instruction site itself. +#[derive(Debug, Clone, Copy)] +pub struct SyscallEntry { + /// The syscall number (`orig_rax` on x86-64). + pub nr: u64, + /// The instruction pointer, pointing just past the `syscall` instruction. + pub rip: u64, + /// The stack pointer at the trap. + pub rsp: u64, + /// Syscall arguments in x86-64 ABI order: rdi, rsi, rdx, r10, r8, r9. + pub args: [u64; 6], +} + +/// The enforcement action a [`Backend`] must carry out after a syscall-entry is +/// inspected. The engine decides *whether* to act; the backend knows *how*. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Let the syscall run — the default, and the only outcome under `Observe`. + Proceed, + /// Neutralise this syscall in place before it executes (`--block`). + Block, + /// Kill the whole traced tree before this syscall executes (`--kill`). + Kill, +} + +/// What one [`Engine::inspect`] produced: the enforcement action to take, and +/// whether any event fired (so the backend can force an immediate UI repaint). +#[derive(Debug, Clone, Copy)] +pub struct Step { + pub action: Action, + pub event_fired: bool, +} + +/// Outcome of a completed trace. +#[derive(Debug, Default, Clone)] +pub struct Summary { + pub exit_code: Option, + pub term_signal: Option, + pub syscalls_seen: u64, + pub events: u64, + pub max_severity: Option, +} + +impl Summary { + fn record(&mut self, ev: &Event) { + self.events += 1; + self.max_severity = Some(match self.max_severity { + Some(cur) => cur.max(ev.severity), + None => ev.severity, + }); + } +} + +/// Live, per-process statistics surfaced to a [`Reporter`] during a run, so a +/// UI can show what each traced process is doing in real time. Keyed by +/// thread-group id — one entry per address space, matching how detection is +/// scoped — so every thread of a process rolls up into one row. +#[derive(Debug, Clone)] +pub struct ProcStat { + pub tgid: i32, + pub name: String, + pub syscalls: u64, + pub events: u64, + pub max_severity: Option, + pub alive: bool, +} + +impl ProcStat { + fn new(tgid: i32) -> Self { + ProcStat { + tgid, + name: read_comm(tgid), + syscalls: 0, + events: 0, + max_severity: None, + alive: true, + } + } + + fn record(&mut self, ev: &Event) { + self.events += 1; + self.max_severity = Some(match self.max_severity { + Some(cur) => cur.max(ev.severity), + None => ev.severity, + }); + } +} + +/// The sink for a run's output. Detection events arrive via [`Reporter::event`]; +/// an implementor that also wants live progress overrides [`Reporter::wants_refresh`] +/// to return `true` and paints in [`Reporter::refresh`]. The defaults make a +/// plain event-only consumer (a logging closure, the test harness) pay nothing +/// for progress machinery it doesn't use — the engine skips building snapshots +/// entirely when no one is watching. +pub trait Reporter { + /// One detection fired. + fn event(&mut self, ev: &Event); + /// Whether this reporter wants periodic [`refresh`](Reporter::refresh) + /// snapshots. Default `false`. + fn wants_refresh(&self) -> bool { + false + } + /// A periodic snapshot of every traced process and the running aggregate. + /// Only called when [`wants_refresh`](Reporter::wants_refresh) is `true`. + fn refresh(&mut self, _stats: &[ProcStat], _summary: &Summary) {} +} + +/// A syscall-transport engine: it drives its tracee(s), captures each +/// syscall-entry into a [`SyscallEntry`], feeds them to an owned [`Engine`], +/// and applies the enforcement the engine asks for. The `ptrace` [`Tracer`] is +/// the first implementor; an eBPF backend would be the second, reusing the same +/// [`Engine`] unchanged. +/// +/// [`Tracer`]: crate::tracer::Tracer +pub trait Backend { + /// Run the trace to completion, feeding every detection (and, if the + /// reporter asks, periodic progress snapshots) to `reporter`, and return + /// the run summary once the last tracee has exited. + fn drive(self: Box, reporter: &mut dyn Reporter) -> io::Result; +} + +/// The detection core shared by every [`Backend`]. Owns the detector, the +/// per-address-space cached maps, the per-process stats, and the run summary; +/// exposes exactly the operations a transport needs to drive detection without +/// ever issuing a syscall of its own. +pub struct Engine { + detector: Detector, + /// The enforcement policy — what to do on a confirmed (CRITICAL) verdict. + enforcement: Enforcement, + /// One cached map per address space (thread-group id). Threads that share + /// memory share an entry; a memory op by any of them marks it dirty. + spaces: HashMap, + /// One live-stats row per address space, surfaced to the reporter. + stats: HashMap, + summary: Summary, +} + +impl Engine { + /// Build an engine from a [`Config`]. The enforcement policy is taken from + /// the config so the backend and the detector agree on it. + pub fn new(cfg: Config) -> Self { + let enforcement = cfg.enforcement; + Engine { + detector: Detector::new(cfg), + enforcement, + spaces: HashMap::new(), + stats: HashMap::new(), + summary: Summary::default(), + } + } + + /// Ensure the per-address-space map cache and the per-process stats row for + /// `tgid` exist. A backend calls this when it first sees a thread-group, so + /// a process shows up in the live view even before its first syscall. + pub fn register_space(&mut self, tgid: i32) { + self.spaces.entry(tgid).or_insert_with(AddrSpace::new); + self.stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); + } + + /// Count one syscall for `tgid` against the summary and its stats row. Kept + /// separate from [`inspect`](Engine::inspect) so a syscall still counts even + /// when the backend cannot read its registers (a lost stop is still work the + /// tracee did). + pub fn count_syscall(&mut self, tgid: i32) { + self.summary.syscalls_seen += 1; + self.stats + .entry(tgid) + .or_insert_with(|| ProcStat::new(tgid)) + .syscalls += 1; + } + + /// Inspect one syscall-entry: refresh the shared map if a prior memory op + /// could have changed it, run the detector, record every event it fires, + /// mark the map stale if this call is itself a memory op, and return the + /// enforcement [`Action`] the backend should carry out. + pub fn inspect( + &mut self, + pid: i32, + tgid: i32, + entry: &SyscallEntry, + reporter: &mut dyn Reporter, + ) -> Step { + let nr = entry.nr; + + // Refresh the shared map if stale or unset. Reading `/proc//maps` + // for any thread yields the whole group's address space. Scoped so the + // &mut to `spaces` is released before detection re-borrows it read-only. + { + let space = self.spaces.entry(tgid).or_insert_with(AddrSpace::new); + if space.dirty || space.map.is_none() { + if let Ok(fresh) = MemoryMap::read(pid) { + space.map = Some(fresh); + space.dirty = false; + } + } + } + + // Run detection against the current map, if we have one. `detector`, + // `summary` and `stats` are disjoint fields from `spaces`, so the + // read-only map borrow coexists with recording events. + let mut max_severity = None; + if let Some(current) = self.spaces.get(&tgid).and_then(|s| s.map.as_ref()) { + // The `syscall` instruction is two bytes; rip already points past it. + let ctx = SyscallCtx { + pid, + nr, + rip: entry.rip.wrapping_sub(2), + rsp: entry.rsp, + args: entry.args, + }; + let stat = self.stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); + for ev in self.detector.on_syscall(tgid, &ctx, current) { + max_severity = + Some(max_severity.map_or(ev.severity, |cur: Severity| cur.max(ev.severity))); + self.summary.record(&ev); + stat.record(&ev); + reporter.event(&ev); + } + } + + // A memory op by any thread can change the shared address space; + // invalidate the whole group's map so the next inspection re-reads it. + if syscalls::is_memory_op(nr) { + if let Some(space) = self.spaces.get_mut(&tgid) { + space.dirty = true; + } + } + + // Enforce only on a confirmed exploitation (CRITICAL). The backend acts + // at the syscall's entry stop — the one moment it has not yet run. + let critical = max_severity == Some(Severity::Critical); + let action = if critical { + match self.enforcement { + Enforcement::Block => Action::Block, + Enforcement::Kill => Action::Kill, + Enforcement::Observe => Action::Proceed, + } + } else { + Action::Proceed + }; + + Step { action, event_fired: max_severity.is_some() } + } + + /// Record a backend-originated event (an enforcement action, a fatal-fault + /// crash) against the summary, the `tgid`'s stats row, and the reporter. + /// Detection events go through [`inspect`](Engine::inspect); this is the + /// shared sink for events the transport raises itself. + pub fn record_event(&mut self, tgid: i32, ev: &Event, reporter: &mut dyn Reporter) { + self.summary.record(ev); + if let Some(stat) = self.stats.get_mut(&tgid) { + stat.record(ev); + } + reporter.event(ev); + } + + /// Record the root tracee's exit code into the summary. + pub fn set_exit_code(&mut self, code: i32) { + self.summary.exit_code = Some(code); + } + + /// Record the root tracee's terminating signal into the summary. + pub fn set_term_signal(&mut self, sig: i32) { + self.summary.term_signal = Some(sig); + } + + /// Flip a process's live-stats row to "exited". A backend calls this once + /// the last thread of `tgid` is gone. + pub fn mark_dead(&mut self, tgid: i32) { + if let Some(s) = self.stats.get_mut(&tgid) { + s.alive = false; + } + } + + /// Push a live snapshot to the reporter. When `force` is false the paint is + /// throttled to ~20 fps so a syscall-heavy target doesn't spend its time + /// redrawing; `force` (a detection, a process lifecycle change, the final + /// frame) always paints. Skipped entirely — no snapshot built — when the + /// reporter doesn't want progress, so the plain event path costs nothing. + pub fn refresh(&self, reporter: &mut dyn Reporter, last: &mut Instant, force: bool) { + if !reporter.wants_refresh() { + return; + } + let now = Instant::now(); + if !force && now.duration_since(*last) < Duration::from_millis(50) { + return; + } + *last = now; + let mut snap: Vec = self.stats.values().cloned().collect(); + snap.sort_by_key(|s| s.tgid); + reporter.refresh(&snap, &self.summary); + } + + /// Consume the engine and return the accumulated summary. + pub fn into_summary(self) -> Summary { + self.summary + } +} + +/// The thread-group id of a thread, read once from `/proc//status`. +/// Threads of a process share a tgid (and their address space); a `fork`ed +/// child gets its own. Falls back to the tid itself if status is unreadable. +pub fn read_tgid(tid: i32) -> i32 { + if let Ok(status) = fs::read_to_string(format!("/proc/{tid}/status")) { + for line in status.lines() { + if let Some(rest) = line.strip_prefix("Tgid:") { + if let Ok(v) = rest.trim().parse::() { + return v; + } + } + } + } + tid +} + +/// The short `comm` name of a process (e.g. `nginx`), read once. Falls back to +/// the pid rendered as a string when `/proc//comm` is unreadable. +fn read_comm(pid: i32) -> String { + match fs::read_to_string(format!("/proc/{pid}/comm")) { + Ok(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => pid.to_string(), + } +} diff --git a/src/lib.rs b/src/lib.rs index b6bd5d7..05373c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,10 +37,12 @@ //! - [`syscalls`] — the syscall table Wraith cares about. //! - [`detect`] — the rules and the exploitation-chain correlator. //! - [`event`] — detection events and their JSON form. -//! - [`tracer`] — the `ptrace` engine that drives a target. +//! - [`engine`] — the transport-agnostic detection core ([`Backend`], [`Engine`]). +//! - [`tracer`] — the `ptrace` [`Backend`] that drives a target. //! - [`ui`] — the live terminal dashboard (`--ui`). pub mod detect; +pub mod engine; pub mod event; pub mod maps; pub mod provenance; @@ -49,6 +51,7 @@ pub mod tracer; pub mod ui; pub use detect::{Config, Detector, Enforcement, SyscallCtx}; +pub use engine::{Backend, Engine, ProcStat, Reporter, Summary}; pub use event::{Event, Kind, Severity}; -pub use tracer::{ProcStat, Reporter, Summary, Tracer}; +pub use tracer::Tracer; pub use ui::{Dashboard, TerminalGuard}; diff --git a/src/tracer.rs b/src/tracer.rs index c6edc6a..f10926a 100644 --- a/src/tracer.rs +++ b/src/tracer.rs @@ -1,11 +1,18 @@ -//! The ptrace engine. +//! The ptrace engine — Wraith's first [`Backend`]. //! //! Wraith drives a target with `PTRACE_SYSCALL`, stopping at the entry to every -//! system call. At each stop it reads the tracee's registers, refreshes the -//! memory map when a prior memory operation could have changed it, and hands -//! the snapshot to the [`Detector`]. The design goal is to add no syscall of -//! our own on the hot path beyond the unavoidable `getregs`, and to re-read -//! `/proc//maps` only when it can have changed. +//! system call. At each stop it reads the tracee's registers into a +//! [`SyscallEntry`] and hands it to the [`Engine`], which refreshes the memory +//! map when a prior operation could have changed it and runs the detector. The +//! design goal is to add no syscall of our own on the hot path beyond the +//! unavoidable `getregs`, and to re-read `/proc//maps` only when it can +//! have changed (the engine's job). +//! +//! This module owns only what is specific to the `ptrace` *transport*: how a +//! syscall stop is obtained (the `waitpid` reap loop), how registers are read, +//! and how enforcement is *carried out* (rewriting the syscall number, killing +//! the tree). The transport-agnostic detection core lives in [`Engine`]; an +//! eBPF backend would reuse it unchanged. //! //! ## Thread-following //! @@ -20,34 +27,23 @@ use std::collections::hash_map::Entry; use std::collections::HashMap; use std::ffi::CString; -use std::fs; use std::io; -use std::time::{Duration, Instant}; +use std::time::Instant; use nix::sys::ptrace; use nix::sys::signal::{kill, Signal}; use nix::sys::wait::{waitpid, WaitStatus}; use nix::unistd::{execvp, fork, ForkResult, Pid}; -use crate::detect::{Config, Detector, Enforcement, SyscallCtx}; +use crate::detect::Config; +use crate::engine::{read_tgid, Action, Backend, Engine, SyscallEntry}; use crate::event::{Event, Kind, Severity}; -use crate::maps::MemoryMap; use crate::syscalls; -/// A cached memory map for one address space (one thread-group), plus a flag -/// set whenever any thread in the group runs a memory-management syscall that -/// could have changed it. Threads share memory, so one thread's `mmap` -/// invalidates the whole group's view. -struct AddrSpace { - map: Option, - dirty: bool, -} - -impl AddrSpace { - fn new() -> Self { - AddrSpace { map: None, dirty: true } - } -} +// Re-export the transport-agnostic detection types from their new home so the +// long-standing `wraith::tracer::{Reporter, Summary, ProcStat}` paths — used by +// the UI, the binary, and the test-suite — keep resolving unchanged. +pub use crate::engine::{ProcStat, Reporter, Summary}; /// Per-thread bookkeeping. `PTRACE_SYSCALL` stops at both entry and exit; each /// thread toggles its own phase independently since their stops interleave. @@ -56,89 +52,6 @@ struct ThreadState { tgid: i32, } -/// What one syscall-entry inspection produced, so the run loop can decide -/// whether to enforce. -struct Inspection { - /// The syscall number, or `None` if registers could not be read. - nr: Option, - /// The highest severity among the events this syscall raised, if any. - max_severity: Option, -} - -/// Outcome of a completed trace. -#[derive(Debug, Default, Clone)] -pub struct Summary { - pub exit_code: Option, - pub term_signal: Option, - pub syscalls_seen: u64, - pub events: u64, - pub max_severity: Option, -} - -impl Summary { - fn record(&mut self, ev: &Event) { - self.events += 1; - self.max_severity = Some(match self.max_severity { - Some(cur) => cur.max(ev.severity), - None => ev.severity, - }); - } -} - -/// Live, per-process statistics surfaced to a [`Reporter`] during a run, so a -/// UI can show what each traced process is doing in real time. Keyed by -/// thread-group id — one entry per address space, matching how detection is -/// scoped — so every thread of a process rolls up into one row. -#[derive(Debug, Clone)] -pub struct ProcStat { - pub tgid: i32, - pub name: String, - pub syscalls: u64, - pub events: u64, - pub max_severity: Option, - pub alive: bool, -} - -impl ProcStat { - fn new(tgid: i32) -> Self { - ProcStat { - tgid, - name: read_comm(tgid), - syscalls: 0, - events: 0, - max_severity: None, - alive: true, - } - } - - fn record(&mut self, ev: &Event) { - self.events += 1; - self.max_severity = Some(match self.max_severity { - Some(cur) => cur.max(ev.severity), - None => ev.severity, - }); - } -} - -/// The sink for a run's output. Detection events arrive via [`Reporter::event`]; -/// an implementor that also wants live progress overrides [`Reporter::wants_refresh`] -/// to return `true` and paints in [`Reporter::refresh`]. The defaults make a -/// plain event-only consumer (a logging closure, the test harness) pay nothing -/// for progress machinery it doesn't use — the engine skips building snapshots -/// entirely when no one is watching. -pub trait Reporter { - /// One detection fired. - fn event(&mut self, ev: &Event); - /// Whether this reporter wants periodic [`refresh`](Reporter::refresh) - /// snapshots. Default `false`. - fn wants_refresh(&self) -> bool { - false - } - /// A periodic snapshot of every traced process and the running aggregate. - /// Only called when [`wants_refresh`](Reporter::wants_refresh) is `true`. - fn refresh(&mut self, _stats: &[ProcStat], _summary: &Summary) {} -} - /// Adapts a plain `FnMut(&Event)` closure into a [`Reporter`], so the common /// "just hand me events" callers keep working unchanged through [`Tracer::run`]. struct FnReporter(F); @@ -149,7 +62,7 @@ impl Reporter for FnReporter { } } -/// How the tracee(s) were obtained, so `run` knows how to seed and resume them. +/// How the tracee(s) were obtained, so `drive` knows how to seed and resume them. enum Target { /// We forked and exec'd it; it is stopped at the post-exec SIGTRAP. We own /// it, so it is killed with us on exit. @@ -164,11 +77,8 @@ enum Target { pub struct Tracer { target: Target, - detector: Detector, - /// What to do on confirmed exploitation. Kept on the tracer (not just the - /// detector) because acting on the tracee — cancelling a syscall, killing - /// the tree — is a ptrace operation the engine owns. - enforcement: Enforcement, + /// The detection configuration; an [`Engine`] is built from it per run. + cfg: Config, } impl Tracer { @@ -213,12 +123,7 @@ impl Tracer { } // We own this child, so tie its life to ours. set_options(child, true)?; - let enforcement = cfg.enforcement; - Ok(Tracer { - target: Target::Spawned(child), - detector: Detector::new(cfg), - enforcement, - }) + Ok(Tracer { target: Target::Spawned(child), cfg }) } } } @@ -238,12 +143,7 @@ impl Tracer { } // Observing someone else's process: leave it running if we stop. set_options(child, false)?; - let enforcement = cfg.enforcement; - Ok(Tracer { - target: Target::Attached(child), - detector: Detector::new(cfg), - enforcement, - }) + Ok(Tracer { target: Target::Attached(child), cfg }) } /// Attach to a whole set of already-running processes at once (`scan` @@ -278,12 +178,7 @@ impl Tracer { "could not attach to any matching process (need CAP_SYS_PTRACE / ownership?)", )); } - let enforcement = cfg.enforcement; - Ok(Tracer { - target: Target::ScanAttached(attached), - detector: Detector::new(cfg), - enforcement, - }) + Ok(Tracer { target: Target::ScanAttached(attached), cfg }) } /// Run the trace to completion — following every thread and child the @@ -301,11 +196,118 @@ impl Tracer { /// engine feeds it every detection and — when it asks via /// [`Reporter::wants_refresh`] — periodic per-process snapshots for a live /// display. The trace ends once the last tracee has exited. - pub fn run_with(mut self, mut reporter: R) -> io::Result + pub fn run_with(self, mut reporter: R) -> io::Result where R: Reporter, { - let mut summary = Summary::default(); + Box::new(self).drive(&mut reporter) + } + + /// Neutralise the syscall the tracee is stopped at by overwriting its + /// syscall number with an invalid value: the kernel then skips the call and + /// returns `-ENOSYS`, so the injected code's action never takes effect. The + /// tracee lives on, which is what `--block` is for. Records the enforcement + /// event through the engine on success. + fn block_syscall(&self, who: Pid, tgid: i32, engine: &mut Engine, reporter: &mut dyn Reporter) { + let Ok(mut regs) = ptrace::getregs(who) else { return }; + let syscall = syscalls::name(regs.orig_rax); + let rip = regs.rip.wrapping_sub(2); + let rsp = regs.rsp; + // -1 is not a valid syscall number; the kernel rejects it without + // running anything and reports -ENOSYS to the tracee. + regs.orig_rax = u64::MAX; + if ptrace::setregs(who, regs).is_err() { + return; + } + let ev = Event::now( + who.as_raw(), + Severity::Critical, + Kind::Blocked, + syscall.clone(), + rip, + rsp, + "enforced", + format!("neutralised `{syscall}` from injected code before it executed (--block)"), + ); + engine.record_event(tgid, &ev, reporter); + } + + /// `SIGKILL` every thread-group under trace. Sending the signal to a group + /// leader (a tgid) tears down all of its threads at once, so the offending + /// process — and every sibling we are following — dies before the syscall + /// we stopped at can run. + fn kill_tree( + &self, + who: Pid, + tgid: i32, + threads: &HashMap, + engine: &mut Engine, + reporter: &mut dyn Reporter, + ) { + let (syscall, rip, rsp) = ptrace::getregs(who) + .map(|r| (syscalls::name(r.orig_rax), r.rip.wrapping_sub(2), r.rsp)) + .unwrap_or_else(|_| ("?".to_string(), 0, 0)); + + // One SIGKILL per distinct thread-group is enough to take down all of + // its threads; de-duplicating avoids redundant signals. + let mut killed_groups = std::collections::HashSet::new(); + for ts in threads.values() { + if killed_groups.insert(ts.tgid) { + let _ = kill(Pid::from_raw(ts.tgid), Signal::SIGKILL); + } + } + + let ev = Event::now( + who.as_raw(), + Severity::Critical, + Kind::Killed, + syscall.clone(), + rip, + rsp, + "enforced", + format!("killed traced process tree on `{syscall}` from injected code (--kill)"), + ); + engine.record_event(tgid, &ev, reporter); + } + + /// Surface a fatal memory-safety signal as a possible failed exploit. + /// Returns whether an event was emitted (so the caller can force a repaint). + fn on_signal( + &self, + pid: Pid, + tgid: i32, + sig: Signal, + engine: &mut Engine, + reporter: &mut dyn Reporter, + ) -> bool { + let fatal = matches!( + sig, + Signal::SIGSEGV | Signal::SIGILL | Signal::SIGBUS | Signal::SIGABRT + ); + if !fatal { + return false; + } + let (rip, rsp) = ptrace::getregs(pid).map(|r| (r.rip, r.rsp)).unwrap_or((0, 0)); + let ev = Event::now( + pid.as_raw(), + Severity::High, + Kind::Crash, + format!("signal:{sig:?}"), + rip, + rsp, + "fault", + format!( + "target received {sig:?} — memory-corruption fault; possible failed exploitation attempt" + ), + ); + engine.record_event(tgid, &ev, reporter); + true + } +} + +impl Backend for Tracer { + fn drive(self: Box, reporter: &mut dyn Reporter) -> io::Result { + let mut engine = Engine::new(self.cfg.clone()); // The tracee(s) to seed, and — for a single spawned/attached target — // the one pid whose exit status the summary records. A `scan` has many @@ -315,12 +317,9 @@ impl Tracer { Target::ScanAttached(pids) => (pids.clone(), None), }; - // Per-thread phase, per-address-space (tgid) cached maps, and per-process - // live stats. Threads that share memory share an `AddrSpace` and a - // `ProcStat` entry. + // Per-thread entry/exit phase, tracked here because it is a `ptrace` + // artifact; the shared maps and stats live in the engine. let mut threads: HashMap = HashMap::new(); - let mut spaces: HashMap = HashMap::new(); - let mut stats: HashMap = HashMap::new(); let mut last_refresh = Instant::now(); // Seed every initial tracee and kick each toward its first syscall stop. @@ -328,11 +327,10 @@ impl Tracer { let raw = p.as_raw(); let tgid = read_tgid(raw); threads.insert(raw, ThreadState { at_entry: true, tgid }); - spaces.entry(tgid).or_insert_with(AddrSpace::new); - stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); + engine.register_space(tgid); ptrace::syscall(*p, None).map_err(nix_err)?; } - refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); + engine.refresh(reporter, &mut last_refresh, true); loop { // Reap any tracee. `ECHILD` means every thread and child has gone. @@ -352,12 +350,11 @@ impl Tracer { if let Entry::Vacant(slot) = threads.entry(raw) { let tgid = read_tgid(raw); slot.insert(ThreadState { at_entry: true, tgid }); - spaces.entry(tgid).or_insert_with(AddrSpace::new); - stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); + engine.register_space(tgid); // Consume this initial stop and let the new tracee run; the // creation SIGSTOP must not be forwarded. let _ = ptrace::syscall(who, None); - refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); + engine.refresh(reporter, &mut last_refresh, true); continue; } @@ -366,10 +363,10 @@ impl Tracer { let tgid = threads.get(&raw).map(|t| t.tgid); threads.remove(&raw); if Some(raw) == root_pid { - summary.exit_code = Some(code); + engine.set_exit_code(code); } - mark_dead_if_last(&mut stats, &threads, tgid); - refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); + mark_dead_if_last(&mut engine, &threads, tgid); + engine.refresh(reporter, &mut last_refresh, true); if threads.is_empty() { break; } @@ -378,10 +375,10 @@ impl Tracer { let tgid = threads.get(&raw).map(|t| t.tgid); threads.remove(&raw); if Some(raw) == root_pid { - summary.term_signal = Some(sig as i32); + engine.set_term_signal(sig as i32); } - mark_dead_if_last(&mut stats, &threads, tgid); - refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); + mark_dead_if_last(&mut engine, &threads, tgid); + engine.refresh(reporter, &mut last_refresh, true); if threads.is_empty() { break; } @@ -391,34 +388,28 @@ impl Tracer { let mut killed = false; let mut event_fired = false; if threads[&raw].at_entry { - summary.syscalls_seen += 1; - let space = spaces.entry(tgid).or_insert_with(AddrSpace::new); - let stat = stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); - stat.syscalls += 1; - let step = self.inspect(who, tgid, space, &mut summary, stat, &mut reporter); - event_fired = step.max_severity.is_some(); - let is_mem = step.nr.map(syscalls::is_memory_op).unwrap_or(false); - let critical = step.max_severity == Some(Severity::Critical); - // `space` and `stat` borrows end above; safe to re-borrow. - if is_mem { - // A memory op by any thread can change the shared - // address space; invalidate the whole group's map. - spaces.get_mut(&tgid).unwrap().dirty = true; - } - // Enforce only on a confirmed exploitation (CRITICAL), - // and only at the syscall's entry stop — the one moment - // the offending syscall has not yet run. - if self.enforcement != Enforcement::Observe && critical { - let stat = stats.get_mut(&tgid).unwrap(); - match self.enforcement { - Enforcement::Block => { - self.block_syscall(who, &mut summary, stat, &mut reporter) - } - Enforcement::Kill => { - self.kill_tree(who, &threads, &mut summary, stat, &mut reporter); + // Count the syscall first, so a lost `getregs` still + // registers as work the tracee did, then inspect it. + engine.count_syscall(tgid); + if let Ok(regs) = ptrace::getregs(who) { + let entry = SyscallEntry { + nr: regs.orig_rax, + rip: regs.rip, + rsp: regs.rsp, + args: [regs.rdi, regs.rsi, regs.rdx, regs.r10, regs.r8, regs.r9], + }; + let step = engine.inspect(raw, tgid, &entry, reporter); + event_fired = step.event_fired; + // Enforce at the entry stop — the one moment the + // offending syscall has not yet run. The mechanism is + // ours; the engine already decided the policy. + match step.action { + Action::Block => self.block_syscall(who, tgid, &mut engine, reporter), + Action::Kill => { + self.kill_tree(who, tgid, &threads, &mut engine, reporter); killed = true; } - Enforcement::Observe => {} + Action::Proceed => {} } } } @@ -434,17 +425,17 @@ impl Tracer { ptrace::syscall(who, None).map_err(nix_err)?; } // Repaint immediately on a detection, else at a throttled rate. - refresh(&mut reporter, &stats, &summary, &mut last_refresh, event_fired); + engine.refresh(reporter, &mut last_refresh, event_fired); } WaitStatus::Stopped(_, sig) => { // A real signal was delivered to the tracee (not a syscall // stop). Fatal memory-safety signals are worth surfacing as // a possible failed exploit, then we forward the signal. let tgid = threads[&raw].tgid; - let stat = stats.entry(tgid).or_insert_with(|| ProcStat::new(tgid)); - let fired = self.on_signal(who, sig, &mut summary, stat, &mut reporter); + engine.register_space(tgid); + let fired = self.on_signal(who, tgid, sig, &mut engine, reporter); ptrace::syscall(who, Some(sig)).map_err(nix_err)?; - refresh(&mut reporter, &stats, &summary, &mut last_refresh, fired); + engine.refresh(reporter, &mut last_refresh, fired); } WaitStatus::PtraceEvent(_, _, _) => { // Clone/fork/exec notification for a tracee we already know; @@ -456,176 +447,8 @@ impl Tracer { } } // A final frame so the last state is on screen before we return. - refresh(&mut reporter, &stats, &summary, &mut last_refresh, true); - Ok(summary) - } - - /// Inspect a single syscall-entry stop for thread `who` in address space - /// `space`, reporting the syscall number and the highest severity it - /// raised so the caller can decide whether to enforce. - fn inspect( - &mut self, - who: Pid, - tgid: i32, - space: &mut AddrSpace, - summary: &mut Summary, - stat: &mut ProcStat, - reporter: &mut R, - ) -> Inspection - where - R: Reporter, - { - let Ok(regs) = ptrace::getregs(who) else { - return Inspection { nr: None, max_severity: None }; - }; - let nr = regs.orig_rax; - - // Refresh the shared map if stale or unset. Reading `/proc//maps` - // for any thread yields the whole group's address space. - if space.dirty || space.map.is_none() { - if let Ok(fresh) = MemoryMap::read(who.as_raw()) { - space.map = Some(fresh); - space.dirty = false; - } - } - let Some(current) = space.map.as_ref() else { - return Inspection { nr: Some(nr), max_severity: None }; - }; - - // The `syscall` instruction is two bytes; RIP already points past it. - let site = regs.rip.wrapping_sub(2); - let ctx = SyscallCtx { - pid: who.as_raw(), - nr, - rip: site, - rsp: regs.rsp, - args: [regs.rdi, regs.rsi, regs.rdx, regs.r10, regs.r8, regs.r9], - }; - - let mut max_severity = None; - for ev in self.detector.on_syscall(tgid, &ctx, current) { - max_severity = Some(max_severity.map_or(ev.severity, |cur: Severity| cur.max(ev.severity))); - summary.record(&ev); - stat.record(&ev); - reporter.event(&ev); - } - Inspection { nr: Some(nr), max_severity } - } - - /// Neutralise the syscall the tracee is stopped at by overwriting its - /// syscall number with an invalid value: the kernel then skips the call and - /// returns `-ENOSYS`, so the injected code's action never takes effect. The - /// tracee lives on, which is what `--block` is for. - fn block_syscall(&self, who: Pid, summary: &mut Summary, stat: &mut ProcStat, reporter: &mut R) - where - R: Reporter, - { - let Ok(mut regs) = ptrace::getregs(who) else { return }; - let syscall = syscalls::name(regs.orig_rax); - let rip = regs.rip.wrapping_sub(2); - let rsp = regs.rsp; - // -1 is not a valid syscall number; the kernel rejects it without - // running anything and reports -ENOSYS to the tracee. - regs.orig_rax = u64::MAX; - if ptrace::setregs(who, regs).is_err() { - return; - } - let ev = Event::now( - who.as_raw(), - Severity::Critical, - Kind::Blocked, - syscall.clone(), - rip, - rsp, - "enforced", - format!("neutralised `{syscall}` from injected code before it executed (--block)"), - ); - summary.record(&ev); - stat.record(&ev); - reporter.event(&ev); - } - - /// `SIGKILL` every thread-group under trace. Sending the signal to a group - /// leader (a tgid) tears down all of its threads at once, so the offending - /// process — and every sibling we are following — dies before the syscall - /// we stopped at can run. - fn kill_tree( - &self, - who: Pid, - threads: &HashMap, - summary: &mut Summary, - stat: &mut ProcStat, - reporter: &mut R, - ) where - R: Reporter, - { - let (syscall, rip, rsp) = ptrace::getregs(who) - .map(|r| (syscalls::name(r.orig_rax), r.rip.wrapping_sub(2), r.rsp)) - .unwrap_or_else(|_| ("?".to_string(), 0, 0)); - - // One SIGKILL per distinct thread-group is enough to take down all of - // its threads; de-duplicating avoids redundant signals. - let mut killed_groups = std::collections::HashSet::new(); - for ts in threads.values() { - if killed_groups.insert(ts.tgid) { - let _ = kill(Pid::from_raw(ts.tgid), Signal::SIGKILL); - } - } - - let ev = Event::now( - who.as_raw(), - Severity::Critical, - Kind::Killed, - syscall.clone(), - rip, - rsp, - "enforced", - format!("killed traced process tree on `{syscall}` from injected code (--kill)"), - ); - summary.record(&ev); - stat.record(&ev); - reporter.event(&ev); - } - - /// Surface a fatal memory-safety signal as a possible failed exploit. - /// Returns whether an event was emitted (so the caller can force a repaint). - fn on_signal( - &self, - pid: Pid, - sig: Signal, - summary: &mut Summary, - stat: &mut ProcStat, - reporter: &mut R, - ) -> bool - where - R: Reporter, - { - let fatal = matches!( - sig, - Signal::SIGSEGV | Signal::SIGILL | Signal::SIGBUS | Signal::SIGABRT - ); - if !fatal { - return false; - } - let (rip, rsp) = ptrace::getregs(pid) - .map(|r| (r.rip, r.rsp)) - .unwrap_or((0, 0)); - let ev = Event::now( - pid.as_raw(), - Severity::High, - Kind::Crash, - format!("signal:{sig:?}"), - rip, - rsp, - "fault", - format!( - "target received {sig:?} — memory-corruption fault; possible failed exploitation attempt" - ), - ); - summary.record(&ev); - stat.record(&ev); - reporter.event(&ev); - true + engine.refresh(reporter, &mut last_refresh, true); + Ok(engine.into_summary()) } } @@ -661,71 +484,15 @@ fn status_pid(status: &WaitStatus) -> Option { } } -/// The short `comm` name of a process (e.g. `nginx`), read once. Falls back to -/// the pid rendered as a string when `/proc//comm` is unreadable. -fn read_comm(pid: i32) -> String { - match fs::read_to_string(format!("/proc/{pid}/comm")) { - Ok(s) if !s.trim().is_empty() => s.trim().to_string(), - _ => pid.to_string(), - } -} - -/// Push a live snapshot to the reporter. When `force` is false the paint is -/// throttled to ~20 fps so a syscall-heavy target doesn't spend its time -/// redrawing; `force` (a detection, a process lifecycle change, the final -/// frame) always paints. Skipped entirely — no snapshot built — when the -/// reporter doesn't want progress, so the plain event path costs nothing. -fn refresh( - reporter: &mut R, - stats: &HashMap, - summary: &Summary, - last: &mut Instant, - force: bool, -) { - if !reporter.wants_refresh() { - return; - } - let now = Instant::now(); - if !force && now.duration_since(*last) < Duration::from_millis(50) { - return; - } - *last = now; - let mut snap: Vec = stats.values().cloned().collect(); - snap.sort_by_key(|s| s.tgid); - reporter.refresh(&snap, summary); -} - /// Mark a process dead once its last thread has gone. `tgid` is the group the /// just-exited thread belonged to; if no surviving thread shares it, the /// process is finished and its row flips to "exited". -fn mark_dead_if_last( - stats: &mut HashMap, - threads: &HashMap, - tgid: Option, -) { +fn mark_dead_if_last(engine: &mut Engine, threads: &HashMap, tgid: Option) { if let Some(tgid) = tgid { if !threads.values().any(|t| t.tgid == tgid) { - if let Some(s) = stats.get_mut(&tgid) { - s.alive = false; - } - } - } -} - -/// The thread-group id of a thread, read once from `/proc//status`. -/// Threads of a process share a tgid (and their address space); a `fork`ed -/// child gets its own. Falls back to the tid itself if status is unreadable. -fn read_tgid(tid: i32) -> i32 { - if let Ok(status) = fs::read_to_string(format!("/proc/{tid}/status")) { - for line in status.lines() { - if let Some(rest) = line.strip_prefix("Tgid:") { - if let Ok(v) = rest.trim().parse::() { - return v; - } - } + engine.mark_dead(tgid); } } - tid } fn nix_err(e: nix::errno::Errno) -> io::Error {