diff --git a/Cargo.toml b/Cargo.toml index 2b66bc3..55c4a0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,17 @@ path = "src/bin/benign.rs" name = "shellcode-sim" path = "src/bin/shellcode_sim.rs" +# Multithreaded counterparts: `benign-threads` is a false-positive control +# across several worker threads; `mt-shellcode-sim` fires its payload from a +# worker thread and is only caught when Wraith follows clones. +[[bin]] +name = "benign-threads" +path = "src/bin/benign_threads.rs" + +[[bin]] +name = "mt-shellcode-sim" +path = "src/bin/mt_shellcode_sim.rs" + [dependencies] nix = { version = "0.29", features = ["ptrace", "process", "signal"] } libc = "0.2" diff --git a/README.md b/README.md index ab748c5..267df05 100644 --- a/README.md +++ b/README.md @@ -129,17 +129,27 @@ 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, syscall loop) + ├─ tracer.rs the ptrace engine (spawn/attach, thread-following loop) └─ bin/ - ├─ wraith.rs the CLI sensor - ├─ benign.rs false-positive control target - └─ shellcode_sim.rs exploitation-behaviour simulator + ├─ wraith.rs the CLI sensor + ├─ benign.rs false-positive control target + ├─ benign_threads.rs multithreaded false-positive control + ├─ shellcode_sim.rs exploitation-behaviour simulator + └─ mt_shellcode_sim.rs exploitation from a worker thread ``` The tracer adds no syscall of its own on the hot path beyond the unavoidable `getregs`, and re-reads `/proc//maps` only when a memory operation could have changed it. +**Thread-following.** Real targets — network daemons, request handlers, fuzz +harnesses — are multithreaded, and an exploit can fire from any thread. Wraith +follows every `clone`/`fork`/`vfork` the target makes and inspects syscalls +from *all* of them. Threads that share an address space share one cached memory +map and one exploitation-chain accumulator, so a payload staged on one thread +and fired from another is still correlated into a single verdict — while +separate processes keep separate state. + --- ## Limitations & roadmap @@ -152,9 +162,12 @@ claim to be a finished EDR. production path is the same logic on **eBPF** (`tracepoint/raw_syscalls` + a page-provenance map) for near-zero overhead — the detection model is transport-agnostic by design. -- **Multithreading.** The current engine focuses on single-threaded targets; - full `PTRACE_O_TRACECLONE` thread-following and per-thread stack tracking is - the next milestone. +- **Attach vs. pre-existing threads.** `wraith run` and `wraith attach` follow + every thread and child the target spawns *after* tracing begins (via + `PTRACE_O_TRACECLONE`/`FORK`/`VFORK`). When attaching to an already-running + multithreaded process, only the threads that clone after attach are picked + up automatically; seizing every pre-existing sibling thread is a small + follow-up. - **Pure-ROP that never leaves legit code.** An attacker who only reuses existing `.text` and never stages new executable memory won't trip the provenance rule — that's what the stack-pivot heuristic is for, and why @@ -162,9 +175,10 @@ claim to be a finished EDR. - **Legitimate JIT** (browsers, JVMs, .NET) runs code from anonymous executable pages; hence `AnonExec` is WARN by default and configurable. -Roadmap: eBPF backend · thread-following · return-address/shadow-stack checks · -ROP-chain length heuristics · per-process behavioural baselining · a policy DSL -for allow-listing legitimate JIT regions. +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. --- @@ -172,13 +186,15 @@ for allow-listing legitimate JIT regions. ```bash cargo build --release -cargo test # 28 unit + 4 end-to-end tests +cargo test # 28 unit + 6 end-to-end tests cargo clippy --all-targets ./demo.sh # side-by-side benign vs. exploitation run ``` -The end-to-end tests drive the real ptrace engine over the `benign` and -`shellcode-sim` binaries; they self-skip where `ptrace` is unavailable. +The end-to-end tests drive the real ptrace engine over the `benign`, +`benign-threads`, `shellcode-sim`, and `mt-shellcode-sim` binaries — including +an exploit fired from a worker thread to exercise thread-following. They +self-skip where `ptrace` is unavailable. ## License diff --git a/demo.sh b/demo.sh index 5f18753..5950627 100755 --- a/demo.sh +++ b/demo.sh @@ -11,10 +11,12 @@ cargo build --release --quiet WRAITH=./target/release/wraith BENIGN=./target/release/benign SIM=./target/release/shellcode-sim +MT_BENIGN=./target/release/benign-threads +MT_SIM=./target/release/mt-shellcode-sim echo echo "============================================================" -echo " 1/2 BENIGN target — expect: clean, exit 0" +echo " 1/4 BENIGN target — expect: clean, exit 0" echo "============================================================" set +e "$WRAITH" run --min info -- "$BENIGN" @@ -23,7 +25,7 @@ set -e echo echo "============================================================" -echo " 2/2 SHELLCODE-SIM target — expect: EXPLOITATION DETECTED, exit 3" +echo " 2/4 SHELLCODE-SIM target — expect: EXPLOITATION DETECTED, exit 3" echo "============================================================" set +e "$WRAITH" run -- "$SIM" @@ -32,8 +34,30 @@ echo " -> wraith exit code: $code" set -e echo -if [ "$code" -eq 3 ]; then - echo "Demo OK: benign was clean; injected-code execution was detected and correlated." +echo "============================================================" +echo " 3/4 BENIGN multithreaded target — expect: clean, exit 0" +echo "============================================================" +set +e +"$WRAITH" run -- "$MT_BENIGN" +echo " -> wraith exit code: $?" +set -e + +echo +echo "============================================================" +echo " 4/4 WORKER-THREAD exploit — payload fires from a spawned" +echo " thread; only thread-following catches it (exit 3)" +echo "============================================================" +set +e +"$WRAITH" run -- "$MT_SIM" +mt_code=$? +echo " -> wraith exit code: $mt_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." else - echo "Demo WARNING: expected exit 3 from the simulator run (got $code)." + echo "Demo WARNING: expected exit 3 from both simulator runs (got $code and $mt_code)." fi diff --git a/src/bin/benign_threads.rs b/src/bin/benign_threads.rs new file mode 100644 index 0000000..02ca8b9 --- /dev/null +++ b/src/bin/benign_threads.rs @@ -0,0 +1,32 @@ +//! A benign *multithreaded* target — the false-positive control for Wraith's +//! thread-following. Several worker threads run concurrently, each issuing a +//! stream of ordinary syscalls (writes, allocations, sleeps) from legitimate +//! code. Wraith follows every one of them and must stay completely silent: +//! spawning threads is not exploitation. + +use std::thread; +use std::time::Duration; + +fn work(id: usize) -> u64 { + // A little CPU work plus routine I/O — the kind of syscall traffic a real + // worker thread produces, all from legitimate file-backed code. + let mut acc = id as u64; + for i in 0..5 { + acc = acc.wrapping_mul(2654435761).wrapping_add(i); + // `write` and `nanosleep` from libc are legitimate-origin syscalls. + println!("worker {id}: tick {i} acc={acc:#x}"); + thread::sleep(Duration::from_millis(2)); + } + acc +} + +fn main() { + let handles: Vec<_> = (0..4).map(|id| thread::spawn(move || work(id))).collect(); + + let mut total = 0u64; + for h in handles { + total = total.wrapping_add(h.join().expect("worker thread panicked")); + } + + println!("benign-threads: done (total={total:#x})"); +} diff --git a/src/bin/mt_shellcode_sim.rs b/src/bin/mt_shellcode_sim.rs new file mode 100644 index 0000000..143ddd1 --- /dev/null +++ b/src/bin/mt_shellcode_sim.rs @@ -0,0 +1,84 @@ +//! A self-contained exploitation simulator whose payload fires from a *worker +//! thread*, not the main thread. It is identical in spirit to `shellcode-sim` +//! — stage an RWX page, write a payload, execute a syscall from it — but the +//! whole sequence happens inside a `std::thread`, the way an exploit against a +//! threaded daemon (a request handler, a parser worker) actually plays out. +//! +//! A tracer that only watches the main thread sees nothing here; the RWX +//! staging and the injected `socket(2,1,0)` both occur on a thread born from a +//! `clone`. Catching it is the whole point of Wraith's thread-following, so +//! this target is the positive control for that capability. +//! +//! The payload is the same hand-assembled x86-64 stub as `shellcode-sim`: +//! +//! ```text +//! bf 02 00 00 00 mov edi, 2 ; AF_INET +//! be 01 00 00 00 mov esi, 1 ; SOCK_STREAM +//! 31 d2 xor edx, edx ; protocol 0 +//! b8 29 00 00 00 mov eax, 41 ; __NR_socket +//! 0f 05 syscall +//! c3 ret +//! ``` + +use std::ptr; +use std::thread; + +#[cfg(target_arch = "x86_64")] +const PAYLOAD: [u8; 19] = [ + 0xbf, 0x02, 0x00, 0x00, 0x00, // mov edi, 2 + 0xbe, 0x01, 0x00, 0x00, 0x00, // mov esi, 1 + 0x31, 0xd2, // xor edx, edx + 0xb8, 0x29, 0x00, 0x00, 0x00, // mov eax, 41 (socket) + 0x0f, 0x05, // syscall <-- issued from the RWX page, on a worker thread +]; + +#[cfg(target_arch = "x86_64")] +fn detonate() { + const PAGE: usize = 4096; + + // Stage 1: allocate a writable+executable page (W^X violation). + let mem = unsafe { + libc::mmap( + ptr::null_mut(), + PAGE, + libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC, + libc::MAP_PRIVATE | libc::MAP_ANONYMOUS, + -1, + 0, + ) + }; + assert!(mem != libc::MAP_FAILED, "mmap RWX failed"); + println!("mt-shellcode-sim: worker staged RWX page at {mem:p}"); + + // Stage 2: write the payload plus a trailing `ret`. + unsafe { + ptr::copy_nonoverlapping(PAYLOAD.as_ptr(), mem as *mut u8, PAYLOAD.len()); + *(mem as *mut u8).add(PAYLOAD.len()) = 0xc3; // ret + } + + // Stage 3: transfer control into the injected code. The `syscall` executes + // with RIP inside the RWX page — on a thread the tracer only sees if it + // followed the `clone`. + let entry: extern "C" fn() -> i64 = unsafe { std::mem::transmute(mem) }; + let fd = entry(); + println!("mt-shellcode-sim: injected socket() from worker thread -> {fd}"); + + if fd >= 0 { + unsafe { libc::close(fd as libc::c_int) }; + } + unsafe { libc::munmap(mem, PAGE) }; +} + +#[cfg(target_arch = "x86_64")] +fn main() { + // The main thread does nothing exploitative; the payload lives on a worker. + let worker = thread::spawn(detonate); + worker.join().expect("worker thread panicked"); + println!("mt-shellcode-sim: done"); +} + +#[cfg(not(target_arch = "x86_64"))] +fn main() { + eprintln!("mt-shellcode-sim: this demonstrator is x86-64 only"); + std::process::exit(1); +} diff --git a/src/detect.rs b/src/detect.rs index 162537c..bf0ff7a 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -6,6 +6,8 @@ //! syscall, a stack pivot) are suspicious on their own, but seen together in //! one process they are an exploitation chain, and Wraith says so explicitly. +use std::collections::HashMap; + use crate::event::{Event, Kind, Severity}; use crate::maps::MemoryMap; use crate::provenance::{classify_rip, classify_rsp, Origin, Prot, StackState}; @@ -64,21 +66,32 @@ impl ChainState { pub struct Detector { cfg: Config, - chain: ChainState, + /// One accumulating chain per traced address space (keyed by thread-group + /// id). Threads of a process share memory, so an exploit staged in one + /// thread and fired from another is a single chain; separate processes get + /// separate chains so their evidence never bleeds together. + chains: HashMap, } impl Detector { pub fn new(cfg: Config) -> Self { Detector { cfg, - chain: ChainState::default(), + chains: HashMap::new(), } } - /// Inspect one syscall and return any events it triggers. - pub fn on_syscall(&mut self, ctx: &SyscallCtx, map: &MemoryMap) -> Vec { + /// Inspect one syscall and return any events it triggers. `proc_key` + /// identifies the address space the syscall belongs to (the tracee's + /// thread-group id); all threads sharing memory pass the same key so their + /// evidence correlates into one exploitation chain. + pub fn on_syscall(&mut self, proc_key: i32, ctx: &SyscallCtx, map: &MemoryMap) -> Vec { let mut events = Vec::new(); let sysname = syscalls::name(ctx.nr); + // Disjoint field borrows: `cfg` is read-only, `chain` is the mutable + // per-process accumulator for this address space. + let cfg = &self.cfg; + let chain = self.chains.entry(proc_key).or_default(); // Breadcrumb: first-stage payloads usually arrive over a read/recv. // A bare `read` is only interesting when it comes from stdin (fd 0); @@ -90,16 +103,16 @@ impl Detector { _ => true, // recvfrom/recvmsg }; if is_external_input { - self.chain.net_input = true; + chain.net_input = true; } } // 1. Provenance of the syscall instruction itself. let origin = classify_rip(map, ctx.rip); if origin.is_anomalous() { - self.chain.foreign_origin = true; + chain.foreign_origin = true; let sensitive = syscalls::is_sensitive(ctx.nr); - let severity = self.foreign_severity(origin, sensitive); + let severity = foreign_severity(cfg, origin, sensitive); let label = map.region_at(ctx.rip).map(|r| r.label()).unwrap_or_else(|| "unmapped".into()); let detail = if sensitive { format!( @@ -119,7 +132,7 @@ impl Detector { label, detail, )); - } else if self.cfg.audit_sensitive && syscalls::is_sensitive(ctx.nr) { + } else if cfg.audit_sensitive && syscalls::is_sensitive(ctx.nr) { let label = map.region_at(ctx.rip).map(|r| r.label()).unwrap_or_else(|| "?".into()); events.push(Event::now( ctx.pid, @@ -134,10 +147,10 @@ impl Detector { } // 2. Stack pivot: the stack pointer is somewhere no real stack lives. - if self.cfg.detect_stack_pivot { + if cfg.detect_stack_pivot { let ss = classify_rsp(map, ctx.rsp); if ss.is_anomalous() && ss != StackState::Unmapped { - self.chain.stack_pivot = true; + chain.stack_pivot = true; let label = map.region_at(ctx.rsp).map(|r| r.label()).unwrap_or_else(|| "?".into()); events.push(Event::now( ctx.pid, @@ -158,7 +171,7 @@ impl Detector { let prot = Prot::from_raw(ctx.args[2]); let addr = ctx.args[0]; if prot.is_wx() { - self.chain.wx_staged = true; + chain.wx_staged = true; events.push(Event::now( ctx.pid, Severity::High, @@ -174,7 +187,7 @@ impl Detector { // W->X flip an attacker performs after writing a payload. if let Some(region) = map.region_at(addr) { if region.write { - self.chain.wx_staged = true; + chain.wx_staged = true; events.push(Event::now( ctx.pid, Severity::High, @@ -193,13 +206,14 @@ impl Detector { // 4. Correlate. A sensitive syscall from foreign code, combined with // any prior staging milestone, is an exploitation chain — one high // confidence verdict rather than a scatter of primitives. - if !self.chain.chain_reported - && self.chain.foreign_origin + if !chain.chain_reported + && chain.foreign_origin && syscalls::is_sensitive(ctx.nr) && origin.is_anomalous() - && self.chain.staging_count() >= 1 + && chain.staging_count() >= 1 { - self.chain.chain_reported = true; + chain.chain_reported = true; + let narrative = chain_narrative(chain); events.push(Event::now( ctx.pid, Severity::Critical, @@ -208,43 +222,43 @@ impl Detector { ctx.rip, ctx.rsp, "correlated", - self.chain_narrative(), + narrative, )); } events } +} - fn foreign_severity(&self, origin: Origin, sensitive: bool) -> Severity { - if sensitive { - return Severity::Critical; - } - match origin { - Origin::AnonExec => { - if self.cfg.jit_is_critical { - Severity::High - } else { - Severity::Warn - } +fn foreign_severity(cfg: &Config, origin: Origin, sensitive: bool) -> Severity { + if sensitive { + return Severity::Critical; + } + match origin { + Origin::AnonExec => { + if cfg.jit_is_critical { + Severity::High + } else { + Severity::Warn } - _ => Severity::High, } + _ => Severity::High, } +} - fn chain_narrative(&self) -> String { - let mut steps = Vec::new(); - if self.chain.net_input { - steps.push("attacker-controlled input received"); - } - if self.chain.wx_staged { - steps.push("executable payload staged (W^X)"); - } - if self.chain.stack_pivot { - steps.push("stack pivot"); - } - steps.push("sensitive syscall from injected code"); - format!("EXPLOITATION CHAIN: {}", steps.join(" -> ")) +fn chain_narrative(chain: &ChainState) -> String { + let mut steps = Vec::new(); + if chain.net_input { + steps.push("attacker-controlled input received"); + } + if chain.wx_staged { + steps.push("executable payload staged (W^X)"); + } + if chain.stack_pivot { + steps.push("stack pivot"); } + steps.push("sensitive syscall from injected code"); + format!("EXPLOITATION CHAIN: {}", steps.join(" -> ")) } #[cfg(test)] @@ -272,14 +286,14 @@ mod tests { #[test] fn legit_syscall_from_libc_is_silent() { let mut d = Detector::new(Config::default()); - let ev = d.on_syscall(&ctx(1, 0x7f0000000500, 0x7ffd00010000, [0; 6]), &map()); + let ev = d.on_syscall(1, &ctx(1, 0x7f0000000500, 0x7ffd00010000, [0; 6]), &map()); assert!(ev.is_empty()); } #[test] fn syscall_from_rwx_page_is_flagged() { let mut d = Detector::new(Config::default()); - let ev = d.on_syscall(&ctx(1, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + let ev = d.on_syscall(1, &ctx(1, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); assert_eq!(ev.len(), 1); assert_eq!(ev[0].kind, Kind::ForeignOriginSyscall); assert_eq!(ev[0].severity, Severity::High); @@ -289,7 +303,7 @@ mod tests { fn execve_from_injected_code_is_critical() { let mut d = Detector::new(Config::default()); // execve (59) from the RWX page. - let ev = d.on_syscall(&ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + let ev = d.on_syscall(1, &ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); assert!(ev.iter().any(|e| e.severity == Severity::Critical && e.kind == Kind::ForeignOriginSyscall)); } @@ -299,7 +313,7 @@ mod tests { let mut d = Detector::new(Config::default()); let prot = (libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC) as u64; // Called from legit code, so the only event is the W^X violation. - let ev = d.on_syscall(&ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); + let ev = d.on_syscall(1, &ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); assert_eq!(ev.len(), 1); assert_eq!(ev[0].kind, Kind::WxViolation); } @@ -308,14 +322,14 @@ mod tests { fn mprotect_wx_transition_on_writable_page() { let mut d = Detector::new(Config::default()); let prot = (libc::PROT_READ | libc::PROT_EXEC) as u64; // exec only, but page is writable - let ev = d.on_syscall(&ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); + let ev = d.on_syscall(1, &ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); assert!(ev.iter().any(|e| e.kind == Kind::WxTransition)); } #[test] fn stack_pivot_into_heap_detected() { let mut d = Detector::new(Config::default()); - let ev = d.on_syscall(&ctx(1, 0x7f0000000500, 0x55f000004000, [0; 6]), &map()); + let ev = d.on_syscall(1, &ctx(1, 0x7f0000000500, 0x55f000004000, [0; 6]), &map()); assert!(ev.iter().any(|e| e.kind == Kind::StackPivot)); } @@ -324,9 +338,9 @@ mod tests { let mut d = Detector::new(Config::default()); // Step 1: stage RWX via mprotect (from legit code). let prot = (libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC) as u64; - d.on_syscall(&ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); + d.on_syscall(1, &ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); // Step 2: execve from the injected RWX page. - let ev = d.on_syscall(&ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + let ev = d.on_syscall(1, &ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); assert!(ev.iter().any(|e| e.kind == Kind::ExploitationChain && e.severity == Severity::Critical)); } @@ -335,9 +349,9 @@ mod tests { fn chain_reported_only_once() { let mut d = Detector::new(Config::default()); let prot = (libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC) as u64; - d.on_syscall(&ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); - let first = d.on_syscall(&ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); - let second = d.on_syscall(&ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + d.on_syscall(1, &ctx(10, 0x7f0000000500, 0x7ffd00010000, [0x7f0000050000, 0x1000, prot, 0, 0, 0]), &map()); + let first = d.on_syscall(1, &ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + let second = d.on_syscall(1, &ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); assert!(first.iter().any(|e| e.kind == Kind::ExploitationChain)); assert!(!second.iter().any(|e| e.kind == Kind::ExploitationChain)); } diff --git a/src/tracer.rs b/src/tracer.rs index 2f27667..652a506 100644 --- a/src/tracer.rs +++ b/src/tracer.rs @@ -6,8 +6,21 @@ //! the snapshot to the [`Detector`]. The design goal is to add no syscall of //! our own on the hot path beyond the unavoidable `getregs`, and to re-read //! `/proc//maps` only when it can have changed. +//! +//! ## Thread-following +//! +//! Real targets — network daemons, parsers, fuzz harnesses — spawn threads, so +//! an exploit can fire from any of them. The engine follows every `clone`, +//! `fork`, and `vfork` (via `PTRACE_O_TRACE{CLONE,FORK,VFORK}`) and reaps *all* +//! tracees with `waitpid(-1)`. Each thread keeps its own syscall entry/exit +//! phase, while threads that share an address space (same thread-group id) +//! share one cached memory map and one exploitation-chain accumulator — so a +//! payload staged in one thread and fired from another is still one verdict. +use std::collections::hash_map::Entry; +use std::collections::HashMap; use std::ffi::CString; +use std::fs; use std::io; use nix::sys::ptrace; @@ -20,6 +33,28 @@ use crate::event::{Event, Kind, Severity}; use crate::maps::MemoryMap; use crate::syscalls; +/// A cached memory map for one address space (one thread-group), plus a flag +/// set whenever any thread in the group runs a memory-management syscall that +/// could have changed it. Threads share memory, so one thread's `mmap` +/// invalidates the whole group's view. +struct AddrSpace { + map: Option, + dirty: bool, +} + +impl AddrSpace { + fn new() -> Self { + AddrSpace { map: None, dirty: true } + } +} + +/// Per-thread bookkeeping. `PTRACE_SYSCALL` stops at both entry and exit; each +/// thread toggles its own phase independently since their stops interleave. +struct ThreadState { + at_entry: bool, + tgid: i32, +} + /// Outcome of a completed trace. #[derive(Debug, Default, Clone)] pub struct Summary { @@ -129,58 +164,101 @@ impl Tracer { } } - /// Run the trace to completion (or until the attached process detaches), - /// invoking `on_event` for every detection. + /// 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. pub fn run(mut self, mut on_event: F) -> io::Result where F: FnMut(&Event), { - let pid = self.pid(); + let root = self.pid(); let mut summary = Summary::default(); - // Cached map plus a "may be stale" flag set after memory operations. - let mut map: Option = None; - let mut map_dirty = true; - // PTRACE_SYSCALL stops at both entry and exit; we only inspect entries. - let mut at_entry = true; + // 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(); - // Kick the tracee toward its first syscall stop. - ptrace::syscall(pid, None).map_err(nix_err)?; + 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)?; loop { - let status = waitpid(pid, None).map_err(nix_err)?; + // Reap any tracee. `ECHILD` means every thread and child has gone. + let status = match waitpid(Pid::from_raw(-1), None) { + Ok(s) => s, + Err(nix::errno::Errno::ECHILD) => break, + Err(e) => return Err(nix_err(e)), + }; + + let Some(who) = status_pid(&status) else { continue }; + let raw = who.as_raw(); + + // First sighting of a tid: a freshly-cloned thread or child, still + // stopped at its creation stop with our trace options inherited. + // Registering lazily on first sight (rather than parsing the parent + // clone event) sidesteps the parent/child wait-ordering race. + if let Entry::Vacant(slot) = threads.entry(raw) { + let tgid = read_tgid(raw); + slot.insert(ThreadState { at_entry: true, tgid }); + spaces.entry(tgid).or_insert_with(AddrSpace::new); + // Consume this initial stop and let the new tracee run; the + // creation SIGSTOP must not be forwarded. + let _ = ptrace::syscall(who, None); + continue; + } + match status { WaitStatus::Exited(_, code) => { - summary.exit_code = Some(code); - break; + threads.remove(&raw); + if raw == root.as_raw() { + summary.exit_code = Some(code); + } + if threads.is_empty() { + break; + } } WaitStatus::Signaled(_, sig, _) => { - summary.term_signal = Some(sig as i32); - break; + threads.remove(&raw); + if raw == root.as_raw() { + summary.term_signal = Some(sig as i32); + } + if threads.is_empty() { + break; + } } WaitStatus::PtraceSyscall(_) => { - if at_entry { + let tgid = threads[&raw].tgid; + if threads[&raw].at_entry { summary.syscalls_seen += 1; - if let Some(nr) = self.inspect(pid, &mut map, &mut map_dirty, &mut summary, &mut on_event) { - // The memory map may change as a result of this - // call; force a refresh before the next inspection. + 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 { + // A memory op by any thread can change the shared + // address space; invalidate the whole group's map. if syscalls::is_memory_op(nr) { - map_dirty = true; + spaces.get_mut(&tgid).unwrap().dirty = true; } } } - at_entry = !at_entry; - ptrace::syscall(pid, None).map_err(nix_err)?; + let ts = threads.get_mut(&raw).unwrap(); + ts.at_entry = !ts.at_entry; + ptrace::syscall(who, None).map_err(nix_err)?; } WaitStatus::Stopped(_, sig) => { // A real signal was delivered to the tracee (not a syscall // stop). Fatal memory-safety signals are worth surfacing as // a possible failed exploit, then we forward the signal. - self.on_signal(pid, sig, &mut summary, &mut on_event); - ptrace::syscall(pid, Some(sig)).map_err(nix_err)?; + self.on_signal(who, sig, &mut summary, &mut on_event); + ptrace::syscall(who, Some(sig)).map_err(nix_err)?; } WaitStatus::PtraceEvent(_, _, _) => { - ptrace::syscall(pid, None).map_err(nix_err)?; + // Clone/fork/exec notification for a tracee we already know; + // the new child is handled on its own first sighting above. + ptrace::syscall(who, None).map_err(nix_err)?; } WaitStatus::Continued(_) => {} WaitStatus::StillAlive => {} @@ -189,42 +267,44 @@ impl Tracer { Ok(summary) } - /// Inspect a single syscall-entry stop. Returns the syscall number, or - /// `None` if registers could not be read. + /// 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. fn inspect( &mut self, - pid: Pid, - map: &mut Option, - map_dirty: &mut bool, + who: Pid, + tgid: i32, + space: &mut AddrSpace, summary: &mut Summary, on_event: &mut F, ) -> Option where F: FnMut(&Event), { - let regs = ptrace::getregs(pid).ok()?; + let regs = ptrace::getregs(who).ok()?; let nr = regs.orig_rax; - // Refresh the memory map if stale or unset. - if *map_dirty || map.is_none() { - if let Ok(fresh) = MemoryMap::read(pid.as_raw()) { - *map = Some(fresh); - *map_dirty = false; + // Refresh the shared map if stale or unset. Reading `/proc//maps` + // for any thread yields the whole group's address space. + if space.dirty || space.map.is_none() { + if let Ok(fresh) = MemoryMap::read(who.as_raw()) { + space.map = Some(fresh); + space.dirty = false; } } - let current = map.as_ref()?; + let current = space.map.as_ref()?; // The `syscall` instruction is two bytes; RIP already points past it. let site = regs.rip.wrapping_sub(2); let ctx = SyscallCtx { - pid: pid.as_raw(), + pid: who.as_raw(), nr, rip: site, rsp: regs.rsp, args: [regs.rdi, regs.rsi, regs.rdx, regs.r10, regs.r8, regs.r9], }; - for ev in self.detector.on_syscall(&ctx, current) { + for ev in self.detector.on_syscall(tgid, &ctx, current) { summary.record(&ev); on_event(&ev); } @@ -267,8 +347,47 @@ fn set_options(pid: Pid) -> io::Result<()> { // 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. - ptrace::setoptions(pid, Options::PTRACE_O_TRACESYSGOOD | Options::PTRACE_O_EXITKILL) - .map_err(nix_err) + // 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) +} + +/// The pid a [`WaitStatus`] refers to, if it carries one. +fn status_pid(status: &WaitStatus) -> Option { + match status { + WaitStatus::Exited(p, _) + | WaitStatus::Signaled(p, _, _) + | WaitStatus::Stopped(p, _) + | WaitStatus::PtraceEvent(p, _, _) + | WaitStatus::PtraceSyscall(p) + | WaitStatus::Continued(p) => Some(*p), + WaitStatus::StillAlive => None, + } +} + +/// The thread-group id of a thread, read once from `/proc//status`. +/// Threads of a process share a tgid (and their address space); a `fork`ed +/// child gets its own. Falls back to the tid itself if status is unreadable. +fn read_tgid(tid: i32) -> i32 { + if let Ok(status) = fs::read_to_string(format!("/proc/{tid}/status")) { + for line in status.lines() { + if let Some(rest) = line.strip_prefix("Tgid:") { + if let Ok(v) = rest.trim().parse::() { + return v; + } + } + } + } + tid } fn nix_err(e: nix::errno::Errno) -> io::Error { diff --git a/tests/integration.rs b/tests/integration.rs index 1b7d0f9..a8b2eb1 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -12,15 +12,27 @@ #![cfg(target_arch = "x86_64")] -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use wraith::detect::Config; use wraith::event::{Event, Kind, Severity}; use wraith::tracer::{Summary, Tracer}; +/// The tracer reaps its whole process tree with `waitpid(-1)`, which is exactly +/// right for the real sensor (a dedicated process with a single tracer) but +/// means two engines cannot run concurrently inside one process. `cargo test` +/// runs these cases in parallel threads of one binary, so we serialize them +/// through this lock; each trace runs start-to-finish before the next begins. +fn trace_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + /// Trace `bin` to completion, returning the collected events and the summary. /// Returns `None` if the tracer could not even start (no ptrace permission). fn trace(bin: &str, cfg: Config) -> Option<(Vec, Summary)> { + let _guard = trace_lock().lock().unwrap_or_else(|e| e.into_inner()); + let collected = Arc::new(Mutex::new(Vec::new())); let sink = Arc::clone(&collected); @@ -105,6 +117,60 @@ fn shellcode_simulator_is_detected() { assert_eq!(summary.max_severity, Some(Severity::Critical)); } +#[test] +fn benign_threads_produce_no_detections() { + // Several worker threads doing ordinary work must stay clean: following a + // clone is not, by itself, a reason to fire. This is the false-positive + // control for thread-following. + let bin = env!("CARGO_BIN_EXE_benign-threads"); + let Some((events, summary)) = trace(bin, Config::default()) else { + return; // ptrace unavailable — skip + }; + + assert!(summary.syscalls_seen > 0, "expected to observe some syscalls"); + assert!( + events.is_empty(), + "benign multithreaded program should produce no events, got: {:?}", + events.iter().map(|e| e.to_line(false)).collect::>() + ); + assert!(summary.max_severity.is_none()); + assert_eq!(summary.exit_code, Some(0)); +} + +#[test] +fn worker_thread_exploit_is_detected() { + // The payload here stages RWX and issues its syscall from a *worker thread* + // born of a clone. A tracer that only watched the main thread would report + // this process clean; catching it proves thread-following works end-to-end. + let bin = env!("CARGO_BIN_EXE_mt-shellcode-sim"); + let Some((events, summary)) = trace(bin, Config::default()) else { + return; + }; + + assert!( + events.iter().any(|e| e.kind == Kind::WxViolation), + "expected a W^X violation from the worker-thread RWX mmap" + ); + + let foreign_critical = events.iter().any(|e| { + e.kind == Kind::ForeignOriginSyscall && e.severity == Severity::Critical + }); + assert!( + foreign_critical, + "expected a CRITICAL foreign-origin syscall from the worker thread, got: {:?}", + events.iter().map(|e| e.to_line(false)).collect::>() + ); + + // Staging and firing happen on the worker but share the process address + // space, so the per-process correlator must still tie them into one chain. + assert!( + events.iter().any(|e| e.kind == Kind::ExploitationChain), + "expected an exploitation-chain verdict correlated across the thread" + ); + + assert_eq!(summary.max_severity, Some(Severity::Critical)); +} + #[test] fn detection_survives_min_severity_gate() { // Even filtering to CRITICAL-only, the payload is caught.