From 31e7f4a1ffb0ea4b119f3d531ba41b7b805e17cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:20:39 +0000 Subject: [PATCH] Harden detection coverage, provenance hot path, and operator safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acts on a security & efficiency review of the sensor. Grouped by theme: Detection coverage - Expand the sensitive-syscall set to modern post-exploitation and evasion primitives: memfd_create, process_vm_read/writev, bpf, seccomp, userfaultfd, setns/unshare/mount, io_uring_setup/enter, sendto/sendmsg/sendmmsg, pidfd_getfd, init_module/finit_module, kexec_load, prctl, chroot, accept4. These only ever escalate a call already coming from injected code (or an opt-in --audit-sensitive breadcrumb), so legitimate programs stay silent. - Track recvmmsg as a first-stage input channel. Robustness / correctness - maps: drop degenerate (start >= end) regions and keep the map sorted, then answer region_at with an O(log n) binary search instead of a linear scan — it runs for rip, rsp, and memory-op targets on every syscall. - tracer: replace panicking HashMap indexing in the wait loop with fallible lookups so a phantom stop can never crash the sensor. - engine: free a process's cached memory map and detector chain state when its last thread exits, bounding memory on long traces of forking targets. Terminal / output safety - Neutralise ANSI escape sequences in attacker-influenced display fields (process comm names, mapped-file labels) before they reach the TTY, in both the log line and the dashboard. - Surface JSON-sink write failures instead of dropping them: warn once on the stream, banner in the TUI. A dropped detection is the worst outcome. - The --json file sink now appends rather than truncates, preserving prior evidence across re-runs. CLI / portability - Add --version, a --max-targets cap for scan, a /proc preflight check, and a warning when --trust-region spans an implausibly large area. - Fail the build with a clear message on non-x86-64 targets. - syscalls::name returns Cow<'static, str>, avoiding a heap allocation per known syscall on the hot path; remove dead is_payload_capable. Adds 12 unit tests; all existing unit and ptrace integration tests still pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016ATUW6fbK4Spc3fJKL6se1 --- README.md | 3 +- src/bin/wraith.rs | 78 ++++++++++++++++++++++++++++--- src/detect.rs | 28 ++++++++++++ src/engine.rs | 35 +++++++++++++- src/event.rs | 35 +++++++++++++- src/lib.rs | 10 ++++ src/maps.rs | 74 ++++++++++++++++++++++++++++-- src/provenance.rs | 7 +-- src/syscalls.rs | 114 +++++++++++++++++++++++++++++++++++++++++----- src/tracer.rs | 26 ++++++++--- src/ui.rs | 71 +++++++++++++++++++++++++++-- 11 files changed, 439 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 9043061..1763890 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ catches every stage and correlates them into one verdict. | Event | Severity | Meaning | |---|---|---| -| `foreign_origin_syscall` | HIGH / CRITICAL | A syscall issued from non-code memory (heap/stack/anon/RWX). CRITICAL when the syscall is *sensitive* (execve, connect, ptrace, …). | +| `foreign_origin_syscall` | HIGH / CRITICAL | A syscall issued from non-code memory (heap/stack/anon/RWX). CRITICAL when the syscall is *sensitive* — program execution (`execve`, `memfd_create`), networking/exfil (`connect`, `sendto`), process tampering (`ptrace`, `process_vm_writev`), defence evasion (`seccomp`, `bpf`, `prctl`), or container escape (`setns`, `unshare`), among others. | | `wx_violation` | HIGH | `mmap`/`mprotect` requesting writable **and** executable memory. | | `wx_transition` | HIGH | A writable page being flipped to executable — payload staging. | | `stack_pivot` | HIGH | Stack pointer sitting in the heap or a file image at syscall time — a ROP indicator. | @@ -123,6 +123,7 @@ Tuning: --ui live full-screen dashboard instead of the log stream --no-stack-pivot disable the ROP stack-pivot heuristic --audit-sensitive log sensitive syscalls from legitimate code too +--max-targets N (scan) attach to at most N matching processes --min floor: info|warn|high|critical (default warn) ``` diff --git a/src/bin/wraith.rs b/src/bin/wraith.rs index f9053ea..caa7774 100644 --- a/src/bin/wraith.rs +++ b/src/bin/wraith.rs @@ -6,7 +6,7 @@ //! wraith scan [OPTIONS] (--match | --all) monitor many running procs //! //! Options: -//! --json also write JSONL events (`-` for stdout) +//! --json also write JSONL events (`-` for stdout; a FILE is appended) //! --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) @@ -14,13 +14,15 @@ //! --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 +//! --max-targets (scan) attach to at most N matching processes //! --ui live full-screen dashboard instead of the log stream //! --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) +//! -V, --version print the wraith version and exit //! -h, --help show this help -use std::fs::File; +use std::fs::OpenOptions; use std::io::{self, IsTerminal, Write}; use std::process::ExitCode; @@ -54,6 +56,13 @@ fn run(args: Vec) -> io::Result { return Ok(ExitCode::SUCCESS); } + // Version is essential for incident response: a log is only as useful as + // knowing exactly which build produced it. + if args[0] == "-V" || args[0] == "--version" { + println!("wraith {}", env!("CARGO_PKG_VERSION")); + return Ok(ExitCode::SUCCESS); + } + let mode = args[0].clone(); let rest = &args[1..]; @@ -71,6 +80,7 @@ fn run(args: Vec) -> io::Result { let mut attach_pid: Option = None; let mut scan_matches: Vec = Vec::new(); let mut scan_all = false; + let mut max_targets: Option = None; while i < rest.len() { let a = &rest[i]; @@ -102,6 +112,16 @@ fn run(args: Vec) -> io::Result { scan_matches.push(v.clone()); } "--all" => scan_all = true, + "--max-targets" => { + i += 1; + let v = rest.get(i).ok_or_else(|| bad("--max-targets needs a number"))?; + max_targets = Some( + v.parse::() + .ok() + .filter(|&n| n > 0) + .ok_or_else(|| bad("--max-targets must be a positive integer"))?, + ); + } "--ui" | "--dashboard" => opts.ui = true, "--no-stack-pivot" => opts.cfg.detect_stack_pivot = false, "--audit-sensitive" => opts.cfg.audit_sensitive = true, @@ -122,7 +142,11 @@ fn run(args: Vec) -> io::Result { let json_sink: Option> = match opts.json.as_deref() { None => None, Some("-") => Some(Box::new(io::stdout())), - Some(path) => Some(Box::new(File::create(path)?)), + // Append rather than truncate: a JSONL evidence log must never lose prior + // detections just because the sensor is re-run against the same file. + Some(path) => Some(Box::new( + OpenOptions::new().create(true).append(true).open(path)?, + )), }; let min = opts.min; let quiet = opts.quiet; @@ -135,6 +159,15 @@ fn run(args: Vec) -> io::Result { )); } + // Everything downstream — memory maps, tgids, comm names — reads /proc. Fail + // early and clearly if it isn't mounted/readable (some restricted or rootless + // containers) instead of deep inside the trace with a cryptic error. + if std::fs::metadata("/proc/self/maps").is_err() { + return Err(bad( + "/proc is not available or readable — Wraith needs a mounted procfs to inspect memory maps", + )); + } + let enforce_note = match enforcement { Enforcement::Observe => "", Enforcement::Block => " [enforcing: block]", @@ -173,10 +206,22 @@ fn run(args: Vec) -> io::Result { "scan needs a filter: --match (repeatable) or --all", )); } - let pids = enumerate_scan_pids(&scan_matches, scan_all); + let mut pids = enumerate_scan_pids(&scan_matches, scan_all); if pids.is_empty() { return Err(bad("scan matched no running processes")); } + // Attaching adds two ptrace stops per syscall to every target, so a + // broad `--all` on a busy host can bog the system down. `--max-targets` + // caps how many we take on. + if let Some(max) = max_targets { + if pids.len() > max { + eprintln!( + "wraith: --max-targets {max}: watching {max} of {} matching process(es)", + pids.len() + ); + pids.truncate(max); + } + } let filter = if scan_all { "--all".to_string() } else { @@ -207,13 +252,22 @@ fn run(args: Vec) -> io::Result { } else { // Plain stream: colored log lines to stderr, optional JSONL to the sink. let mut json_sink = json_sink; + let mut json_broken = false; let mut on_event = |ev: &Event| { if ev.severity >= min { if !quiet { let _ = writeln!(io::stderr(), "{}", ev.to_line(color)); } if let Some(sink) = json_sink.as_mut() { - let _ = writeln!(sink, "{}", ev.to_json()); + // Don't swallow a failed write: for a security sensor a lost + // detection is the worst outcome. Warn once so a full disk or + // broken pipe is visible without flooding stderr. + if writeln!(sink, "{}", ev.to_json()).is_err() && !json_broken { + json_broken = true; + eprintln!( + "wraith: warning: writing to the JSON sink failed — later events may be missing from it" + ); + } } } }; @@ -333,6 +387,16 @@ fn parse_region(s: &str) -> io::Result<(u64, u64)> { if end <= start { return Err(bad("--trust-region END must be greater than START")); } + // A trusted region exempts its whole span from provenance and W^X checks, so + // an accidentally huge range (a typo like `0-ffffffffffffffff`) would quietly + // blind the sensor. Real JIT arenas are megabytes; warn well above that. + const HUGE_SPAN: u64 = 1 << 32; // 4 GiB + if end - start > HUGE_SPAN { + eprintln!( + "wraith: warning: --trust-region {s} spans {} bytes — this exempts a very large area from detection; double-check the range", + end - start + ); + } Ok((start, end)) } @@ -358,7 +422,7 @@ wraith run [OPTIONS] -- [args...] spawn and monitor a program\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 \ +--json also write JSONL events (`-` = stdout; a FILE is appended, not truncated)\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 \ @@ -366,10 +430,12 @@ OPTIONS:\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 \ +--max-targets (scan) attach to at most N matching processes\n \ --ui live full-screen dashboard (per-process rows + event feed)\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 \ +-V, --version print the wraith version and exit\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 \ diff --git a/src/detect.rs b/src/detect.rs index 9135464..7b5483d 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -275,6 +275,14 @@ impl Detector { events } + + /// Forget all accumulated evidence for an address space whose last thread + /// has exited. Without this the per-process chain map grows for the life of + /// the trace — a slow leak when following a long-lived target that forks or + /// spawns many short-lived children. A recycled key simply starts fresh. + pub fn retire(&mut self, proc_key: i32) { + self.chains.remove(&proc_key); + } } fn foreign_severity(cfg: &Config, origin: Origin, sensitive: bool) -> Severity { @@ -451,4 +459,24 @@ mod tests { assert!(first.iter().any(|e| e.kind == Kind::ExploitationChain)); assert!(!second.iter().any(|e| e.kind == Kind::ExploitationChain)); } + + #[test] + fn retire_frees_chain_state_and_resets_correlation() { + let mut d = Detector::new(Config::default()); + let prot = (libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC) as u64; + // Stage a W^X milestone so the address space has accumulated evidence. + d.on_syscall(1, &ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); + assert!(d.chains.contains_key(&1), "staging should record chain state"); + + d.retire(1); + assert!(!d.chains.contains_key(&1), "retire must free the chain entry"); + + // A recycled key starts clean: a lone foreign-origin sensitive syscall + // has no prior staging to correlate with, so no chain is reported. + let ev = d.on_syscall(1, &ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + assert!( + !ev.iter().any(|e| e.kind == Kind::ExploitationChain), + "retired state must not resurrect an exploitation chain" + ); + } } diff --git a/src/engine.rs b/src/engine.rs index 250157c..019adc0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -311,12 +311,22 @@ impl Engine { 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. + /// Flip a process's live-stats row to "exited" and release the state that is + /// no longer needed now that its last thread is gone. A backend calls this + /// once the last thread of `tgid` has exited. + /// + /// The cached memory map (by far the largest per-process allocation — a + /// `Vec` of every mapped region, each with its path) and the detector's + /// per-process chain accumulator are dropped, so tracing a long-lived target + /// that spawns many short-lived children no longer leaks memory without + /// bound. The lightweight stats row is deliberately kept (only flipped to + /// "exited") so the UI can still show the process that just finished. pub fn mark_dead(&mut self, tgid: i32) { if let Some(s) = self.stats.get_mut(&tgid) { s.alive = false; } + self.spaces.remove(&tgid); + self.detector.retire(tgid); } /// Push a live snapshot to the reporter. When `force` is false the paint is @@ -368,3 +378,24 @@ fn read_comm(pid: i32) -> String { _ => pid.to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::Config; + + #[test] + fn dead_process_frees_space_but_keeps_stats_row() { + let mut e = Engine::new(Config::default()); + e.register_space(4242); + assert!(e.spaces.contains_key(&4242)); + assert!(e.stats.contains_key(&4242)); + + e.mark_dead(4242); + + // The stats row survives (flipped to exited) so the UI can show it... + assert_eq!(e.stats.get(&4242).map(|s| s.alive), Some(false)); + // ...but the heavy cached map is released, bounding memory on long traces. + assert!(!e.spaces.contains_key(&4242), "dead space must be freed"); + } +} diff --git a/src/event.rs b/src/event.rs index c0ae33d..01246c4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -153,12 +153,23 @@ impl Event { self.kind.as_str(), self.syscall, self.rip, - self.origin, + sanitize_display(&self.origin), self.detail, ) } } +/// Strip terminal control characters from an untrusted string before it reaches +/// a TTY. A region's label comes from the mapped file's path and a process's +/// name comes from `/proc//comm` — both attacker-influenceable — so without +/// this a hostile process could smuggle ANSI escape sequences into the operator's +/// display to rewrite or hide a detection row. Printable Unicode is preserved; +/// only control characters (C0, C1, DEL) are removed. The JSON sink neutralises +/// the same bytes differently, escaping them numerically (see [`json_escape`]). +pub fn sanitize_display(s: &str) -> String { + s.chars().filter(|c| !c.is_control()).collect() +} + /// Escape the characters that would break a JSON string literal. fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -215,4 +226,26 @@ mod tests { assert!(l.contains("wx_violation")); assert!(l.contains("mprotect")); } + + #[test] + fn sanitize_strips_control_but_keeps_printable() { + // Control characters (here a clear-screen ANSI sequence) are removed; + // the surrounding printable text — including non-ASCII — survives. + assert_eq!(sanitize_display("evil\x1b[2Jname"), "evil[2Jname"); + assert_eq!(sanitize_display("café-server"), "café-server"); + assert_eq!(sanitize_display("a\r\n\tb"), "ab"); + } + + #[test] + fn line_neutralizes_hostile_origin_label() { + // A mapped-file label carrying an escape sequence must not reach the TTY + // as a live escape — the raw clear-screen bytes are gone from the line. + let e = Event::now( + 1, Severity::High, Kind::ForeignOriginSyscall, "execve", + 0x1000, 0x2000, "eviltool\x1b[2J\x1b[H", "injected code is now acting", + ); + let l = e.to_line(false); + assert!(!l.contains('\x1b'), "escape from origin must be stripped: {l:?}"); + assert!(l.contains("eviltool"), "sanitised label text should remain: {l:?}"); + } } diff --git a/src/lib.rs b/src/lib.rs index 05373c6..8d57408 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,16 @@ //! - [`tracer`] — the `ptrace` [`Backend`] that drives a target. //! - [`ui`] — the live terminal dashboard (`--ui`). +// Wraith decodes syscall arguments straight out of the x86-64 `user_regs_struct` +// (`orig_rax`, `rip`, `rsp`, `rdi`…) and keys off x86-64 syscall numbers. Those +// are architecture-specific, so refuse to build anywhere else with a clear +// message rather than failing deep inside the tracer with a missing-field error. +#[cfg(not(target_arch = "x86_64"))] +compile_error!( + "Wraith currently supports x86-64 Linux only: its syscall table and register \ + decoding are x86-64-specific. Build on an x86-64 host." +); + pub mod detect; pub mod engine; pub mod event; diff --git a/src/maps.rs b/src/maps.rs index af61d36..464eebe 100644 --- a/src/maps.rs +++ b/src/maps.rs @@ -104,14 +104,21 @@ impl MemoryMap { /// aborting the trace, since a single unexpected line should never blind /// the detector. pub fn parse(raw: &str) -> Self { - let regions = raw.lines().filter_map(parse_line).collect(); + let mut regions: Vec = raw.lines().filter_map(parse_line).collect(); + // The kernel already emits `/proc//maps` sorted by start address, + // but sort defensively so the binary search in `region_at` stays correct + // even against an oddly-ordered source (e.g. a FUSE-mounted procfs). + regions.sort_by_key(|r| r.start); Self { regions } } - /// The region containing `addr`, if any. Regions never overlap, so the - /// first hit is the answer. + /// The region containing `addr`, if any. Regions are sorted by start address + /// and never overlap, so a binary search for the first region ending past + /// `addr` finds the only candidate in `O(log n)` — this is on the hot path, + /// queried for `rip`, `rsp`, and memory-op targets on every syscall. pub fn region_at(&self, addr: u64) -> Option<&Region> { - self.regions.iter().find(|r| r.contains(addr)) + let idx = self.regions.partition_point(|r| r.end <= addr); + self.regions.get(idx).filter(|r| r.contains(addr)) } pub fn regions(&self) -> &[Region] { @@ -168,6 +175,11 @@ fn parse_line(line: &str) -> Option { let (start_s, end_s) = range.split_once('-')?; let start = u64::from_str_radix(start_s, 16).ok()?; let end = u64::from_str_radix(end_s, 16).ok()?; + // A real region is a non-empty half-open range; drop anything degenerate so + // it can never poison the sorted-and-disjoint invariant `region_at` relies on. + if start >= end { + return None; + } let (read, write, exec, shared) = parse_perms(perms)?; Some(Region { @@ -229,4 +241,58 @@ mod tests { assert!(libc.exec && !libc.write); assert_eq!(libc.label(), "libc.so.6"); } + + #[test] + fn binary_search_matches_linear_scan() { + // The O(log n) `region_at` must agree with a naive linear scan for every + // address: region starts and ends, interiors, boundaries, and gaps. + let m = MemoryMap::parse(SAMPLE); + let linear = |addr: u64| m.regions().iter().find(|r| r.contains(addr)); + let mut probes = vec![0u64, 1, u64::MAX]; + for r in m.regions() { + probes.extend([ + r.start.wrapping_sub(1), + r.start, + r.start + 1, + r.end.wrapping_sub(1), + r.end, + r.end + 1, + ]); + } + for addr in probes { + assert_eq!( + m.region_at(addr).map(|r| r.start), + linear(addr).map(|r| r.start), + "mismatch at {addr:#x}" + ); + } + } + + #[test] + fn regions_are_sorted_even_from_unordered_input() { + // A source that lists regions out of order must still yield a sorted map + // so the binary search holds. + let unordered = "\ +7f0000000000-7f0000001000 r-xp 0 08:01 1 /b +5500000000-5500001000 r-xp 0 08:01 2 /a"; + let m = MemoryMap::parse(unordered); + let starts: Vec = m.regions().iter().map(|r| r.start).collect(); + assert_eq!(starts, [0x5500000000, 0x7f0000000000]); + assert!(m.region_at(0x5500000500).is_some()); + assert!(m.region_at(0x7f0000000500).is_some()); + } + + #[test] + fn degenerate_and_malformed_lines_are_dropped() { + // An empty range (start == end) and a truncated line must be skipped + // rather than parsed into a poisonous region. + let bad = "\ +55f000001000-55f000001000 r-xp 0 08:01 1 /zero-length +55f000002000-55f000003000 r-xp 0 08:01 2 /ok +this is not a maps line"; + let m = MemoryMap::parse(bad); + assert_eq!(m.regions().len(), 1); + assert!(m.region_at(0x55f000001000).is_none()); + assert!(m.region_at(0x55f000002500).is_some()); + } } diff --git a/src/provenance.rs b/src/provenance.rs index 60511d0..b112e8a 100644 --- a/src/provenance.rs +++ b/src/provenance.rs @@ -7,7 +7,7 @@ //! fingerprint of code that was injected or reached through corrupted control //! flow. -use crate::maps::{MemoryMap, Region, RegionKind}; +use crate::maps::{MemoryMap, RegionKind}; /// Where a syscall instruction was executing from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -152,11 +152,6 @@ impl Prot { } } -/// Convenience: is this region one an attacker would stage a payload in? -pub fn is_payload_capable(region: &Region) -> bool { - region.write && region.exec -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/syscalls.rs b/src/syscalls.rs index b7520fa..3afff07 100644 --- a/src/syscalls.rs +++ b/src/syscalls.rs @@ -2,27 +2,65 @@ //! post-exploitation behaviour plus the memory-management calls we must track //! to keep the memory map fresh. Unknown numbers render as `syscall_`. +use std::borrow::Cow; + /// The syscalls an attacker reaches for after gaining control: spawning /// programs, touching the filesystem, opening the network, changing identity, -/// or tampering with other processes. Emitting these from a foreign origin is -/// the difference between "something is odd" and "you are being popped". +/// tampering with other processes, or disabling the kernel's own defences. +/// Emitting these from a foreign origin is the difference between "something is +/// odd" and "you are being popped". +/// +/// Membership only ever *raises* the stakes of a call already coming from +/// injected code (or surfaces an INFO breadcrumb under `--audit-sensitive`); a +/// legitimate program issuing any of these from its own `.text` is still +/// silent, so the set can be generous without manufacturing false positives. pub const SENSITIVE: &[u64] = &[ + // Code execution / launching new programs. 59, // execve 322, // execveat + 319, // memfd_create — fileless payloads: an anonymous file run via /proc/self/fd + // Network: opening channels or exfiltrating data. 41, // socket 42, // connect 49, // bind 50, // listen 43, // accept + 288, // accept4 + 44, // sendto — data exfiltration + 46, // sendmsg + 307, // sendmmsg + // Filesystem access from injected code. 2, // open 257, // openat + // Identity / privilege changes. 105, // setuid 106, // setgid 117, // setresuid + 161, // chroot + // Reaching into other processes. 101, // ptrace + 310, // process_vm_readv — cross-process memory read + 311, // process_vm_writev — cross-process injection without ptrace + 438, // pidfd_getfd — steal a file descriptor from another process + // Spawning execution contexts. 56, // clone 57, // fork 58, // vfork + // Disabling kernel defences / escaping containers. + 157, // prctl — can clear NO_NEW_PRIVS, rename, disable core dumps, etc. + 317, // seccomp + 321, // bpf — loading eBPF programs + 323, // userfaultfd — kernel-exploit stabilisation / TOCTOU races + 165, // mount + 272, // unshare + 308, // setns — entering another namespace (container escape) + // Reaching ring 0. + 175, // init_module + 313, // finit_module + 246, // kexec_load + // io_uring can issue syscalls out of band of the ptrace/seccomp syscall path. + 425, // io_uring_setup + 426, // io_uring_enter ]; /// Memory-management syscalls after which the process memory map may have @@ -57,13 +95,17 @@ pub fn is_execve(nr: u64) -> bool { } pub fn is_network_input(nr: u64) -> bool { - // read(0), recvfrom(45), recvmsg(47), readv(19) — the usual channels an - // exploit's first-stage payload arrives on. - matches!(nr, 0 | 45 | 47 | 19) + // read(0), recvfrom(45), recvmsg(47), readv(19), recvmmsg(299) — the usual + // channels an exploit's first-stage payload arrives on. + matches!(nr, 0 | 45 | 47 | 19 | 299) } /// Human-readable name for a syscall number, or `syscall_` if unknown. -pub fn name(nr: u64) -> String { +/// +/// Returns a [`Cow`] so the overwhelmingly common case — a syscall we know — +/// borrows a `&'static str` and allocates nothing on the hot path; only an +/// unknown number pays for the `syscall_` formatting. +pub fn name(nr: u64) -> Cow<'static, str> { let s = match nr { 0 => "read", 1 => "write", @@ -78,7 +120,9 @@ pub fn name(nr: u64) -> String { 41 => "socket", 42 => "connect", 43 => "accept", + 44 => "sendto", 45 => "recvfrom", + 46 => "sendmsg", 47 => "recvmsg", 49 => "bind", 50 => "listen", @@ -91,12 +135,32 @@ pub fn name(nr: u64) -> String { 105 => "setuid", 106 => "setgid", 117 => "setresuid", + 157 => "prctl", + 161 => "chroot", + 165 => "mount", + 175 => "init_module", 231 => "exit_group", + 246 => "kexec_load", 257 => "openat", + 272 => "unshare", + 288 => "accept4", + 299 => "recvmmsg", + 307 => "sendmmsg", + 308 => "setns", + 310 => "process_vm_readv", + 311 => "process_vm_writev", + 313 => "finit_module", + 317 => "seccomp", + 319 => "memfd_create", + 321 => "bpf", 322 => "execveat", - _ => return format!("syscall_{nr}"), + 323 => "userfaultfd", + 425 => "io_uring_setup", + 426 => "io_uring_enter", + 438 => "pidfd_getfd", + _ => return Cow::Owned(format!("syscall_{nr}")), }; - s.to_string() + Cow::Borrowed(s) } #[cfg(test)] @@ -105,9 +169,12 @@ mod tests { #[test] fn names_known_and_unknown() { - assert_eq!(name(59), "execve"); - assert_eq!(name(10), "mprotect"); - assert_eq!(name(9999), "syscall_9999"); + assert_eq!(&*name(59), "execve"); + assert_eq!(&*name(10), "mprotect"); + assert_eq!(&*name(9999), "syscall_9999"); + // Known syscalls borrow a static string; only unknowns allocate. + assert!(matches!(name(59), Cow::Borrowed(_))); + assert!(matches!(name(9999), Cow::Owned(_))); } #[test] @@ -119,4 +186,29 @@ mod tests { assert!(is_network_input(0)); assert!(!is_sensitive(1)); // write is not, by itself, sensitive } + + #[test] + fn modern_evasion_syscalls_are_sensitive() { + // The additions that cover fileless, cross-process, defence-evasion and + // container-escape techniques must all be in the sensitive set, and each + // must render with a real name rather than `syscall_`. + for nr in [ + 319, // memfd_create + 311, // process_vm_writev + 321, // bpf + 317, // seccomp + 323, // userfaultfd + 308, // setns + 426, // io_uring_enter + 44, // sendto + ] { + assert!(is_sensitive(nr), "syscall {nr} should be sensitive"); + assert!(!name(nr).starts_with("syscall_"), "syscall {nr} needs a name"); + } + } + + #[test] + fn recvmmsg_is_network_input() { + assert!(is_network_input(299)); + } } diff --git a/src/tracer.rs b/src/tracer.rs index f10926a..c8b47ad 100644 --- a/src/tracer.rs +++ b/src/tracer.rs @@ -246,7 +246,7 @@ impl Tracer { ) { 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)); + .unwrap_or_else(|_| (std::borrow::Cow::Borrowed("?"), 0, 0)); // One SIGKILL per distinct thread-group is enough to take down all of // its threads; de-duplicating avoids redundant signals. @@ -384,10 +384,20 @@ impl Backend for Tracer { } } WaitStatus::PtraceSyscall(_) => { - let tgid = threads[&raw].tgid; + // The vacant-entry check above guarantees `raw` is known here; + // still, read it fallibly rather than index-and-panic, so a + // phantom stop from a kernel/wait race can never crash the + // sensor. An unattributable stop is simply resumed. + let (at_entry, tgid) = match threads.get(&raw) { + Some(ts) => (ts.at_entry, ts.tgid), + None => { + let _ = ptrace::syscall(who, None); + continue; + } + }; let mut killed = false; let mut event_fired = false; - if threads[&raw].at_entry { + if at_entry { // Count the syscall first, so a lost `getregs` still // registers as work the tracee did, then inspect it. engine.count_syscall(tgid); @@ -413,8 +423,9 @@ impl Backend for Tracer { } } } - let ts = threads.get_mut(&raw).unwrap(); - ts.at_entry = !ts.at_entry; + if let Some(ts) = threads.get_mut(&raw) { + ts.at_entry = !ts.at_entry; + } // 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 @@ -431,7 +442,10 @@ impl Backend for Tracer { // 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 Some(tgid) = threads.get(&raw).map(|t| t.tgid) else { + ptrace::syscall(who, Some(sig)).map_err(nix_err)?; + continue; + }; engine.register_space(tgid); let fired = self.on_signal(who, tgid, sig, &mut engine, reporter); ptrace::syscall(who, Some(sig)).map_err(nix_err)?; diff --git a/src/ui.rs b/src/ui.rs index 8c0791c..5849d94 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,7 +20,7 @@ use std::time::Instant; use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal}; use crate::detect::Enforcement; -use crate::event::{Event, Severity}; +use crate::event::{sanitize_display, Event, Severity}; use crate::tracer::{ProcStat, Reporter, Summary}; /// How many recent detections to retain for the feed (only the tail is drawn). @@ -36,6 +36,11 @@ pub struct Dashboard { started: Instant, feed: VecDeque, json: Option>, + /// Set once a write to the JSON sink fails. In the TUI stderr is the alternate + /// screen, so a failure can't be logged there without corrupting the display; + /// instead it is surfaced as a banner in the frame so the operator knows the + /// evidence log may be incomplete. + sink_error: bool, } impl Dashboard { @@ -56,6 +61,7 @@ impl Dashboard { started: Instant::now(), feed: VecDeque::new(), json, + sink_error: false, } } @@ -80,7 +86,7 @@ impl Dashboard { Enforcement::Block => "block", Enforcement::Kill => "kill", }; - let subtitle = format!( + let mut subtitle = format!( " {} · enforce: {} · {} proc · {} syscalls · {} events", self.mode, enforce, @@ -88,6 +94,9 @@ impl Dashboard { human(summary.syscalls_seen), human(summary.events), ); + if self.sink_error { + subtitle.push_str(" · ⚠ JSON SINK WRITE FAILED — log may be incomplete"); + } out.push(dim(&clip(&subtitle, cols))); // Tiny terminals: stop after the header so we never overflow. @@ -185,7 +194,12 @@ impl Reporter for Dashboard { return; } if let Some(sink) = self.json.as_mut() { - let _ = writeln!(sink, "{}", ev.to_json()); + // A failed write (full disk, broken pipe) must not be swallowed: for a + // security sensor a lost detection is the worst possible outcome, so + // remember it and let the frame flag it to the operator. + if writeln!(sink, "{}", ev.to_json()).and_then(|_| sink.flush()).is_err() { + self.sink_error = true; + } } self.feed.push_back(ev.clone()); if self.feed.len() > FEED_CAP { @@ -213,7 +227,9 @@ fn proc_row(s: &ProcStat, name_w: usize, cols: usize) -> String { let prefix = format!( " {:>6} {} {:>8} {:>6} ", s.tgid, - padr(&s.name, name_w), + // The process name comes from `/proc//comm`; strip any control + // characters so a hostile process cannot inject ANSI into the table. + padr(&sanitize_display(&s.name), name_w), clip(&human(s.syscalls), 8), clip(&human(s.events), 6), ); @@ -234,7 +250,9 @@ fn event_row(ev: &Event, cols: usize) -> String { ev.kind.as_str(), ev.syscall, ev.rip, - ev.origin, + // Origin is a mapped-file label — attacker-influenceable — so neutralise + // any embedded terminal escapes before it hits the feed. + sanitize_display(&ev.origin), ev.detail, ); format!("{head}{}", clip(&rest, cols.saturating_sub(9))) @@ -548,4 +566,47 @@ mod tests { assert!(alive.contains('●')); assert!(dead.contains('○')); } + + #[test] + fn proc_row_neutralizes_hostile_process_name() { + // A comm containing a clear-screen escape must never emit the live escape + // into the table; the neutered literal text remains. + let row = proc_row(&stat(1, "evil\x1b[2Jname", 0, 0, None, true), 20, 80); + assert!(!row.contains("\x1b[2J"), "clear-screen escape from comm must be stripped"); + assert!(strip_ansi(&row).contains("evil[2Jname")); + } + + #[test] + fn event_row_neutralizes_hostile_origin() { + let ev = Event::now(1, Severity::High, Kind::WxViolation, "mmap", 0x10, 0x20, "lbl\x1b[2Jx", "d"); + let row = event_row(&ev, 200); + assert!(!row.contains("\x1b[2J"), "escape from origin label must be stripped"); + } + + #[test] + fn json_sink_failure_is_surfaced_in_frame() { + // A writer that always fails stands in for a full disk / broken pipe. + struct FailWriter; + impl io::Write for FailWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("sink is full")) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut d = Dashboard::new( + "run".into(), + Enforcement::Observe, + Severity::Warn, + Some(Box::new(FailWriter)), + ); + d.event(&Event::now(1, Severity::High, Kind::WxViolation, "mmap", 0, 0, "anon", "x")); + assert!(d.sink_error, "a failed sink write must be recorded"); + let frame = d.frame(&[], &Summary::default(), 120, 24).join("\n"); + assert!( + frame.contains("JSON SINK WRITE FAILED"), + "the frame must warn the operator that the log may be incomplete:\n{frame}" + ); + } }