diff --git a/README.md b/README.md index 267df05..d260926 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ cargo build --release # Monitor a process that's already running: sudo ./target/release/wraith attach 4242 +# Monitor every process matching a name at once: +sudo ./target/release/wraith scan --match nginx + # Emit machine-readable events for your SIEM/pipeline: ./target/release/wraith run --json events.jsonl -- ./target ``` @@ -104,11 +107,14 @@ catches every stage and correlates them into one verdict. | `crash` | HIGH | Target took SIGSEGV/SIGILL/SIGBUS/SIGABRT — often a *failed* exploit worth investigating. | | `exploitation_chain` | CRITICAL | Multiple primitives correlated into a single high-confidence verdict. | | `sensitive_call` | INFO | Audit breadcrumb (with `--audit-sensitive`): a sensitive syscall from legitimate code. | +| `blocked` | CRITICAL | Enforcement neutralised the offending syscall in place (`--block`). | +| `killed` | CRITICAL | Enforcement killed the traced tree on confirmed exploitation (`--kill`). | Tuning: ``` --jit-critical treat anonymous-exec pages as HIGH (targets that never JIT) +--trust-region A-B treat the hex range [A,B) as legitimate JIT (repeatable) --no-stack-pivot disable the ROP stack-pivot heuristic --audit-sensitive log sensitive syscalls from legitimate code too --min floor: info|warn|high|critical (default warn) @@ -116,6 +122,81 @@ Tuning: --- +## Active response (enforcement) + +By default Wraith only *detects* — it never touches the tracee. Because it sits +at the syscall-entry stop, though, it is standing at the one moment the +offending syscall has not yet run, so it can also *stop* the attack: + +```bash +# Neutralise the offending syscall in place — the injected code's execve/connect +# returns an error and never takes effect; the process lives on so you can watch +# what it does next. +wraith run --block -- ./target + +# Terminate the whole traced tree the instant exploitation is confirmed, before +# the offending syscall executes. +wraith run --kill -- ./target +``` + +Both fire only on a **CRITICAL** verdict — injected code issuing a *sensitive* +syscall, or a correlated exploitation chain — so a HIGH/WARN anomaly (a JIT +page, a lone RWX mapping) never trips enforcement. `--block` overwrites the +syscall number at its entry stop so the kernel skips it and returns `-ENOSYS`; +`--kill` sends `SIGKILL` to every traced thread-group. + +### Trusting a JIT + +Language runtimes (Node.js, the JVM, .NET, browsers) execute JIT-compiled code +from anonymous executable pages and flip writable pages to executable as they +compile — behaviour that looks, syscall-for-syscall, like payload staging. When +you know where a runtime places its code, hand Wraith the range and it treats +provenance and W^X inside it as legitimate: + +```bash +# Trust one or more JIT arenas (repeat --trust-region as needed). +wraith run --trust-region 7f2a10000000-7f2a14000000 -- ./node-service +``` + +Trust applies to concrete addresses — the syscall's execution site and an +`mprotect` target page — so a JIT that respects W^X (map RW, write, `mprotect` +RX) is fully exempted, while a *direct* RWX allocation elsewhere is still +flagged. + +--- + +## Scanning many processes at once + +`run` and `attach` watch a single process tree. `scan` attaches to a whole set +of already-running processes in one shot — select them by name/cmdline +substring, or take everything you have permission to trace: + +```bash +# Attach to every process whose name or command line contains "nginx" +# (repeat --match to widen the net); follows the children they spawn too. +sudo wraith scan --match nginx --match redis + +# Attach to every process we're allowed to trace (heavy — see below). +sudo wraith scan --all --min high +``` + +One tracer drives all of them through a single reap loop, and enforcement +(`--block`/`--kill`) and the JSON stream work exactly as they do for a single +target. Caveats worth knowing: + +- **Needs privilege.** Attaching to a process you don't own requires + `CAP_SYS_PTRACE` (run as root); processes you can't attach to are skipped, not + fatal. +- **It has a cost.** Every traced process pays the two-stops-per-syscall + `ptrace` tax, so `--all` on a busy host is expensive — prefer `--match`. + Whole-system, near-zero-overhead monitoring is the eBPF backend's job (below). +- **Non-destructive by default.** Unlike `run`, `scan`/`attach` do *not* set + `PTRACE_O_EXITKILL`: stopping Wraith leaves every scanned process running. +- **Post-attach threads only.** As with `attach`, sibling threads that already + existed before Wraith attached aren't picked up automatically (see below). + +--- + ## Architecture Small, auditable, and dependency-light on purpose — a sensor others run should @@ -129,7 +210,7 @@ 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, thread-following loop) + ├─ tracer.rs the ptrace engine (spawn/attach/scan, thread-following, enforcement) └─ bin/ ├─ wraith.rs the CLI sensor ├─ benign.rs false-positive control target @@ -173,12 +254,20 @@ claim to be a finished EDR. provenance rule — that's what the stack-pivot heuristic is for, and why return-address validation and a shadow stack are on the roadmap. - **Legitimate JIT** (browsers, JVMs, .NET) runs code from anonymous - executable pages; hence `AnonExec` is WARN by default and configurable. - -Roadmap: eBPF backend · return-address/shadow-stack checks · ROP-chain length -heuristics · per-thread stack tracking · seizing pre-existing threads on attach -· per-process behavioural baselining · a policy DSL for allow-listing -legitimate JIT regions. + executable pages; hence `AnonExec` is WARN by default, `AnonExec` can be + raised with `--jit-critical`, and known JIT arenas can be exempted outright + with `--trust-region`. +- **Coverage vs. cost.** `scan --match`/`--all` can watch many processes at + once, but every traced process pays the two-stops-per-syscall `ptrace` tax, so + this suits a handful of high-value targets rather than a busy whole system. + Near-zero-overhead, watch-everything monitoring is the eBPF backend's job (see + below), not something the `ptrace` engine should attempt. + +Roadmap: eBPF backend (near-zero-overhead, system-wide) · return-address/ +shadow-stack checks · ROP-chain length heuristics · per-thread stack tracking · +seizing pre-existing threads on attach · per-process behavioural baselining · +richer JIT policy (auto-learn a runtime's arenas rather than hand-supplied +ranges). --- @@ -186,7 +275,7 @@ legitimate JIT regions. ```bash cargo build --release -cargo test # 28 unit + 6 end-to-end tests +cargo test # 36 unit + 9 end-to-end tests cargo clippy --all-targets ./demo.sh # side-by-side benign vs. exploitation run ``` diff --git a/demo.sh b/demo.sh index 5950627..aec6f77 100755 --- a/demo.sh +++ b/demo.sh @@ -53,11 +53,27 @@ mt_code=$? echo " -> wraith exit code: $mt_code" set -e +echo +echo "============================================================" +echo " 5/5 ENFORCEMENT — same payload, but Wraith intervenes" +echo "============================================================" +echo "--- --block: the injected socket() is neutralised (returns -ENOSYS)," +echo " the process survives so you can watch what it does next ---" +set +e +"$WRAITH" run --block -- "$SIM" +echo " -> wraith exit code: $?" +echo +echo "--- --kill: the traced tree is SIGKILLed before the payload runs ---" +"$WRAITH" run --kill -- "$SIM" +echo " -> wraith exit code: $?" +set -e + echo if [ "$code" -eq 3 ] && [ "$mt_code" -eq 3 ]; then echo "Demo OK: benign runs (single- and multi-threaded) were clean;" echo " injected-code execution was detected on the main thread AND" - echo " on a worker thread, and correlated into an exploitation chain." + echo " on a worker thread, correlated into an exploitation chain, and" + echo " (in --block/--kill) stopped before the payload's syscall ran." else echo "Demo WARNING: expected exit 3 from both simulator runs (got $code and $mt_code)." fi diff --git a/src/bin/wraith.rs b/src/bin/wraith.rs index 99974da..191733e 100644 --- a/src/bin/wraith.rs +++ b/src/bin/wraith.rs @@ -3,11 +3,17 @@ //! Usage: //! wraith run [OPTIONS] -- [args...] spawn and monitor a program //! wraith attach [OPTIONS] monitor a running process +//! wraith scan [OPTIONS] (--match | --all) monitor many running procs //! //! Options: //! --json also write JSONL events (`-` for stdout) //! --min minimum severity to report: info|warn|high|critical //! --jit-critical treat anonymous-exec origins as HIGH (no-JIT targets) +//! --trust-region A-B treat the hex range [A,B) as legitimate JIT (repeatable) +//! --block neutralise the offending syscall on detection +//! --kill SIGKILL the traced tree on detection +//! --match (scan) attach to processes whose name/cmdline matches +//! --all (scan) attach to every process we're allowed to trace //! --no-stack-pivot disable the ROP stack-pivot heuristic //! --audit-sensitive also log sensitive syscalls from legitimate code //! --quiet suppress the human event stream (use with --json) @@ -17,7 +23,7 @@ use std::fs::File; use std::io::{self, IsTerminal, Write}; use std::process::ExitCode; -use wraith::detect::Config; +use wraith::detect::{Config, Enforcement}; use wraith::event::{Event, Severity}; use wraith::tracer::Tracer; @@ -59,6 +65,8 @@ fn run(args: Vec) -> io::Result { let mut i = 0; let mut target: Vec = Vec::new(); let mut attach_pid: Option = None; + let mut scan_matches: Vec = Vec::new(); + let mut scan_all = false; while i < rest.len() { let a = &rest[i]; @@ -77,6 +85,19 @@ fn run(args: Vec) -> io::Result { opts.min = parse_sev(v)?; } "--jit-critical" => opts.cfg.jit_is_critical = true, + "--trust-region" => { + i += 1; + let v = rest.get(i).ok_or_else(|| bad("--trust-region needs a START-END range"))?; + opts.cfg.trusted_regions.push(parse_region(v)?); + } + "--block" => opts.cfg.enforcement = Enforcement::Block, + "--kill" => opts.cfg.enforcement = Enforcement::Kill, + "--match" => { + i += 1; + let v = rest.get(i).ok_or_else(|| bad("--match needs a substring"))?; + scan_matches.push(v.clone()); + } + "--all" => scan_all = true, "--no-stack-pivot" => opts.cfg.detect_stack_pivot = false, "--audit-sensitive" => opts.cfg.audit_sensitive = true, "--quiet" => opts.quiet = true, @@ -112,20 +133,54 @@ fn run(args: Vec) -> io::Result { } }; + let enforce_note = match opts.cfg.enforcement { + Enforcement::Observe => "", + Enforcement::Block => " [enforcing: block]", + Enforcement::Kill => " [enforcing: kill]", + }; + let tracer = match mode.as_str() { "run" => { if target.is_empty() { return Err(bad("no program to run; use: wraith run -- [args...]")); } - eprintln!("wraith: monitoring `{}` (provenance mode)", target.join(" ")); + eprintln!( + "wraith: monitoring `{}` (provenance mode){enforce_note}", + target.join(" ") + ); Tracer::spawn(&target, opts.cfg)? } "attach" => { let pid = attach_pid.ok_or_else(|| bad("attach needs a pid"))?; - eprintln!("wraith: attaching to pid {pid}"); + eprintln!("wraith: attaching to pid {pid}{enforce_note}"); Tracer::attach(pid, opts.cfg)? } - other => return Err(bad(&format!("unknown mode `{other}` (expected run|attach)"))), + "scan" => { + if !scan_all && scan_matches.is_empty() { + return Err(bad( + "scan needs a filter: --match (repeatable) or --all", + )); + } + let pids = enumerate_scan_pids(&scan_matches, scan_all); + if pids.is_empty() { + return Err(bad("scan matched no running processes")); + } + eprintln!( + "wraith: scanning {} process(es){}{enforce_note}", + pids.len(), + if scan_all { + " (--all)".to_string() + } else { + format!(" matching {scan_matches:?}") + }, + ); + Tracer::attach_many(&pids, opts.cfg)? + } + other => { + return Err(bad(&format!( + "unknown mode `{other}` (expected run|attach|scan)" + ))) + } }; let summary = tracer.run(&mut on_event)?; @@ -156,6 +211,63 @@ fn verdict(sev: Option) -> &'static str { } } +/// Walk `/proc` and return the PIDs to scan. A process is selected when `all` +/// is set, or when any `needle` is a substring of its `comm` or `cmdline`. The +/// scanner's own PID and PID 1 are always excluded; the attach itself (in +/// [`Tracer::attach_many`]) skips anything we lack permission to trace. +fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec { + let self_pid = std::process::id() as i32; + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return out; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(|n| n.parse::().ok()) else { + continue; + }; + if pid == self_pid || pid == 1 { + continue; + } + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); + // cmdline is NUL-separated argv; join it into one searchable string. + let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")) + .map(|b| String::from_utf8_lossy(&b).replace('\0', " ")) + .unwrap_or_default(); + if proc_matches(comm.trim(), cmdline.trim(), needles, all) { + out.push(pid); + } + } + out +} + +/// Pure predicate: does a process with this `comm`/`cmdline` pass the filter? +/// Split out from the `/proc` walk so it can be unit-tested without a live +/// process table. +fn proc_matches(comm: &str, cmdline: &str, needles: &[String], all: bool) -> bool { + if all { + return true; + } + needles + .iter() + .any(|n| comm.contains(n.as_str()) || cmdline.contains(n.as_str())) +} + +/// Parse a `START-END` hex range (each side optionally `0x`-prefixed) into a +/// half-open `[start, end)` pair, e.g. `7f0000030000-7f0000031000`. +fn parse_region(s: &str) -> io::Result<(u64, u64)> { + let (a, b) = s + .split_once('-') + .ok_or_else(|| bad("--trust-region wants START-END (hex), e.g. 7f00aa000000-7f00aa010000"))?; + let parse_hex = |x: &str| u64::from_str_radix(x.trim().trim_start_matches("0x"), 16); + let start = parse_hex(a).map_err(|_| bad("--trust-region START is not hex"))?; + let end = parse_hex(b).map_err(|_| bad("--trust-region END is not hex"))?; + if end <= start { + return Err(bad("--trust-region END must be greater than START")); + } + Ok((start, end)) +} + fn parse_sev(s: &str) -> io::Result { match s.to_ascii_lowercase().as_str() { "info" => Ok(Severity::Info), @@ -175,16 +287,75 @@ fn print_help() { "wraith — signature-free runtime exploitation detection\n\n\ USAGE:\n \ wraith run [OPTIONS] -- [args...] spawn and monitor a program\n \ -wraith attach [OPTIONS] monitor a running process\n\n\ +wraith attach [OPTIONS] monitor one running process\n \ +wraith scan [OPTIONS] (--match | --all) monitor many running processes\n\n\ OPTIONS:\n \ ---json also write JSONL events (`-` = stdout)\n \ ---min minimum severity to report: info|warn|high|critical (default: warn)\n \ ---jit-critical treat anonymous-exec origins as HIGH (targets that never JIT)\n \ ---no-stack-pivot disable the ROP stack-pivot heuristic\n \ ---audit-sensitive also log sensitive syscalls from legitimate code\n \ ---quiet suppress the human stream (pair with --json)\n \ --h, --help show this help\n\n\ +--json also write JSONL events (`-` = stdout)\n \ +--min minimum severity to report: info|warn|high|critical (default: warn)\n \ +--jit-critical treat anonymous-exec origins as HIGH (targets that never JIT)\n \ +--trust-region A-B treat the hex range [A,B) as legitimate JIT (repeatable)\n \ +--block neutralise the offending syscall on exploitation (CRITICAL)\n \ +--kill SIGKILL the traced tree on exploitation (CRITICAL)\n \ +--match (scan) attach to processes whose name/cmdline matches (repeatable)\n \ +--all (scan) attach to every process we're allowed to trace\n \ +--no-stack-pivot disable the ROP stack-pivot heuristic\n \ +--audit-sensitive also log sensitive syscalls from legitimate code\n \ +--quiet suppress the human stream (pair with --json)\n \ +-h, --help show this help\n\n\ +ENFORCEMENT:\n \ +Detection is always on. --block and --kill add active response and fire only on\n \ +a CRITICAL verdict (injected code issuing a sensitive syscall, or a correlated\n \ +chain): --block cancels that syscall in place; --kill terminates the tree.\n\n\ +SCAN:\n \ +`scan` attaches to a set of already-running processes at once. It needs\n \ +CAP_SYS_PTRACE (or ownership of the targets) and adds two stops per syscall to\n \ +each, so favour --match over --all on a busy host. Stopping wraith leaves the\n \ +scanned processes running.\n\n\ EXIT CODES:\n \ 0 clean/minor · 1 suspicious (HIGH) · 3 exploitation (CRITICAL) · 2 usage error\n" ); } + +#[cfg(test)] +mod tests { + use super::*; + + fn needles(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn all_matches_everything() { + assert!(proc_matches("anything", "", &[], true)); + assert!(proc_matches("", "", &needles(&["nomatch"]), true)); + } + + #[test] + fn match_by_comm_or_cmdline() { + let n = needles(&["nginx"]); + assert!(proc_matches("nginx", "/usr/sbin/nginx -g daemon off;", &n, false)); + assert!(proc_matches("worker", "/usr/sbin/nginx: worker process", &n, false)); + assert!(!proc_matches("sshd", "/usr/sbin/sshd -D", &n, false)); + } + + #[test] + fn empty_filter_matches_nothing() { + assert!(!proc_matches("anything", "any cmdline", &[], false)); + } + + #[test] + fn any_of_several_needles_matches() { + let n = needles(&["redis", "postgres"]); + assert!(proc_matches("postgres", "postgres: writer", &n, false)); + assert!(!proc_matches("mysqld", "/usr/sbin/mysqld", &n, false)); + } + + #[test] + fn region_parsing() { + assert_eq!(parse_region("1000-2000").unwrap(), (0x1000, 0x2000)); + assert_eq!(parse_region("0x1000-0x2000").unwrap(), (0x1000, 0x2000)); + assert!(parse_region("2000-1000").is_err()); + assert!(parse_region("nope").is_err()); + assert!(parse_region("1000-zzzz").is_err()); + } +} diff --git a/src/detect.rs b/src/detect.rs index bf0ff7a..9135464 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -13,6 +13,26 @@ use crate::maps::MemoryMap; use crate::provenance::{classify_rip, classify_rsp, Origin, Prot, StackState}; use crate::syscalls; +/// What Wraith does when it is confident it has caught exploitation (a +/// CRITICAL event). Detection is always on; enforcement decides whether Wraith +/// also intervenes to stop the attack in its tracks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Enforcement { + /// Detect and report only — never touch the tracee. The default, and the + /// only mode that is guaranteed side-effect-free. + #[default] + Observe, + /// Neutralise the offending syscall in place: at its entry stop the syscall + /// number is overwritten so the kernel skips it and returns an error, so + /// the injected code's `execve`/`connect`/… never actually runs. The + /// process keeps going, which is useful when you want it to survive (and + /// log what it does next) rather than die. + Block, + /// `SIGKILL` the whole traced tree the instant exploitation is confirmed, + /// before the offending syscall executes. + Kill, +} + /// Tunable behaviour. #[derive(Debug, Clone)] pub struct Config { @@ -24,6 +44,16 @@ pub struct Config { pub detect_stack_pivot: bool, /// Emit INFO breadcrumbs for sensitive syscalls from legitimate origins. pub audit_sensitive: bool, + /// Half-open `[start, end)` address ranges the operator vouches for as + /// legitimate JIT / runtime-generated code. A syscall whose instruction + /// pointer — or an `mmap`/`mprotect` whose target page — falls inside one + /// of these is exempt from the provenance and W^X rules, so a known JIT + /// engine can be monitored without drowning the operator in false + /// positives. Empty by default, so it changes nothing unless asked for. + pub trusted_regions: Vec<(u64, u64)>, + /// Whether (and how) to actively stop confirmed exploitation. See + /// [`Enforcement`]. + pub enforcement: Enforcement, } impl Default for Config { @@ -32,10 +62,21 @@ impl Default for Config { jit_is_critical: false, detect_stack_pivot: true, audit_sensitive: false, + trusted_regions: Vec::new(), + enforcement: Enforcement::Observe, } } } +impl Config { + /// True when `addr` sits inside an operator-trusted JIT region. + pub fn is_trusted(&self, addr: u64) -> bool { + self.trusted_regions + .iter() + .any(|&(start, end)| addr >= start && addr < end) + } +} + /// Register/argument snapshot at a syscall-entry stop. #[derive(Debug, Clone, Copy)] pub struct SyscallCtx { @@ -107,9 +148,11 @@ impl Detector { } } - // 1. Provenance of the syscall instruction itself. + // 1. Provenance of the syscall instruction itself. A trusted JIT + // region is exempt: the operator has vouched that runtime-generated + // code lives there, so a syscall from it is not evidence of injection. let origin = classify_rip(map, ctx.rip); - if origin.is_anomalous() { + if origin.is_anomalous() && !cfg.is_trusted(ctx.rip) { chain.foreign_origin = true; let sensitive = syscalls::is_sensitive(ctx.nr); let severity = foreign_severity(cfg, origin, sensitive); @@ -166,11 +209,15 @@ impl Detector { } // 3. W^X: pages requested/made writable-and-executable, or flipped - // from writable to executable (payload staging). + // from writable to executable (payload staging). A page inside a + // trusted JIT region is exempt — JIT engines legitimately map + // writable-then-executable code there. if syscalls::is_mmap(ctx.nr) || syscalls::is_mprotect(ctx.nr) { let prot = Prot::from_raw(ctx.args[2]); let addr = ctx.args[0]; - if prot.is_wx() { + if cfg.is_trusted(addr) { + // Operator-vouched JIT page; not payload staging. + } else if prot.is_wx() { chain.wx_staged = true; events.push(Event::now( ctx.pid, @@ -345,6 +392,55 @@ mod tests { && e.severity == Severity::Critical)); } + #[test] + fn trusted_region_suppresses_foreign_origin() { + // The RWX page at 0x7f0000030000 would normally be flagged, but if the + // operator vouches for it as a JIT region the syscall from it is silent. + let cfg = Config { + trusted_regions: vec![(0x7f0000030000, 0x7f0000031000)], + ..Config::default() + }; + let mut d = Detector::new(cfg); + let ev = d.on_syscall(1, &ctx(1, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + assert!(ev.is_empty(), "trusted JIT region must not raise a foreign-origin event"); + } + + #[test] + fn trusted_region_suppresses_wx_and_chain() { + // A JIT that maps RWX inside its trusted range, then runs a sensitive + // syscall from it, must not escalate — no W^X event, no chain. + let cfg = Config { + trusted_regions: vec![(0x7f0000030000, 0x7f0000031000)], + ..Config::default() + }; + let mut d = Detector::new(cfg); + let prot = (libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC) as u64; + let staging = d.on_syscall( + 1, + &ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000030000, 0x1000, prot, 0, 0, 0]), + &map(), + ); + assert!(staging.is_empty(), "W^X inside a trusted region must be exempt"); + let firing = d.on_syscall(1, &ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + assert!( + firing.is_empty(), + "a sensitive syscall from a trusted region must not escalate, got: {firing:?}" + ); + } + + #[test] + fn untrusted_page_outside_range_still_flagged() { + // A trusted range must not blanket-trust the whole address space: the + // RWX page outside it is still caught. + let cfg = Config { + trusted_regions: vec![(0x400000, 0x401000)], + ..Config::default() + }; + let mut d = Detector::new(cfg); + let ev = d.on_syscall(1, &ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + assert!(ev.iter().any(|e| e.kind == Kind::ForeignOriginSyscall)); + } + #[test] fn chain_reported_only_once() { let mut d = Detector::new(Config::default()); diff --git a/src/event.rs b/src/event.rs index 6fbe278..c0ae33d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -48,6 +48,11 @@ pub enum Kind { /// The target took a fatal signal (SIGSEGV/SIGILL/SIGBUS/SIGABRT) — often /// the visible symptom of a memory-corruption attempt that missed. Crash, + /// Enforcement neutralised the offending syscall in place (`--block`): the + /// kernel was told to skip it and return an error. + Blocked, + /// Enforcement killed the traced tree on confirmed exploitation (`--kill`). + Killed, } impl Kind { @@ -60,6 +65,8 @@ impl Kind { Kind::SensitiveCall => "sensitive_call", Kind::ExploitationChain => "exploitation_chain", Kind::Crash => "crash", + Kind::Blocked => "blocked", + Kind::Killed => "killed", } } } diff --git a/src/tracer.rs b/src/tracer.rs index 652a506..766ceb4 100644 --- a/src/tracer.rs +++ b/src/tracer.rs @@ -24,11 +24,11 @@ use std::fs; use std::io; use nix::sys::ptrace; -use nix::sys::signal::Signal; +use nix::sys::signal::{kill, Signal}; use nix::sys::wait::{waitpid, WaitStatus}; use nix::unistd::{execvp, fork, ForkResult, Pid}; -use crate::detect::{Config, Detector, SyscallCtx}; +use crate::detect::{Config, Detector, Enforcement, SyscallCtx}; use crate::event::{Event, Kind, Severity}; use crate::maps::MemoryMap; use crate::syscalls; @@ -55,6 +55,15 @@ 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 { @@ -75,17 +84,26 @@ impl Summary { } } -/// How the tracee was obtained, so `run` knows how to resume it. +/// How the tracee(s) were obtained, so `run` knows how to seed and resume them. enum Target { - /// We forked and exec'd it; it is stopped at the post-exec SIGTRAP. + /// 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. Spawned(Pid), - /// We attached to an already-running process. + /// We attached to one already-running process. We do not own it, so it is + /// left running if we exit. Attached(Pid), + /// We attached to a whole set of already-running processes (`scan` mode). + /// Peers with no distinguished root; all left running if we exit. + ScanAttached(Vec), } 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, } impl Tracer { @@ -128,10 +146,13 @@ impl Tracer { ))); } } - set_options(child)?; + // 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, }) } } @@ -150,41 +171,86 @@ impl Tracer { ))); } } - set_options(child)?; + // 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, }) } - fn pid(&self) -> Pid { - match self.target { - Target::Spawned(p) => p, - Target::Attached(p) => p, + /// Attach to a whole set of already-running processes at once (`scan` + /// mode). Each pid is attached, waited for its stop, and configured + /// independently; a pid we cannot attach to (permission, or it exited + /// between enumeration and attach) is skipped rather than failing the + /// whole scan. Errors only if *nothing* could be attached. + pub fn attach_many(pids: &[i32], cfg: Config) -> io::Result { + let mut attached = Vec::new(); + for &pid in pids { + let child = Pid::from_raw(pid); + if ptrace::attach(child).is_err() { + continue; // not ours / gone / already traced + } + match waitpid(child, None) { + Ok(WaitStatus::Stopped(_, _)) => {} + _ => { + let _ = ptrace::detach(child, None); + continue; + } + } + // Never kill-on-exit under scan: stopping the monitor must not take + // down every process it was watching. + if set_options(child, false).is_err() { + let _ = ptrace::detach(child, None); + continue; + } + attached.push(child); } + if attached.is_empty() { + return Err(io::Error::other( + "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, + }) } /// Run the trace to completion — following every thread and child the - /// target spawns — invoking `on_event` for every detection. The trace ends - /// once the last tracee has exited. + /// target(s) spawn — invoking `on_event` for every detection. The trace + /// ends once the last tracee has exited. pub fn run(mut self, mut on_event: F) -> io::Result where F: FnMut(&Event), { - let root = self.pid(); let mut summary = Summary::default(); + // 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 + // peers and no distinguished root. + let (initial, root_pid): (Vec, Option) = match &self.target { + Target::Spawned(p) | Target::Attached(p) => (vec![*p], Some(p.as_raw())), + Target::ScanAttached(pids) => (pids.clone(), None), + }; + // Per-thread phase, and per-address-space (tgid) cached maps. Threads // that share memory share an `AddrSpace` entry. let mut threads: HashMap = HashMap::new(); let mut spaces: HashMap = HashMap::new(); - let root_tgid = read_tgid(root.as_raw()); - threads.insert(root.as_raw(), ThreadState { at_entry: true, tgid: root_tgid }); - spaces.insert(root_tgid, AddrSpace::new()); - - // Kick the root tracee toward its first syscall stop. - ptrace::syscall(root, None).map_err(nix_err)?; + // Seed every initial tracee and kick each toward its first syscall stop. + for p in &initial { + 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); + ptrace::syscall(*p, None).map_err(nix_err)?; + } loop { // Reap any tracee. `ECHILD` means every thread and child has gone. @@ -214,7 +280,7 @@ impl Tracer { match status { WaitStatus::Exited(_, code) => { threads.remove(&raw); - if raw == root.as_raw() { + if Some(raw) == root_pid { summary.exit_code = Some(code); } if threads.is_empty() { @@ -223,7 +289,7 @@ impl Tracer { } WaitStatus::Signaled(_, sig, _) => { threads.remove(&raw); - if raw == root.as_raw() { + if Some(raw) == root_pid { summary.term_signal = Some(sig as i32); } if threads.is_empty() { @@ -232,21 +298,47 @@ impl Tracer { } WaitStatus::PtraceSyscall(_) => { let tgid = threads[&raw].tgid; + let mut killed = false; if threads[&raw].at_entry { summary.syscalls_seen += 1; let space = spaces.entry(tgid).or_insert_with(AddrSpace::new); - let nr = self.inspect(who, tgid, space, &mut summary, &mut on_event); - if let Some(nr) = nr { + let step = self.inspect(who, tgid, space, &mut summary, &mut on_event); + if let Some(nr) = step.nr { // A memory op by any thread can change the shared // address space; invalidate the whole group's map. if syscalls::is_memory_op(nr) { 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 + && step.max_severity == Some(Severity::Critical) + { + match self.enforcement { + Enforcement::Block => { + self.block_syscall(who, &mut summary, &mut on_event) + } + Enforcement::Kill => { + self.kill_tree(who, &threads, &mut summary, &mut on_event); + killed = true; + } + Enforcement::Observe => {} + } + } } let ts = threads.get_mut(&raw).unwrap(); ts.at_entry = !ts.at_entry; - ptrace::syscall(who, None).map_err(nix_err)?; + // Resume. After a kill the tracee is stopped with a pending + // SIGKILL; restarting it lets the kernel deliver it, and the + // call racing with the process's death is expected, so a + // failure here is not fatal to the trace. + if killed { + let _ = ptrace::syscall(who, None); + } else { + ptrace::syscall(who, None).map_err(nix_err)?; + } } WaitStatus::Stopped(_, sig) => { // A real signal was delivered to the tracee (not a syscall @@ -268,8 +360,8 @@ impl Tracer { } /// Inspect a single syscall-entry stop for thread `who` in address space - /// `space`. Returns the syscall number, or `None` if registers could not - /// be read. + /// `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, @@ -277,11 +369,13 @@ impl Tracer { space: &mut AddrSpace, summary: &mut Summary, on_event: &mut F, - ) -> Option + ) -> Inspection where F: FnMut(&Event), { - let regs = ptrace::getregs(who).ok()?; + 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` @@ -292,7 +386,9 @@ impl Tracer { space.dirty = false; } } - let current = space.map.as_ref()?; + 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); @@ -304,11 +400,85 @@ impl Tracer { 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); on_event(&ev); } - Some(nr) + 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, on_event: &mut F) + where + F: FnMut(&Event), + { + 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); + on_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, + on_event: &mut F, + ) where + F: FnMut(&Event), + { + 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); + on_event(&ev); } fn on_signal(&self, pid: Pid, sig: Signal, summary: &mut Summary, on_event: &mut F) @@ -342,23 +512,23 @@ impl Tracer { } } -fn set_options(pid: Pid) -> io::Result<()> { +fn set_options(pid: Pid, kill_on_exit: bool) -> io::Result<()> { use ptrace::Options; // TRACESYSGOOD lets us tell syscall stops apart from signal stops. - // EXITKILL guarantees the tracee dies with us instead of being left - // orphaned and stopped if the tracer crashes. // TRACE{CLONE,FORK,VFORK} make every thread and child the target spawns a // tracee too, and are inherited by those descendants — so following the // whole process tree needs setting them only on the root. - ptrace::setoptions( - pid, - Options::PTRACE_O_TRACESYSGOOD - | Options::PTRACE_O_EXITKILL - | Options::PTRACE_O_TRACECLONE - | Options::PTRACE_O_TRACEFORK - | Options::PTRACE_O_TRACEVFORK, - ) - .map_err(nix_err) + let mut opts = Options::PTRACE_O_TRACESYSGOOD + | Options::PTRACE_O_TRACECLONE + | Options::PTRACE_O_TRACEFORK + | Options::PTRACE_O_TRACEVFORK; + // EXITKILL ties the tracee's life to ours — right for a process we spawned + // and own, but wrong when merely observing someone else's process (attach / + // scan): stopping the monitor must not kill what it was watching. + if kill_on_exit { + opts |= Options::PTRACE_O_EXITKILL; + } + ptrace::setoptions(pid, opts).map_err(nix_err) } /// The pid a [`WaitStatus`] refers to, if it carries one. diff --git a/tests/integration.rs b/tests/integration.rs index a8b2eb1..839baf0 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -184,3 +184,70 @@ fn detection_survives_min_severity_gate() { .collect(); assert!(!criticals.is_empty(), "at least one CRITICAL event expected"); } + +#[test] +fn kill_enforcement_terminates_on_exploitation() { + // In --kill mode the simulator must be SIGKILLed the moment it issues the + // injected socket() — it never gets to exit cleanly on its own. + use wraith::detect::Enforcement; + let bin = env!("CARGO_BIN_EXE_shellcode-sim"); + let cfg = Config { enforcement: Enforcement::Kill, ..Config::default() }; + let Some((events, summary)) = trace(bin, cfg) else { + return; // ptrace unavailable — skip + }; + + assert!( + events.iter().any(|e| e.kind == Kind::Killed), + "expected a `killed` enforcement event, got: {:?}", + events.iter().map(|e| e.to_line(false)).collect::>() + ); + // Killed by SIGKILL (9) before it could exit normally. + assert_eq!( + summary.term_signal, + Some(libc::SIGKILL), + "target should be terminated by SIGKILL, summary: {summary:?}" + ); + assert_eq!(summary.exit_code, None, "a killed target has no clean exit code"); + assert_eq!(summary.max_severity, Some(Severity::Critical)); +} + +#[test] +fn block_enforcement_neutralizes_but_lets_process_live() { + // In --block mode the injected socket() is cancelled (returns -ENOSYS), so + // the simulator survives and exits cleanly, but a `blocked` event proves + // the exploit syscall was neutralised. + use wraith::detect::Enforcement; + let bin = env!("CARGO_BIN_EXE_shellcode-sim"); + let cfg = Config { enforcement: Enforcement::Block, ..Config::default() }; + let Some((events, summary)) = trace(bin, cfg) else { + return; + }; + + assert!( + events.iter().any(|e| e.kind == Kind::Blocked), + "expected a `blocked` enforcement event, got: {:?}", + events.iter().map(|e| e.to_line(false)).collect::>() + ); + // The exploitation was still detected... + assert_eq!(summary.max_severity, Some(Severity::Critical)); + // ...but the process was allowed to finish rather than killed. + assert_eq!( + summary.exit_code, + Some(0), + "blocked target should run to a clean exit, summary: {summary:?}" + ); + assert_eq!(summary.term_signal, None); +} + +#[test] +fn observe_mode_never_intervenes() { + // The default mode must not emit enforcement events — detection only. + let bin = env!("CARGO_BIN_EXE_shellcode-sim"); + let Some((events, _)) = trace(bin, Config::default()) else { + return; + }; + assert!( + !events.iter().any(|e| matches!(e.kind, Kind::Blocked | Kind::Killed)), + "observe mode must not enforce" + ); +}