From af4310d6f6fe7fa7a5dbe4ac3e310a2a5601844c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:29:51 +0000 Subject: [PATCH] Follow every thread: catch exploits that fire from worker threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraith previously traced only the main thread — it waited on a single pid and never set PTRACE_O_TRACECLONE — so any syscall issued from a thread the target spawned was invisible. For a sensor aimed at network daemons, request handlers, and fuzz harnesses (almost always multithreaded), that was the biggest real blind spot and the stated next milestone. The tracer now follows every clone/fork/vfork and reaps the whole tree 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 on one thread and fired from another is still correlated into a single verdict, and distinct processes keep distinct state. New tracees are registered lazily on their first stop, which sidesteps the parent/child wait-ordering race. - detect.rs: chain state is now per-process (keyed by tgid) instead of a single accumulator, so cross-thread staging correlates and separate processes don't bleed evidence together. - tracer.rs: PTRACE_O_TRACECLONE/FORK/VFORK, waitpid(-1) reap loop, per-thread state, per-address-space map cache with cross-thread dirtying. - New targets: benign-threads (multithreaded false-positive control) and mt-shellcode-sim (payload detonated from a worker thread). - Two end-to-end tests cover both; the e2e suite serializes through a lock since two ptrace engines can't share one process. - README, demo.sh, and roadmap updated to reflect thread-following. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WS9djsh9BpTKXGgYBJET5u --- Cargo.toml | 11 ++ README.md | 42 +++++--- demo.sh | 34 +++++- src/bin/benign_threads.rs | 32 ++++++ src/bin/mt_shellcode_sim.rs | 84 +++++++++++++++ src/detect.rs | 120 +++++++++++---------- src/tracer.rs | 201 ++++++++++++++++++++++++++++-------- tests/integration.rs | 68 +++++++++++- 8 files changed, 479 insertions(+), 113 deletions(-) create mode 100644 src/bin/benign_threads.rs create mode 100644 src/bin/mt_shellcode_sim.rs 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.