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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 29 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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
Expand All @@ -152,33 +162,39 @@ 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
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.
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.

---

## Building & testing

```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

Expand Down
34 changes: 29 additions & 5 deletions demo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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
32 changes: 32 additions & 0 deletions src/bin/benign_threads.rs
Original file line number Diff line number Diff line change
@@ -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})");
}
84 changes: 84 additions & 0 deletions src/bin/mt_shellcode_sim.rs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading