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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ exploitation*, not the *identity of the bug*.
> Built as the runtime-defence companion to [`ghost`](https://github.com/pandaadir05/ghost).
> `ghost` finds weaknesses; `wraith` catches them being used.

![Wraith's live scan dashboard: three clean workers and one process caught mid-exploitation](docs/scan-demo.svg)

<p align="center"><em><code>wraith scan --ui --match netd</code> — one row per process with live syscall/event
counters, and a correlated exploitation verdict the instant injected code issues a syscall.</em></p>

---

## The idea
Expand Down Expand Up @@ -115,6 +120,7 @@ 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)
--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
--min <sev> floor: info|warn|high|critical (default warn)
Expand Down Expand Up @@ -194,6 +200,27 @@ target. Caveats worth knowing:
`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).
- **Never traces itself.** `scan` excludes its own process and its whole
ancestor chain (the shell/terminal that launched it), so a broad `--match`
can't accidentally attach to — and hang on — the tool that started it.

### Live dashboard (`--ui`)

Add `--ui` to any mode for a full-screen terminal dashboard instead of the
scrolling log — the picture at the top of this README is exactly that, on the
scan flow:

```bash
sudo wraith scan --ui --match nginx
```

One row per traced process with live syscall/event counters and a colour-coded
verdict (`clean` → `suspicious` → `EXPLOITATION`), above a feed of the most
recent detections and a status bar carrying the aggregate verdict. It repaints
on every detection and at ~20 fps otherwise, restores the terminal cleanly on
exit or Ctrl-C, and still honours `--json` (the event stream is written to the
sink underneath the UI). The dashboard is hand-rolled ANSI — no TUI dependency
— so the sensor's supply chain stays `nix` + `libc` only.

---

Expand All @@ -211,6 +238,7 @@ carry the smallest supply chain you can manage. The engine links only `nix` and
├─ 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)
├─ ui.rs the live terminal dashboard (--ui), hand-rolled ANSI
└─ bin/
├─ wraith.rs the CLI sensor
├─ benign.rs false-positive control target
Expand Down Expand Up @@ -275,7 +303,7 @@ ranges).

```bash
cargo build --release
cargo test # 36 unit + 9 end-to-end tests
cargo test # 42 unit + 10 end-to-end tests
cargo clippy --all-targets
./demo.sh # side-by-side benign vs. exploitation run
```
Expand Down
38 changes: 38 additions & 0 deletions docs/scan-demo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
133 changes: 101 additions & 32 deletions src/bin/wraith.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
//! --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
//! --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)
Expand All @@ -26,6 +27,7 @@ use std::process::ExitCode;
use wraith::detect::{Config, Enforcement};
use wraith::event::{Event, Severity};
use wraith::tracer::Tracer;
use wraith::ui::{Dashboard, TerminalGuard};

fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
Expand All @@ -42,6 +44,7 @@ struct Opts {
json: Option<String>,
min: Severity,
quiet: bool,
ui: bool,
cfg: Config,
}

Expand All @@ -58,6 +61,7 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
json: None,
min: Severity::Warn,
quiet: false,
ui: false,
cfg: Config::default(),
};

Expand Down Expand Up @@ -98,6 +102,7 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
scan_matches.push(v.clone());
}
"--all" => scan_all = true,
"--ui" | "--dashboard" => opts.ui = true,
"--no-stack-pivot" => opts.cfg.detect_stack_pivot = false,
"--audit-sensitive" => opts.cfg.audit_sensitive = true,
"--quiet" => opts.quiet = true,
Expand All @@ -114,45 +119,52 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {

// Set up output sinks.
let color = io::stderr().is_terminal();
let mut json_sink: Option<Box<dyn Write>> = match opts.json.as_deref() {
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)?)),
};
let min = opts.min;
let quiet = opts.quiet;
let ui = opts.ui;
let enforcement = opts.cfg.enforcement;

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());
}
}
};
if ui && !io::stderr().is_terminal() {
return Err(bad(
"--ui needs an interactive terminal on stderr; drop --ui, or use --json for a stream",
));
}

let enforce_note = match opts.cfg.enforcement {
let enforce_note = match enforcement {
Enforcement::Observe => "",
Enforcement::Block => " [enforcing: block]",
Enforcement::Kill => " [enforcing: kill]",
};

// A short label for the run, used by the dashboard header. Assigned by
// every non-returning arm below.
let ui_label;

let tracer = match mode.as_str() {
"run" => {
if target.is_empty() {
return Err(bad("no program to run; use: wraith run -- <program> [args...]"));
}
eprintln!(
"wraith: monitoring `{}` (provenance mode){enforce_note}",
target.join(" ")
);
ui_label = format!("run — {}", target.join(" "));
if !ui {
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}{enforce_note}");
ui_label = format!("attach — pid {pid}");
if !ui {
eprintln!("wraith: attaching to pid {pid}{enforce_note}");
}
Tracer::attach(pid, opts.cfg)?
}
"scan" => {
Expand All @@ -165,15 +177,18 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
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:?}")
},
);
let filter = if scan_all {
"--all".to_string()
} else {
format!("--match {}", scan_matches.join(","))
};
ui_label = format!("scan {filter}");
if !ui {
eprintln!(
"wraith: scanning {} process(es) {filter}{enforce_note}",
pids.len(),
);
}
Tracer::attach_many(&pids, opts.cfg)?
}
other => {
Expand All @@ -183,7 +198,27 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
}
};

let summary = tracer.run(&mut on_event)?;
let summary = if ui {
// Live dashboard: the tracer drives a Dashboard reporter inside a guard
// that restores the terminal on exit (and on Ctrl-C via a signal handler).
let dash = Dashboard::new(ui_label, enforcement, min, json_sink);
let _guard = TerminalGuard::enter()?;
tracer.run_with(dash)?
} else {
// Plain stream: colored log lines to stderr, optional JSONL to the sink.
let mut json_sink = json_sink;
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());
}
}
};
tracer.run(&mut on_event)?
};

// A short verdict on stderr so a human sees the bottom line.
eprintln!(
Expand Down Expand Up @@ -212,11 +247,13 @@ fn verdict(sev: Option<Severity>) -> &'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.
/// is set, or when any `needle` is a substring of its `comm` or `cmdline`. Our
/// own process, its whole ancestor chain (the shell/terminal that launched us),
/// and PID 1 are excluded — a `scan` should watch its targets, never the tools
/// that started it. The attach itself (in [`Tracer::attach_many`]) then skips
/// anything we lack permission to trace.
fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec<i32> {
let self_pid = std::process::id() as i32;
let excluded = ancestor_pids();
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return out;
Expand All @@ -226,7 +263,7 @@ fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec<i32> {
let Some(pid) = name.to_str().and_then(|n| n.parse::<i32>().ok()) else {
continue;
};
if pid == self_pid || pid == 1 {
if pid == 1 || excluded.contains(&pid) {
continue;
}
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default();
Expand All @@ -241,6 +278,37 @@ fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec<i32> {
out
}

/// The set of PIDs from us up to the root of the process tree — our own PID and
/// every ancestor. Used to keep `scan` from attaching to the shell, terminal,
/// or supervisor that launched it (which would otherwise match a broad filter
/// and, being long-lived, keep the trace running forever).
fn ancestor_pids() -> std::collections::HashSet<i32> {
let mut set = std::collections::HashSet::new();
let mut pid = std::process::id() as i32;
// Bounded walk: real trees are shallow, and this guards against a cycle.
for _ in 0..128 {
if !set.insert(pid) {
break;
}
match read_ppid(pid) {
Some(ppid) if ppid > 1 => pid = ppid,
_ => break,
}
}
set
}

/// The parent PID of `pid` from `/proc/<pid>/status`, if readable.
fn read_ppid(pid: i32) -> Option<i32> {
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("PPid:") {
return rest.trim().parse().ok();
}
}
None
}

/// 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.
Expand Down Expand Up @@ -298,6 +366,7 @@ OPTIONS:\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 \
--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 \
Expand Down
7 changes: 5 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@
//! - [`detect`] — the rules and the exploitation-chain correlator.
//! - [`event`] — detection events and their JSON form.
//! - [`tracer`] — the `ptrace` engine that drives a target.
//! - [`ui`] — the live terminal dashboard (`--ui`).

pub mod detect;
pub mod event;
pub mod maps;
pub mod provenance;
pub mod syscalls;
pub mod tracer;
pub mod ui;

pub use detect::{Config, Detector, SyscallCtx};
pub use detect::{Config, Detector, Enforcement, SyscallCtx};
pub use event::{Event, Kind, Severity};
pub use tracer::{Summary, Tracer};
pub use tracer::{ProcStat, Reporter, Summary, Tracer};
pub use ui::{Dashboard, TerminalGuard};
Loading
Loading