diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..804593d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: ci + +on: + push: + branches: ["**"] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + run: rustup toolchain install stable --profile minimal --component clippy + - name: Build + run: cargo build --all-targets --verbose + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + # The end-to-end tests need ptrace; they self-skip where it is + # unavailable, so the suite stays green on constrained runners. + run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe7a469 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.jsonl diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d707c65 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,47 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "wraith" +version = "0.1.0" +dependencies = [ + "libc", + "nix", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2b66bc3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "wraith" +version = "0.1.0" +edition = "2021" +rust-version = "1.74" +description = "Signature-free runtime exploitation detection via syscall provenance verification" +license = "MIT" +repository = "https://github.com/grloper/cyber-rust" +readme = "README.md" +keywords = ["security", "edr", "exploit", "ptrace", "detection"] +categories = ["command-line-utilities"] + +[[bin]] +name = "wraith" +path = "src/bin/wraith.rs" + +# Self-contained targets used by the test-suite and for live demos. +# `benign` never triggers a detection; `shellcode-sim` stages and executes +# code from an RWX anonymous mapping the way a real exploit payload does. +[[bin]] +name = "benign" +path = "src/bin/benign.rs" + +[[bin]] +name = "shellcode-sim" +path = "src/bin/shellcode_sim.rs" + +[dependencies] +nix = { version = "0.29", features = ["ptrace", "process", "signal"] } +libc = "0.2" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c9b8c4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 grloper, pandaadir05 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 929c41e..ab748c5 100644 --- a/README.md +++ b/README.md @@ -1 +1,185 @@ -# cyber-rust \ No newline at end of file +# Wraith + +**Signature-free runtime exploitation detection via syscall provenance verification.** + +Wraith is a low-level Rust security sensor that answers a question most tools +can't: *is this process being exploited right now?* — without knowing the +vulnerability or the payload in advance. That makes it effective against +**zero-days and n-days alike**, because it keys on the *behaviour of +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. + +--- + +## The idea + +Defensive tooling usually answers one of two questions: + +| Question | Needs to know | Blind to | +|---|---|---| +| *Does this code contain a known bug?* (SAST/scanners) | the vulnerability | zero-days | +| *Do these bytes match known-bad?* (AV/YARA) | the payload | novel payloads | + +Wraith answers a **third** question — *is this process executing in a state +that only exploitation produces?* — and it needs to know neither the bug nor +the payload. + +The observation behind it: **every** memory-corruption exploit, no matter the +root cause (stack overflow, UAF, type confusion, an unknown zero-day), +eventually converges on the same visible act. To accomplish anything — spawn a +shell, open a socket, read a secret — the attacker must issue **system calls**. +And at the instant those syscalls happen, the process is in a state that +legitimate execution *never* produces. + +Wraith attaches to a process with `ptrace`, stops at the entry of every +syscall, and checks a handful of invariants that hold for all benign programs: + +1. **Provenance** — a `syscall` instruction only ever runs from a *file-backed + executable page* (the program's own `.text`, a shared library, or the kernel + vDSO). Shellcode injected into the heap, stack, or an anonymous page + **breaks this**. +2. **W^X** — no benign program needs a page that is writable *and* executable, + nor to flip a writable page to executable. Payload staging **breaks this**. +3. **Stack integrity** — at syscall time the stack pointer lives inside a real + stack, never the heap or a file image. A ROP stack pivot **breaks this**. + +Because these are invariants of *legitimate behaviour* rather than signatures +of *specific attacks*, any violation is evidence of exploitation regardless of +how the attacker got there. + +--- + +## Quickstart + +```bash +cargo build --release + +# Monitor a program you launch: +./target/release/wraith run -- /usr/bin/some-service --flags + +# Monitor a process that's already running: +sudo ./target/release/wraith attach 4242 + +# Emit machine-readable events for your SIEM/pipeline: +./target/release/wraith run --json events.jsonl -- ./target +``` + +Wraith exits `0` when clean, `1` on suspicious (HIGH) activity, and `3` when it +detects exploitation (CRITICAL) — so it drops straight into CI and fuzzing +harnesses as a behavioural oracle. + +### See it work + +The repo ships two self-contained targets (no real exploit required): + +```console +$ wraith run --min info -- ./target/release/benign +wraith: monitoring `./target/release/benign` (provenance mode) +benign: ... normal work ... +wraith: 81 syscalls, 0 event(s); verdict: clean # <- false-positive control + +$ wraith run -- ./target/release/shellcode-sim + HIGH pid=6586 wx_violation mmap @ 0x7fa8ad52534a `mmap` requests writable+executable memory — classic shellcode staging +CRITICAL pid=6586 foreign_origin_syscall socket @ 0x7fa8ad696011 [anon] sensitive syscall `socket` issued from wx-violation memory — injected code is now acting +CRITICAL pid=6586 exploitation_chain socket @ 0x7fa8ad696011 [correlated] EXPLOITATION CHAIN: executable payload staged (W^X) -> sensitive syscall from injected code +wraith: 69 syscalls, 3 event(s); verdict: EXPLOITATION DETECTED +``` + +`shellcode-sim` stages an RWX page, writes a payload into it, and issues a +syscall from that page — the exact tail end of a real exploit — and Wraith +catches every stage and correlates them into one verdict. + +--- + +## What it detects + +| 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, …). | +| `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. | +| `crash` | HIGH | Target took SIGSEGV/SIGILL/SIGBUS/SIGABRT — often a *failed* exploit worth investigating. | +| `exploitation_chain` | CRITICAL | Multiple primitives correlated into a single high-confidence verdict. | +| `sensitive_call` | INFO | Audit breadcrumb (with `--audit-sensitive`): a sensitive syscall from legitimate code. | + +Tuning: + +``` +--jit-critical treat anonymous-exec pages as HIGH (targets that never JIT) +--no-stack-pivot disable the ROP stack-pivot heuristic +--audit-sensitive log sensitive syscalls from legitimate code too +--min floor: info|warn|high|critical (default warn) +``` + +--- + +## Architecture + +Small, auditable, and dependency-light on purpose — a sensor others run should +carry the smallest supply chain you can manage. The engine links only `nix` and +`libc`; JSON is emitted by hand. + +``` + src/ + ├─ maps.rs parse /proc//maps into typed regions + ├─ provenance.rs classify an instruction/stack pointer against the map + ├─ 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) + └─ bin/ + ├─ wraith.rs the CLI sensor + ├─ benign.rs false-positive control target + └─ shellcode_sim.rs exploitation-behaviour simulator +``` + +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. + +--- + +## Limitations & roadmap + +Wraith is an honest research prototype with a clear production path; it does not +claim to be a finished EDR. + +- **`ptrace` overhead.** Two stops per syscall suits high-value targets + (network daemons, parsers, fuzz targets), not the whole system. The + 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. +- **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 + return-address validation and a shadow stack are on the roadmap. +- **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. + +--- + +## Building & testing + +```bash +cargo build --release +cargo test # 28 unit + 4 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. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/demo.sh b/demo.sh new file mode 100755 index 0000000..5f18753 --- /dev/null +++ b/demo.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Side-by-side demonstration: Wraith stays silent on a benign program and +# catches the payload simulator executing a syscall from injected memory. +set -euo pipefail + +cd "$(dirname "$0")" + +echo "==> building (release)" +cargo build --release --quiet + +WRAITH=./target/release/wraith +BENIGN=./target/release/benign +SIM=./target/release/shellcode-sim + +echo +echo "============================================================" +echo " 1/2 BENIGN target — expect: clean, exit 0" +echo "============================================================" +set +e +"$WRAITH" run --min info -- "$BENIGN" +echo " -> wraith exit code: $?" +set -e + +echo +echo "============================================================" +echo " 2/2 SHELLCODE-SIM target — expect: EXPLOITATION DETECTED, exit 3" +echo "============================================================" +set +e +"$WRAITH" run -- "$SIM" +code=$? +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." +else + echo "Demo WARNING: expected exit 3 from the simulator run (got $code)." +fi diff --git a/src/bin/benign.rs b/src/bin/benign.rs new file mode 100644 index 0000000..cdeb930 --- /dev/null +++ b/src/bin/benign.rs @@ -0,0 +1,35 @@ +//! A deliberately ordinary program. It performs a spread of everyday syscalls — +//! including sensitive ones like `socket` — but always from legitimate, +//! file-backed code. Wraith must report it as clean. Used by the test-suite as +//! the false-positive control and handy for a live "nothing fires" demo. + +use std::io::Read; + +fn main() { + println!("benign: starting normal work"); + + // Filesystem: open + read a file (open/read/close from libc — legit code). + if let Ok(mut f) = std::fs::File::open("/proc/self/status") { + let mut buf = String::new(); + let _ = f.read_to_string(&mut buf); + println!("benign: read {} bytes from /proc/self/status", buf.len()); + } + + // Network: create and immediately close a socket. `socket` is a sensitive + // syscall, but issued from legitimate code, so it must NOT be flagged + // unless the operator explicitly asks for --audit-sensitive. + unsafe { + let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0); + if fd >= 0 { + libc::close(fd); + println!("benign: opened and closed a socket from legitimate code"); + } + } + + // A little compute so the process lives long enough to be worth tracing. + let mut acc: u64 = 0; + for i in 0..100_000u64 { + acc = acc.wrapping_add(i.wrapping_mul(2654435761)); + } + println!("benign: done ({acc:#x})"); +} diff --git a/src/bin/shellcode_sim.rs b/src/bin/shellcode_sim.rs new file mode 100644 index 0000000..3abc3f4 --- /dev/null +++ b/src/bin/shellcode_sim.rs @@ -0,0 +1,75 @@ +//! A self-contained exploitation *simulator* — no real vulnerability, no +//! external target. It reproduces the observable tail end of virtually every +//! memory-corruption exploit: allocate an executable page, write a payload +//! into it, and transfer control there so a syscall is issued from injected +//! code. This is the behaviour Wraith exists to catch, and it lets the +//! test-suite prove detection without shipping a real exploit. +//! +//! The payload is a hand-assembled x86-64 stub that performs `socket(2,1,0)` +//! (a sensitive syscall) and returns cleanly: +//! +//! ```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; + +#[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 + // NOTE: `ret` (0xc3) is appended at runtime; keeping the array at the + // instruction boundary above documents the syscall site clearly. +]; + +#[cfg(target_arch = "x86_64")] +fn main() { + const PAGE: usize = 4096; + + // Stage 1: allocate a writable+executable page (W^X violation) — exactly + // what a payload does to hold its shellcode. + 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!("shellcode-sim: 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 — the provenance violation Wraith detects. + let entry: extern "C" fn() -> i64 = unsafe { std::mem::transmute(mem) }; + let fd = entry(); + println!("shellcode-sim: payload ran from injected page; socket() -> {fd}"); + + if fd >= 0 { + unsafe { libc::close(fd as libc::c_int) }; + } + unsafe { libc::munmap(mem, PAGE) }; + println!("shellcode-sim: done"); +} + +#[cfg(not(target_arch = "x86_64"))] +fn main() { + eprintln!("shellcode-sim: this demonstrator is x86-64 only"); + std::process::exit(1); +} diff --git a/src/bin/wraith.rs b/src/bin/wraith.rs new file mode 100644 index 0000000..99974da --- /dev/null +++ b/src/bin/wraith.rs @@ -0,0 +1,190 @@ +//! `wraith` — the command-line sensor. +//! +//! Usage: +//! wraith run [OPTIONS] -- [args...] spawn and monitor a program +//! wraith attach [OPTIONS] monitor a running process +//! +//! Options: +//! --json also write JSONL events (`-` for stdout) +//! --min minimum severity to report: info|warn|high|critical +//! --jit-critical treat anonymous-exec origins as HIGH (no-JIT targets) +//! --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) +//! -h, --help show this help + +use std::fs::File; +use std::io::{self, IsTerminal, Write}; +use std::process::ExitCode; + +use wraith::detect::Config; +use wraith::event::{Event, Severity}; +use wraith::tracer::Tracer; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match run(args) { + Ok(code) => code, + Err(e) => { + eprintln!("wraith: error: {e}"); + ExitCode::from(2) + } + } +} + +struct Opts { + json: Option, + min: Severity, + quiet: bool, + cfg: Config, +} + +fn run(args: Vec) -> io::Result { + if args.is_empty() || args[0] == "-h" || args[0] == "--help" { + print_help(); + return Ok(ExitCode::SUCCESS); + } + + let mode = args[0].clone(); + let rest = &args[1..]; + + let mut opts = Opts { + json: None, + min: Severity::Warn, + quiet: false, + cfg: Config::default(), + }; + + // Split option flags from the trailing target specification. + let mut i = 0; + let mut target: Vec = Vec::new(); + let mut attach_pid: Option = None; + + while i < rest.len() { + let a = &rest[i]; + match a.as_str() { + "--" => { + target = rest[i + 1..].to_vec(); + break; + } + "--json" => { + i += 1; + opts.json = Some(rest.get(i).cloned().ok_or_else(|| bad("--json needs a value"))?); + } + "--min" => { + i += 1; + let v = rest.get(i).ok_or_else(|| bad("--min needs a value"))?; + opts.min = parse_sev(v)?; + } + "--jit-critical" => opts.cfg.jit_is_critical = true, + "--no-stack-pivot" => opts.cfg.detect_stack_pivot = false, + "--audit-sensitive" => opts.cfg.audit_sensitive = true, + "--quiet" => opts.quiet = true, + other => { + if mode == "attach" && attach_pid.is_none() { + attach_pid = Some(other.parse().map_err(|_| bad("invalid pid"))?); + } else { + return Err(bad(&format!("unexpected argument: {other}"))); + } + } + } + i += 1; + } + + // Set up output sinks. + let color = io::stderr().is_terminal(); + let mut json_sink: Option> = 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 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()); + } + } + }; + + let tracer = match mode.as_str() { + "run" => { + if target.is_empty() { + return Err(bad("no program to run; use: wraith run -- [args...]")); + } + eprintln!("wraith: monitoring `{}` (provenance mode)", 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}"); + Tracer::attach(pid, opts.cfg)? + } + other => return Err(bad(&format!("unknown mode `{other}` (expected run|attach)"))), + }; + + let summary = tracer.run(&mut on_event)?; + + // A short verdict on stderr so a human sees the bottom line. + eprintln!( + "wraith: {} syscalls, {} event(s); verdict: {}", + summary.syscalls_seen, + summary.events, + verdict(summary.max_severity), + ); + + // Exit non-zero when something serious fired, so wraith is CI/pipeline + // friendly (a HIGH/CRITICAL trips the build). + Ok(match summary.max_severity { + Some(Severity::Critical) => ExitCode::from(3), + Some(Severity::High) => ExitCode::from(1), + _ => ExitCode::SUCCESS, + }) +} + +fn verdict(sev: Option) -> &'static str { + match sev { + Some(Severity::Critical) => "EXPLOITATION DETECTED", + Some(Severity::High) => "suspicious activity", + Some(Severity::Warn) => "minor anomalies", + _ => "clean", + } +} + +fn parse_sev(s: &str) -> io::Result { + match s.to_ascii_lowercase().as_str() { + "info" => Ok(Severity::Info), + "warn" => Ok(Severity::Warn), + "high" => Ok(Severity::High), + "critical" | "crit" => Ok(Severity::Critical), + _ => Err(bad("severity must be info|warn|high|critical")), + } +} + +fn bad(msg: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, msg.to_string()) +} + +fn print_help() { + println!( + "wraith — signature-free runtime exploitation detection\n\n\ +USAGE:\n \ +wraith run [OPTIONS] -- [args...] spawn and monitor a program\n \ +wraith attach [OPTIONS] monitor a running process\n\n\ +OPTIONS:\n \ +--json also write JSONL events (`-` = stdout)\n \ +--min minimum severity to report: info|warn|high|critical (default: warn)\n \ +--jit-critical treat anonymous-exec origins as HIGH (targets that never JIT)\n \ +--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 \ +-h, --help show this help\n\n\ +EXIT CODES:\n \ +0 clean/minor · 1 suspicious (HIGH) · 3 exploitation (CRITICAL) · 2 usage error\n" + ); +} diff --git a/src/detect.rs b/src/detect.rs new file mode 100644 index 0000000..162537c --- /dev/null +++ b/src/detect.rs @@ -0,0 +1,344 @@ +//! The detection engine. +//! +//! [`Detector`] is fed one [`SyscallCtx`] per syscall-entry stop plus the +//! current [`MemoryMap`], and returns any [`Event`]s that fire. It also runs a +//! lightweight correlator: individual primitives (a W^X page, a foreign-origin +//! 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 crate::event::{Event, Kind, Severity}; +use crate::maps::MemoryMap; +use crate::provenance::{classify_rip, classify_rsp, Origin, Prot, StackState}; +use crate::syscalls; + +/// Tunable behaviour. +#[derive(Debug, Clone)] +pub struct Config { + /// Treat anonymous-executable origins as HIGH rather than WARN. Off by + /// default because legitimate JIT engines (browsers, JVMs) run code from + /// anonymous executable pages; on for hardened targets that never JIT. + pub jit_is_critical: bool, + /// Enable the ROP stack-pivot heuristic. + pub detect_stack_pivot: bool, + /// Emit INFO breadcrumbs for sensitive syscalls from legitimate origins. + pub audit_sensitive: bool, +} + +impl Default for Config { + fn default() -> Self { + Config { + jit_is_critical: false, + detect_stack_pivot: true, + audit_sensitive: false, + } + } +} + +/// Register/argument snapshot at a syscall-entry stop. +#[derive(Debug, Clone, Copy)] +pub struct SyscallCtx { + pub pid: i32, + pub nr: u64, + pub rip: u64, + pub rsp: u64, + /// Syscall arguments in the x86-64 ABI order: rdi, rsi, rdx, r10, r8, r9. + pub args: [u64; 6], +} + +/// Accumulated evidence for a single traced process. +#[derive(Debug, Default, Clone)] +struct ChainState { + net_input: bool, + wx_staged: bool, + foreign_origin: bool, + stack_pivot: bool, + chain_reported: bool, +} + +impl ChainState { + /// Distinct staging milestones observed so far. + fn staging_count(&self) -> u32 { + self.net_input as u32 + self.wx_staged as u32 + self.stack_pivot as u32 + } +} + +pub struct Detector { + cfg: Config, + chain: ChainState, +} + +impl Detector { + pub fn new(cfg: Config) -> Self { + Detector { + cfg, + chain: ChainState::default(), + } + } + + /// Inspect one syscall and return any events it triggers. + pub fn on_syscall(&mut self, ctx: &SyscallCtx, map: &MemoryMap) -> Vec { + let mut events = Vec::new(); + let sysname = syscalls::name(ctx.nr); + + // Breadcrumb: first-stage payloads usually arrive over a read/recv. + // A bare `read` is only interesting when it comes from stdin (fd 0); + // reads on other fds are just the loader/program doing routine I/O. + // `recvfrom`/`recvmsg` operate on sockets, so they always count. + if syscalls::is_network_input(ctx.nr) { + let is_external_input = match ctx.nr { + 0 | 19 => ctx.args[0] == 0, // read/readv from stdin + _ => true, // recvfrom/recvmsg + }; + if is_external_input { + self.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; + let sensitive = syscalls::is_sensitive(ctx.nr); + let severity = self.foreign_severity(origin, sensitive); + let label = map.region_at(ctx.rip).map(|r| r.label()).unwrap_or_else(|| "unmapped".into()); + let detail = if sensitive { + format!( + "sensitive syscall `{sysname}` issued from {} memory — injected code is now acting", + origin.as_str() + ) + } else { + format!("syscall `{sysname}` issued from {} memory (not legitimate code)", origin.as_str()) + }; + events.push(Event::now( + ctx.pid, + severity, + Kind::ForeignOriginSyscall, + sysname.clone(), + ctx.rip, + ctx.rsp, + label, + detail, + )); + } else if self.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, + Severity::Info, + Kind::SensitiveCall, + sysname.clone(), + ctx.rip, + ctx.rsp, + label, + format!("sensitive syscall `{sysname}` from legitimate code"), + )); + } + + // 2. Stack pivot: the stack pointer is somewhere no real stack lives. + if self.cfg.detect_stack_pivot { + let ss = classify_rsp(map, ctx.rsp); + if ss.is_anomalous() && ss != StackState::Unmapped { + self.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, + Severity::High, + Kind::StackPivot, + sysname.clone(), + ctx.rip, + ctx.rsp, + label, + format!("stack pointer pivoted into {} at syscall time (ROP indicator)", ss.as_str()), + )); + } + } + + // 3. W^X: pages requested/made writable-and-executable, or flipped + // from writable to executable (payload staging). + if syscalls::is_mmap(ctx.nr) || syscalls::is_mprotect(ctx.nr) { + let prot = Prot::from_raw(ctx.args[2]); + let addr = ctx.args[0]; + if prot.is_wx() { + self.chain.wx_staged = true; + events.push(Event::now( + ctx.pid, + Severity::High, + Kind::WxViolation, + sysname.clone(), + ctx.rip, + ctx.rsp, + format!("{:#x}", addr), + format!("`{sysname}` requests writable+executable memory — classic shellcode staging"), + )); + } else if syscalls::is_mprotect(ctx.nr) && prot.exec { + // Adding execute to a page that is currently writable is the + // 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; + events.push(Event::now( + ctx.pid, + Severity::High, + Kind::WxTransition, + sysname.clone(), + ctx.rip, + ctx.rsp, + region.label(), + "writable page is being made executable — payload staging (W->X)".to_string(), + )); + } + } + } + } + + // 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 + && syscalls::is_sensitive(ctx.nr) + && origin.is_anomalous() + && self.chain.staging_count() >= 1 + { + self.chain.chain_reported = true; + events.push(Event::now( + ctx.pid, + Severity::Critical, + Kind::ExploitationChain, + sysname, + ctx.rip, + ctx.rsp, + "correlated", + self.chain_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 + } + } + _ => 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(" -> ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Map with the binary, libc, an RWX page, a writable-only anon page, heap + // and stack. + const MAP: &str = "\ +55f000001000-55f000002000 r-xp 00000000 08:01 1 /usr/bin/app +55f000003000-55f000010000 rw-p 00000000 00:00 0 [heap] +7f0000000000-7f0000021000 r-xp 00000000 08:01 2 /usr/lib/libc.so.6 +7f0000030000-7f0000031000 rwxp 00000000 00:00 0 +7f0000050000-7f0000051000 rw-p 00000000 00:00 0 +7ffd00000000-7ffd00021000 rw-p 00000000 00:00 0 [stack]"; + + fn map() -> MemoryMap { + MemoryMap::parse(MAP) + } + + fn ctx(nr: u64, rip: u64, rsp: u64, args: [u64; 6]) -> SyscallCtx { + SyscallCtx { pid: 1, nr, rip, rsp, args } + } + + #[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()); + 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()); + assert_eq!(ev.len(), 1); + assert_eq!(ev[0].kind, Kind::ForeignOriginSyscall); + assert_eq!(ev[0].severity, Severity::High); + } + + #[test] + 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()); + assert!(ev.iter().any(|e| e.severity == Severity::Critical + && e.kind == Kind::ForeignOriginSyscall)); + } + + #[test] + fn mprotect_rwx_is_wx_violation() { + 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()); + assert_eq!(ev.len(), 1); + assert_eq!(ev[0].kind, Kind::WxViolation); + } + + #[test] + 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()); + 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()); + assert!(ev.iter().any(|e| e.kind == Kind::StackPivot)); + } + + #[test] + fn exploitation_chain_correlates() { + 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()); + // Step 2: execve from the injected RWX page. + let ev = d.on_syscall(&ctx(59, 0x7f0000030010, 0x7ffd00010000, [0; 6]), &map()); + assert!(ev.iter().any(|e| e.kind == Kind::ExploitationChain + && e.severity == Severity::Critical)); + } + + #[test] + 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()); + assert!(first.iter().any(|e| e.kind == Kind::ExploitationChain)); + assert!(!second.iter().any(|e| e.kind == Kind::ExploitationChain)); + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..6fbe278 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,211 @@ +//! Detection events and their serialization. +//! +//! JSON is emitted by hand rather than through `serde` on purpose: a security +//! sensor that other people run should carry the smallest dependency surface +//! we can manage. The whole engine links only `nix` and `libc`. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// How alarming an event is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// Context only — a sensitive syscall from a legitimate origin. + Info, + /// Worth a look — anonymous-exec origin, could be a JIT. + Warn, + /// Almost certainly malicious in a non-JIT process. + High, + /// Exploitation. Injected code issuing syscalls, or a correlated chain. + Critical, +} + +impl Severity { + pub fn as_str(self) -> &'static str { + match self { + Severity::Info => "INFO", + Severity::Warn => "WARN", + Severity::High => "HIGH", + Severity::Critical => "CRITICAL", + } + } +} + +/// The class of anomaly an event represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// A syscall was issued from a memory region that is not legitimate code. + ForeignOriginSyscall, + /// A page was requested/made writable *and* executable. + WxViolation, + /// A page that was writable became executable (payload staging). + WxTransition, + /// The stack pointer was pivoted out of any real stack at syscall time. + StackPivot, + /// A sensitive syscall from a legitimate origin (audit breadcrumb). + SensitiveCall, + /// Multiple primitives correlated into a single exploitation verdict. + ExploitationChain, + /// The target took a fatal signal (SIGSEGV/SIGILL/SIGBUS/SIGABRT) — often + /// the visible symptom of a memory-corruption attempt that missed. + Crash, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Kind::ForeignOriginSyscall => "foreign_origin_syscall", + Kind::WxViolation => "wx_violation", + Kind::WxTransition => "wx_transition", + Kind::StackPivot => "stack_pivot", + Kind::SensitiveCall => "sensitive_call", + Kind::ExploitationChain => "exploitation_chain", + Kind::Crash => "crash", + } + } +} + +/// A single detection. +#[derive(Debug, Clone)] +pub struct Event { + pub ts_ns: u128, + pub pid: i32, + pub severity: Severity, + pub kind: Kind, + pub syscall: String, + pub rip: u64, + pub rsp: u64, + /// Region label for `rip` (e.g. `libc.so.6`, `[heap]`, `anon`). + pub origin: String, + /// One-line human explanation. + pub detail: String, +} + +impl Event { + #[allow(clippy::too_many_arguments)] + pub fn now( + pid: i32, + severity: Severity, + kind: Kind, + syscall: impl Into, + rip: u64, + rsp: u64, + origin: impl Into, + detail: impl Into, + ) -> Self { + let ts_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + Event { + ts_ns, + pid, + severity, + kind, + syscall: syscall.into(), + rip, + rsp, + origin: origin.into(), + detail: detail.into(), + } + } + + /// A single JSON object on one line (JSONL-friendly). + pub fn to_json(&self) -> String { + format!( + "{{\"ts_ns\":{},\"pid\":{},\"severity\":\"{}\",\"kind\":\"{}\",\"syscall\":\"{}\",\"rip\":\"{:#x}\",\"rsp\":\"{:#x}\",\"origin\":\"{}\",\"detail\":\"{}\"}}", + self.ts_ns, + self.pid, + self.severity.as_str(), + self.kind.as_str(), + json_escape(&self.syscall), + self.rip, + self.rsp, + json_escape(&self.origin), + json_escape(&self.detail), + ) + } + + /// A colourized, human-readable one-liner for a terminal. + pub fn to_line(&self, color: bool) -> String { + let tag = self.severity.as_str(); + let painted = if color { + let code = match self.severity { + Severity::Info => "36", // cyan + Severity::Warn => "33", // yellow + Severity::High => "35", // magenta + Severity::Critical => "1;31", // bold red + }; + format!("\x1b[{code}m{tag:>8}\x1b[0m") + } else { + format!("{tag:>8}") + }; + format!( + "{} pid={} {:<24} {} @ {:#x} [{}] {}", + painted, + self.pid, + self.kind.as_str(), + self.syscall, + self.rip, + self.origin, + self.detail, + ) + } +} + +/// Escape the characters that would break a JSON string literal. +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_is_wellformed_and_escaped() { + let e = Event::now( + 42, + Severity::Critical, + Kind::ForeignOriginSyscall, + "execve", + 0xdead, + 0xbeef, + "[heap]", + r#"quote " and \ backslash"#, + ); + let j = e.to_json(); + assert!(j.contains("\"severity\":\"CRITICAL\"")); + assert!(j.contains("\"syscall\":\"execve\"")); + assert!(j.contains("\"rip\":\"0xdead\"")); + assert!(j.contains("\\\"")); // escaped quote survives + assert!(j.contains("\\\\")); // escaped backslash survives + } + + #[test] + fn severity_orders_by_alarm() { + assert!(Severity::Critical > Severity::High); + assert!(Severity::High > Severity::Warn); + assert!(Severity::Warn > Severity::Info); + } + + #[test] + fn line_contains_key_fields() { + let e = Event::now(7, Severity::High, Kind::WxViolation, "mprotect", 0x1000, 0x2000, "anon", "rwx requested"); + let l = e.to_line(false); + assert!(l.contains("pid=7")); + assert!(l.contains("wx_violation")); + assert!(l.contains("mprotect")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..71cb30c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,51 @@ +//! # Wraith +//! +//! Signature-free runtime exploitation detection via **syscall provenance +//! verification**. +//! +//! Most defensive tooling answers one of two questions: *does this program +//! contain a known vulnerability?* (needs the bug) or *does this file match +//! known-bad bytes?* (needs the payload). Wraith answers a third, harder one: +//! *is this process being exploited right now?* — without knowing the bug or +//! the payload in advance. +//! +//! The insight is that every memory-corruption exploit, whatever the root +//! cause, converges on the same observable behaviour: to accomplish anything +//! the attacker must eventually issue system calls, and at that moment the +//! process is in a state legitimate execution never produces. Wraith attaches +//! to a process with `ptrace`, stops at the entry of every syscall, and checks +//! a handful of invariants that hold for all benign programs: +//! +//! 1. **Provenance** — a syscall instruction only ever executes from a +//! file-backed executable page (the program's own code, a shared library, +//! or the kernel vDSO). Injected shellcode in the heap, stack, or an +//! anonymous page breaks this. See [`provenance`]. +//! 2. **W^X** — no benign program needs a page that is writable *and* +//! executable, nor to flip a writable page to executable. Payload staging +//! breaks this. See [`detect`]. +//! 3. **Stack integrity** — at syscall time the stack pointer is inside a real +//! stack, never the heap or a file image. ROP stack pivots break this. +//! +//! Because these are invariants of *legitimate behaviour* rather than +//! signatures of *specific attacks*, a violation is evidence of exploitation +//! regardless of which vulnerability (zero-day or n-day) was used to get +//! there. +//! +//! ## Layout +//! - [`maps`] — parse `/proc//maps`. +//! - [`provenance`] — classify an instruction/stack pointer against the map. +//! - [`syscalls`] — the syscall table Wraith cares about. +//! - [`detect`] — the rules and the exploitation-chain correlator. +//! - [`event`] — detection events and their JSON form. +//! - [`tracer`] — the `ptrace` engine that drives a target. + +pub mod detect; +pub mod event; +pub mod maps; +pub mod provenance; +pub mod syscalls; +pub mod tracer; + +pub use detect::{Config, Detector, SyscallCtx}; +pub use event::{Event, Kind, Severity}; +pub use tracer::{Summary, Tracer}; diff --git a/src/maps.rs b/src/maps.rs new file mode 100644 index 0000000..af61d36 --- /dev/null +++ b/src/maps.rs @@ -0,0 +1,232 @@ +//! Parser for `/proc//maps`. +//! +//! The virtual-memory map is the ground truth Wraith uses to attribute a +//! syscall to the memory region its instruction pointer sits in. We only +//! re-read the map when a memory-management syscall (`mmap`/`mprotect`/ +//! `munmap`) could have changed it, or on a cache miss, so parsing stays off +//! the hot path. + +use std::fmt; +use std::fs; +use std::io; + +/// The logical kind of a mapped region, derived from its pathname column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegionKind { + /// The main thread stack (`[stack]`). + Stack, + /// The program break heap (`[heap]`). + Heap, + /// Kernel-provided fast-syscall page (`[vdso]`/`[vvar]`/`[vsyscall]`). + Kernel, + /// Backed by a file on disk (executable image, shared library, mmap'd file). + File(String), + /// Anonymous mapping with no backing file and no special name. + Anonymous, +} + +/// A single line of the process memory map. +#[derive(Debug, Clone)] +pub struct Region { + pub start: u64, + pub end: u64, + pub read: bool, + pub write: bool, + pub exec: bool, + pub shared: bool, + pub kind: RegionKind, +} + +impl Region { + #[inline] + pub fn contains(&self, addr: u64) -> bool { + addr >= self.start && addr < self.end + } + + /// True when the region is backed by a real file on disk. File-backed + /// executable pages are the *only* legitimate origin for program code. + pub fn is_file_backed(&self) -> bool { + matches!(self.kind, RegionKind::File(_)) + } + + /// A short, human-readable label for reports. + pub fn label(&self) -> String { + match &self.kind { + RegionKind::Stack => "[stack]".into(), + RegionKind::Heap => "[heap]".into(), + RegionKind::Kernel => "[kernel]".into(), + RegionKind::Anonymous => "anon".into(), + RegionKind::File(p) => { + // Just the basename keeps event lines readable. + p.rsplit('/').next().unwrap_or(p).to_string() + } + } + } + + fn perms(&self) -> String { + format!( + "{}{}{}{}", + if self.read { 'r' } else { '-' }, + if self.write { 'w' } else { '-' }, + if self.exec { 'x' } else { '-' }, + if self.shared { 's' } else { 'p' }, + ) + } +} + +impl fmt::Display for Region { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:#x}-{:#x} {} {}", + self.start, + self.end, + self.perms(), + self.label() + ) + } +} + +/// An ordered snapshot of a process's virtual address space. +#[derive(Debug, Clone, Default)] +pub struct MemoryMap { + regions: Vec, +} + +impl MemoryMap { + /// Read and parse `/proc//maps`. + pub fn read(pid: i32) -> io::Result { + let raw = fs::read_to_string(format!("/proc/{pid}/maps"))?; + Ok(Self::parse(&raw)) + } + + /// Parse the textual maps format. Malformed lines are skipped rather than + /// aborting the trace, since a single unexpected line should never blind + /// the detector. + pub fn parse(raw: &str) -> Self { + let regions = raw.lines().filter_map(parse_line).collect(); + Self { regions } + } + + /// The region containing `addr`, if any. Regions never overlap, so the + /// first hit is the answer. + pub fn region_at(&self, addr: u64) -> Option<&Region> { + self.regions.iter().find(|r| r.contains(addr)) + } + + pub fn regions(&self) -> &[Region] { + &self.regions + } + + pub fn is_empty(&self) -> bool { + self.regions.is_empty() + } +} + +fn parse_perms(field: &str) -> Option<(bool, bool, bool, bool)> { + let b = field.as_bytes(); + if b.len() < 4 { + return None; + } + Some(( + b[0] == b'r', + b[1] == b'w', + b[2] == b'x', + b[3] == b's', + )) +} + +fn classify(path: &str) -> RegionKind { + match path { + "" => RegionKind::Anonymous, + "[stack]" => RegionKind::Stack, + "[heap]" => RegionKind::Heap, + "[vdso]" | "[vvar]" | "[vsyscall]" | "[vvar_vclock]" => RegionKind::Kernel, + // Per-thread stacks appear as `[stack:tid]` on older kernels. + p if p.starts_with("[stack") => RegionKind::Stack, + // Any other bracketed pseudo-file is kernel-managed. + p if p.starts_with('[') => RegionKind::Kernel, + p => RegionKind::File(p.to_string()), + } +} + +fn parse_line(line: &str) -> Option { + // Format: START-END PERMS OFFSET DEV INODE [PATHNAME] + let mut it = line.split_whitespace(); + let range = it.next()?; + let perms = it.next()?; + let _offset = it.next()?; + let _dev = it.next()?; + let _inode = it.next()?; + // Pathname may contain spaces; take the remainder of the line. + let path = line + .splitn(6, char::is_whitespace) + .nth(5) + .map(str::trim) + .unwrap_or(""); + + let (start_s, end_s) = range.split_once('-')?; + let start = u64::from_str_radix(start_s, 16).ok()?; + let end = u64::from_str_radix(end_s, 16).ok()?; + let (read, write, exec, shared) = parse_perms(perms)?; + + Some(Region { + start, + end, + read, + write, + exec, + shared, + kind: classify(path), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +55f0aa3b1000-55f0aa3b2000 r-xp 00001000 08:01 131march /usr/bin/target +55f0aa3d0000-55f0aa3f1000 rw-p 00000000 00:00 0 [heap] +7f2c9c000000-7f2c9c021000 r-xp 00000000 08:01 262147 /usr/lib/x86_64-linux-gnu/libc.so.6 +7f2c9d000000-7f2c9d001000 rwxp 00000000 00:00 0 +7ffde1200000-7ffde1221000 rw-p 00000000 00:00 0 [stack] +7ffde13a0000-7ffde13a4000 r-xp 00000000 00:00 0 [vdso]"; + + #[test] + fn parses_all_regions() { + let m = MemoryMap::parse(SAMPLE); + assert_eq!(m.regions().len(), 6); + } + + #[test] + fn classifies_kinds() { + let m = MemoryMap::parse(SAMPLE); + assert!(matches!(m.region_at(0x55f0aa3b1500).unwrap().kind, RegionKind::File(_))); + assert_eq!(m.region_at(0x55f0aa3d0100).unwrap().kind, RegionKind::Heap); + assert_eq!(m.region_at(0x7ffde1200500).unwrap().kind, RegionKind::Stack); + assert_eq!(m.region_at(0x7ffde13a0100).unwrap().kind, RegionKind::Kernel); + } + + #[test] + fn detects_rwx_region() { + let m = MemoryMap::parse(SAMPLE); + let r = m.region_at(0x7f2c9d000500).unwrap(); + assert!(r.read && r.write && r.exec); + assert_eq!(r.kind, RegionKind::Anonymous); + } + + #[test] + fn address_outside_any_region() { + let m = MemoryMap::parse(SAMPLE); + assert!(m.region_at(0x1000).is_none()); + } + + #[test] + fn perms_display_roundtrip() { + let m = MemoryMap::parse(SAMPLE); + let libc = m.region_at(0x7f2c9c000100).unwrap(); + assert!(libc.exec && !libc.write); + assert_eq!(libc.label(), "libc.so.6"); + } +} diff --git a/src/provenance.rs b/src/provenance.rs new file mode 100644 index 0000000..60511d0 --- /dev/null +++ b/src/provenance.rs @@ -0,0 +1,236 @@ +//! Syscall provenance classification. +//! +//! Given the instruction pointer of a stopped tracee and its current memory +//! map, decide where the syscall *came from*. Benign programs only ever issue +//! syscalls from file-backed executable pages (their own `.text`, a shared +//! library, or the kernel vDSO). Everything else is, to varying degrees, the +//! fingerprint of code that was injected or reached through corrupted control +//! flow. + +use crate::maps::{MemoryMap, Region, RegionKind}; + +/// Where a syscall instruction was executing from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + /// File-backed executable page — the program's own code or a library. + LegitCode, + /// Kernel vDSO fast-syscall page. + Vdso, + /// Executing from a writable+executable page (W^X violated in place). + WxViolation, + /// Executing from the thread stack — textbook injected shellcode. + StackExec, + /// Executing from the heap — textbook injected shellcode. + HeapExec, + /// Executing from an anonymous executable page (JIT payload or staged + /// shellcode). Legitimate for JIT engines, hence its own bucket. + AnonExec, + /// The instruction pointer is not inside any mapped region. + Unmapped, + /// The containing page is not marked executable. Should be impossible for + /// a live syscall; indicates a stale map or an exotic state. + NonExec, +} + +impl Origin { + /// True for origins that never occur during legitimate execution. + pub fn is_anomalous(self) -> bool { + !matches!(self, Origin::LegitCode | Origin::Vdso) + } + + pub fn as_str(self) -> &'static str { + match self { + Origin::LegitCode => "legit-code", + Origin::Vdso => "vdso", + Origin::WxViolation => "wx-violation", + Origin::StackExec => "stack-exec", + Origin::HeapExec => "heap-exec", + Origin::AnonExec => "anon-exec", + Origin::Unmapped => "unmapped", + Origin::NonExec => "non-exec", + } + } +} + +/// Classify the origin of the instruction at `rip` against `map`. +pub fn classify_rip(map: &MemoryMap, rip: u64) -> Origin { + let Some(region) = map.region_at(rip) else { + return Origin::Unmapped; + }; + + if matches!(region.kind, RegionKind::Kernel) { + return Origin::Vdso; + } + + if !region.exec { + return Origin::NonExec; + } + + // From here down the page is executable; the question is whether it *should* + // be. Precedence matters: a writable+executable page is the strongest + // signal, so it wins even over the stack/heap labels. + if region.write { + return Origin::WxViolation; + } + + match region.kind { + RegionKind::Stack => Origin::StackExec, + RegionKind::Heap => Origin::HeapExec, + RegionKind::File(_) => Origin::LegitCode, + RegionKind::Anonymous => Origin::AnonExec, + RegionKind::Kernel => Origin::Vdso, // handled above; here for exhaustiveness + } +} + +/// Result of inspecting the stack pointer at syscall time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackState { + /// Stack pointer sits in a normal writable stack/anonymous region. + Normal, + /// Stack pointer sits in the heap — a classic ROP stack pivot target. + PivotedToHeap, + /// Stack pointer sits in a file-backed region — an impossible place for a + /// real stack, so almost certainly a pivot into attacker-chosen data. + PivotedToFile, + /// Stack pointer is not in any mapped region. + Unmapped, +} + +impl StackState { + pub fn is_anomalous(self) -> bool { + !matches!(self, StackState::Normal) + } + + pub fn as_str(self) -> &'static str { + match self { + StackState::Normal => "normal", + StackState::PivotedToHeap => "pivot-heap", + StackState::PivotedToFile => "pivot-file", + StackState::Unmapped => "unmapped", + } + } +} + +/// Inspect the stack pointer for evidence of a ROP stack pivot. +/// +/// This is a heuristic: multi-threaded programs place thread stacks in +/// anonymous mappings, which we accept as normal. What no legitimate program +/// does is run with its stack pointer inside the heap or inside a file-backed +/// image, so those are the states we flag. +pub fn classify_rsp(map: &MemoryMap, rsp: u64) -> StackState { + let Some(region) = map.region_at(rsp) else { + return StackState::Unmapped; + }; + match ®ion.kind { + RegionKind::Heap => StackState::PivotedToHeap, + RegionKind::File(_) => StackState::PivotedToFile, + _ => StackState::Normal, + } +} + +/// Decode the `prot` argument of an `mmap`/`mprotect` syscall into the +/// dangerous combinations Wraith cares about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Prot { + pub read: bool, + pub write: bool, + pub exec: bool, +} + +impl Prot { + pub fn from_raw(prot: u64) -> Self { + Prot { + read: prot & libc::PROT_READ as u64 != 0, + write: prot & libc::PROT_WRITE as u64 != 0, + exec: prot & libc::PROT_EXEC as u64 != 0, + } + } + + /// A page requested writable *and* executable at once. + pub fn is_wx(self) -> bool { + self.write && self.exec + } +} + +/// Convenience: is this region one an attacker would stage a payload in? +pub fn is_payload_capable(region: &Region) -> bool { + region.write && region.exec +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAP: &str = "\ +55f000001000-55f000002000 r-xp 00000000 08:01 1 /usr/bin/app +55f000003000-55f000010000 rw-p 00000000 00:00 0 [heap] +7f0000000000-7f0000021000 r-xp 00000000 08:01 2 /usr/lib/libc.so.6 +7f0000030000-7f0000031000 rwxp 00000000 00:00 0 +7f0000040000-7f0000041000 r-xp 00000000 00:00 0 +7ffd00000000-7ffd00021000 rw-p 00000000 00:00 0 [stack] +7ffd00100000-7ffd00104000 r-xp 00000000 00:00 0 [vdso]"; + + fn m() -> MemoryMap { + MemoryMap::parse(MAP) + } + + #[test] + fn legit_code_from_binary_and_libc() { + assert_eq!(classify_rip(&m(), 0x55f000001500), Origin::LegitCode); + assert_eq!(classify_rip(&m(), 0x7f0000000500), Origin::LegitCode); + } + + #[test] + fn vdso_is_legit() { + assert_eq!(classify_rip(&m(), 0x7ffd00100010), Origin::Vdso); + assert!(!classify_rip(&m(), 0x7ffd00100010).is_anomalous()); + } + + #[test] + fn rwx_page_is_wx_violation() { + let o = classify_rip(&m(), 0x7f0000030010); + assert_eq!(o, Origin::WxViolation); + assert!(o.is_anomalous()); + } + + #[test] + fn anon_exec_page_is_flagged_but_distinct() { + let o = classify_rip(&m(), 0x7f0000040010); + assert_eq!(o, Origin::AnonExec); + assert!(o.is_anomalous()); + } + + #[test] + fn unmapped_rip() { + assert_eq!(classify_rip(&m(), 0xdead0000), Origin::Unmapped); + } + + #[test] + fn non_exec_page() { + // Executing a syscall from the (non-exec) heap page. + assert_eq!(classify_rip(&m(), 0x55f000003100), Origin::NonExec); + } + + #[test] + fn stack_pivot_into_heap() { + assert_eq!(classify_rsp(&m(), 0x55f000004000), StackState::PivotedToHeap); + } + + #[test] + fn stack_pivot_into_file() { + assert_eq!(classify_rsp(&m(), 0x7f0000000800), StackState::PivotedToFile); + } + + #[test] + fn normal_stack_pointer() { + assert_eq!(classify_rsp(&m(), 0x7ffd00010000), StackState::Normal); + } + + #[test] + fn prot_decoding() { + let p = Prot::from_raw((libc::PROT_WRITE | libc::PROT_EXEC) as u64); + assert!(p.is_wx()); + let ro = Prot::from_raw(libc::PROT_READ as u64); + assert!(!ro.is_wx()); + } +} diff --git a/src/syscalls.rs b/src/syscalls.rs new file mode 100644 index 0000000..b7520fa --- /dev/null +++ b/src/syscalls.rs @@ -0,0 +1,122 @@ +//! A compact x86-64 syscall-number table, limited to the calls that matter for +//! post-exploitation behaviour plus the memory-management calls we must track +//! to keep the memory map fresh. Unknown numbers render as `syscall_`. + +/// The syscalls an attacker reaches for after gaining control: spawning +/// programs, touching the filesystem, opening the network, changing identity, +/// or tampering with other processes. Emitting these from a foreign origin is +/// the difference between "something is odd" and "you are being popped". +pub const SENSITIVE: &[u64] = &[ + 59, // execve + 322, // execveat + 41, // socket + 42, // connect + 49, // bind + 50, // listen + 43, // accept + 2, // open + 257, // openat + 105, // setuid + 106, // setgid + 117, // setresuid + 101, // ptrace + 56, // clone + 57, // fork + 58, // vfork +]; + +/// Memory-management syscalls after which the process memory map may have +/// changed and must be re-read. +pub const MEMORY_OPS: &[u64] = &[ + 9, // mmap + 10, // mprotect + 11, // munmap + 25, // mremap + 12, // brk + 26, // msync +]; + +pub fn is_sensitive(nr: u64) -> bool { + SENSITIVE.contains(&nr) +} + +pub fn is_memory_op(nr: u64) -> bool { + MEMORY_OPS.contains(&nr) +} + +pub fn is_mprotect(nr: u64) -> bool { + nr == 10 +} + +pub fn is_mmap(nr: u64) -> bool { + nr == 9 +} + +pub fn is_execve(nr: u64) -> bool { + nr == 59 || nr == 322 +} + +pub fn is_network_input(nr: u64) -> bool { + // read(0), recvfrom(45), recvmsg(47), readv(19) — the usual channels an + // exploit's first-stage payload arrives on. + matches!(nr, 0 | 45 | 47 | 19) +} + +/// Human-readable name for a syscall number, or `syscall_` if unknown. +pub fn name(nr: u64) -> String { + let s = match nr { + 0 => "read", + 1 => "write", + 2 => "open", + 3 => "close", + 9 => "mmap", + 10 => "mprotect", + 11 => "munmap", + 12 => "brk", + 19 => "readv", + 25 => "mremap", + 41 => "socket", + 42 => "connect", + 43 => "accept", + 45 => "recvfrom", + 47 => "recvmsg", + 49 => "bind", + 50 => "listen", + 56 => "clone", + 57 => "fork", + 58 => "vfork", + 59 => "execve", + 60 => "exit", + 101 => "ptrace", + 105 => "setuid", + 106 => "setgid", + 117 => "setresuid", + 231 => "exit_group", + 257 => "openat", + 322 => "execveat", + _ => return format!("syscall_{nr}"), + }; + s.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_known_and_unknown() { + assert_eq!(name(59), "execve"); + assert_eq!(name(10), "mprotect"); + assert_eq!(name(9999), "syscall_9999"); + } + + #[test] + fn category_predicates() { + assert!(is_sensitive(59)); + assert!(is_execve(322)); + assert!(is_memory_op(10)); + assert!(is_mprotect(10)); + assert!(is_network_input(0)); + assert!(!is_sensitive(1)); // write is not, by itself, sensitive + } +} diff --git a/src/tracer.rs b/src/tracer.rs new file mode 100644 index 0000000..2f27667 --- /dev/null +++ b/src/tracer.rs @@ -0,0 +1,276 @@ +//! The ptrace engine. +//! +//! Wraith drives a target with `PTRACE_SYSCALL`, stopping at the entry to every +//! system call. At each stop it reads the tracee's registers, refreshes the +//! memory map when a prior memory operation could have changed it, and hands +//! 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. + +use std::ffi::CString; +use std::io; + +use nix::sys::ptrace; +use nix::sys::signal::Signal; +use nix::sys::wait::{waitpid, WaitStatus}; +use nix::unistd::{execvp, fork, ForkResult, Pid}; + +use crate::detect::{Config, Detector, SyscallCtx}; +use crate::event::{Event, Kind, Severity}; +use crate::maps::MemoryMap; +use crate::syscalls; + +/// Outcome of a completed trace. +#[derive(Debug, Default, Clone)] +pub struct Summary { + pub exit_code: Option, + pub term_signal: Option, + pub syscalls_seen: u64, + pub events: u64, + pub max_severity: Option, +} + +impl Summary { + fn record(&mut self, ev: &Event) { + self.events += 1; + self.max_severity = Some(match self.max_severity { + Some(cur) => cur.max(ev.severity), + None => ev.severity, + }); + } +} + +/// How the tracee was obtained, so `run` knows how to resume it. +enum Target { + /// We forked and exec'd it; it is stopped at the post-exec SIGTRAP. + Spawned(Pid), + /// We attached to an already-running process. + Attached(Pid), +} + +pub struct Tracer { + target: Target, + detector: Detector, +} + +impl Tracer { + /// Fork, `PTRACE_TRACEME`, and exec `argv`. Returns with the child stopped + /// at its first instruction, ready for [`Tracer::run`]. + pub fn spawn(argv: &[String], cfg: Config) -> io::Result { + if argv.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty command")); + } + // Build C strings in the parent; between fork and exec we must touch + // only async-signal-safe operations. + let cargs: Vec = argv + .iter() + .map(|a| CString::new(a.as_str())) + .collect::>() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "argument contains NUL"))?; + + match unsafe { fork() }.map_err(nix_err)? { + ForkResult::Child => { + // If any of this fails we cannot safely return; die immediately. + if ptrace::traceme().is_err() { + unsafe { libc::_exit(126) }; + } + let _ = execvp(&cargs[0], &cargs); + // execvp only returns on failure. + unsafe { libc::_exit(127) }; + } + ForkResult::Parent { child } => { + // Consume the automatic stop that the exec delivers. + match waitpid(child, None).map_err(nix_err)? { + WaitStatus::Exited(_, code) => { + return Err(io::Error::other(format!( + "target exited before trace could begin (code {code}) — command not found?" + ))); + } + WaitStatus::Stopped(_, _) => {} + other => { + return Err(io::Error::other(format!( + "unexpected initial wait status: {other:?}" + ))); + } + } + set_options(child)?; + Ok(Tracer { + target: Target::Spawned(child), + detector: Detector::new(cfg), + }) + } + } + } + + /// Attach to a running process by PID. + pub fn attach(pid: i32, cfg: Config) -> io::Result { + let child = Pid::from_raw(pid); + ptrace::attach(child).map_err(nix_err)?; + // Attach delivers a SIGSTOP; wait for it. + match waitpid(child, None).map_err(nix_err)? { + WaitStatus::Stopped(_, _) => {} + other => { + return Err(io::Error::other(format!( + "unexpected status after attach: {other:?}" + ))); + } + } + set_options(child)?; + Ok(Tracer { + target: Target::Attached(child), + detector: Detector::new(cfg), + }) + } + + fn pid(&self) -> Pid { + match self.target { + Target::Spawned(p) => p, + Target::Attached(p) => p, + } + } + + /// Run the trace to completion (or until the attached process detaches), + /// invoking `on_event` for every detection. + pub fn run(mut self, mut on_event: F) -> io::Result + where + F: FnMut(&Event), + { + let pid = 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; + + // Kick the tracee toward its first syscall stop. + ptrace::syscall(pid, None).map_err(nix_err)?; + + loop { + let status = waitpid(pid, None).map_err(nix_err)?; + match status { + WaitStatus::Exited(_, code) => { + summary.exit_code = Some(code); + break; + } + WaitStatus::Signaled(_, sig, _) => { + summary.term_signal = Some(sig as i32); + break; + } + WaitStatus::PtraceSyscall(_) => { + if 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. + if syscalls::is_memory_op(nr) { + map_dirty = true; + } + } + } + at_entry = !at_entry; + ptrace::syscall(pid, 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)?; + } + WaitStatus::PtraceEvent(_, _, _) => { + ptrace::syscall(pid, None).map_err(nix_err)?; + } + WaitStatus::Continued(_) => {} + WaitStatus::StillAlive => {} + } + } + Ok(summary) + } + + /// Inspect a single syscall-entry stop. 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, + summary: &mut Summary, + on_event: &mut F, + ) -> Option + where + F: FnMut(&Event), + { + let regs = ptrace::getregs(pid).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; + } + } + let current = 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(), + 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) { + summary.record(&ev); + on_event(&ev); + } + Some(nr) + } + + fn on_signal(&self, pid: Pid, sig: Signal, summary: &mut Summary, on_event: &mut F) + where + F: FnMut(&Event), + { + let fatal = matches!( + sig, + Signal::SIGSEGV | Signal::SIGILL | Signal::SIGBUS | Signal::SIGABRT + ); + if !fatal { + return; + } + let (rip, rsp) = ptrace::getregs(pid) + .map(|r| (r.rip, r.rsp)) + .unwrap_or((0, 0)); + let ev = Event::now( + pid.as_raw(), + Severity::High, + Kind::Crash, + format!("signal:{sig:?}"), + rip, + rsp, + "fault", + format!( + "target received {sig:?} — memory-corruption fault; possible failed exploitation attempt" + ), + ); + summary.record(&ev); + on_event(&ev); + } +} + +fn set_options(pid: Pid) -> io::Result<()> { + use ptrace::Options; + // 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) +} + +fn nix_err(e: nix::errno::Errno) -> io::Error { + io::Error::from_raw_os_error(e as i32) +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..1b7d0f9 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,120 @@ +//! End-to-end tests that drive real processes through the ptrace engine. +//! +//! These exercise the whole pipeline — fork/exec, syscall stops, live +//! `/proc//maps` parsing, provenance classification, and correlation — on +//! the two helper binaries Cargo builds alongside the test: +//! * `benign` must produce zero detections (false-positive control) +//! * `shellcode-sim` must be caught executing a syscall from injected memory +//! +//! They are `x86_64`-only and require an unrestricted `ptrace` (they self-skip +//! where that is not available, e.g. a hardened CI sandbox), so a constrained +//! environment degrades to "skipped" rather than "failed". + +#![cfg(target_arch = "x86_64")] + +use std::sync::{Arc, Mutex}; + +use wraith::detect::Config; +use wraith::event::{Event, Kind, Severity}; +use wraith::tracer::{Summary, Tracer}; + +/// 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 collected = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&collected); + + let tracer = match Tracer::spawn(&[bin.to_string()], cfg) { + Ok(t) => t, + Err(e) => { + eprintln!("skipping: could not spawn tracer ({e})"); + return None; + } + }; + let summary = tracer + .run(|ev| sink.lock().unwrap().push(ev.clone())) + .expect("trace run failed"); + + let events = collected.lock().unwrap().clone(); + Some((events, summary)) +} + +#[test] +fn benign_program_produces_no_detections() { + let bin = env!("CARGO_BIN_EXE_benign"); + 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 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 benign_program_clean_even_when_auditing_sensitive() { + // With --audit-sensitive the benign socket() surfaces as an INFO + // breadcrumb, but nothing should ever exceed INFO. + let bin = env!("CARGO_BIN_EXE_benign"); + let cfg = Config { audit_sensitive: true, ..Config::default() }; + let Some((events, summary)) = trace(bin, cfg) else { + return; + }; + assert!( + events.iter().all(|e| e.severity == Severity::Info), + "benign audit run should only yield INFO events" + ); + assert!(matches!(summary.max_severity, None | Some(Severity::Info))); +} + +#[test] +fn shellcode_simulator_is_detected() { + let bin = env!("CARGO_BIN_EXE_shellcode-sim"); + let Some((events, summary)) = trace(bin, Config::default()) else { + return; + }; + + // The mmap RWX staging must be caught. + assert!( + events.iter().any(|e| e.kind == Kind::WxViolation), + "expected a W^X violation from the RWX mmap" + ); + + // The syscall executed from the injected page must be caught as a + // CRITICAL foreign-origin sensitive syscall. + 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 injected code, got: {:?}", + events.iter().map(|e| e.to_line(false)).collect::>() + ); + + // And the correlator must escalate to an exploitation chain. + assert!( + events.iter().any(|e| e.kind == Kind::ExploitationChain), + "expected an exploitation-chain verdict" + ); + + assert_eq!(summary.max_severity, Some(Severity::Critical)); +} + +#[test] +fn detection_survives_min_severity_gate() { + // Even filtering to CRITICAL-only, the payload is caught. + let bin = env!("CARGO_BIN_EXE_shellcode-sim"); + let Some((events, _)) = trace(bin, Config::default()) else { + return; + }; + let criticals: Vec<_> = events + .iter() + .filter(|e| e.severity >= Severity::Critical) + .collect(); + assert!(!criticals.is_empty(), "at least one CRITICAL event expected"); +}