Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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 <sev> floor: info|warn|high|critical (default warn)
```

Expand Down
78 changes: 72 additions & 6 deletions src/bin/wraith.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@
//! wraith scan [OPTIONS] (--match <s> | --all) monitor many running procs
//!
//! Options:
//! --json <FILE|-> also write JSONL events (`-` for stdout)
//! --json <FILE|-> also write JSONL events (`-` for stdout; a FILE is appended)
//! --min <SEV> 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 <substr> (scan) attach to processes whose name/cmdline matches
//! --all (scan) attach to every process we're allowed to trace
//! --max-targets <N> (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;

Expand Down Expand Up @@ -54,6 +56,13 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
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..];

Expand All @@ -71,6 +80,7 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
let mut attach_pid: Option<i32> = None;
let mut scan_matches: Vec<String> = Vec::new();
let mut scan_all = false;
let mut max_targets: Option<usize> = None;

while i < rest.len() {
let a = &rest[i];
Expand Down Expand Up @@ -102,6 +112,16 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
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::<usize>()
.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,
Expand All @@ -122,7 +142,11 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
let json_sink: Option<Box<dyn Write>> = 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;
Expand All @@ -135,6 +159,15 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
));
}

// 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]",
Expand Down Expand Up @@ -173,10 +206,22 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
"scan needs a filter: --match <substring> (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 {
Expand Down Expand Up @@ -207,13 +252,22 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
} 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"
);
}
}
}
};
Expand Down Expand Up @@ -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))
}

Expand All @@ -358,18 +422,20 @@ wraith run [OPTIONS] -- <program> [args...] spawn and monitor a program\n \
wraith attach [OPTIONS] <pid> monitor one running process\n \
wraith scan [OPTIONS] (--match <s> | --all) monitor many running processes\n\n\
OPTIONS:\n \
--json <FILE|-> also write JSONL events (`-` = stdout)\n \
--json <FILE|-> also write JSONL events (`-` = stdout; a FILE is appended, not truncated)\n \
--min <SEV> 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 <substr> (scan) attach to processes whose name/cmdline matches (repeatable)\n \
--all (scan) attach to every process we're allowed to trace\n \
--max-targets <N> (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 \
Expand Down
28 changes: 28 additions & 0 deletions src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
);
}
}
35 changes: 33 additions & 2 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
}
35 changes: 34 additions & 1 deletion src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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());
Expand Down Expand Up @@ -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:?}");
}
}
10 changes: 10 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading