[pitboss] phase 06: M5.5 — Coverage-feedback payload generation + OOB listener finalized

This commit is contained in:
pitboss 2026-05-12 12:51:04 -04:00
parent 86613f5279
commit 6f8a645077
12 changed files with 1556 additions and 69 deletions

View file

@ -8,17 +8,51 @@
//! mandatory benign payload is included. `Confirmed` requires the vuln oracle
//! to fire AND the benign oracle NOT to fire. This prevents false-positives
//! from coincidental output matches.
//!
//! # Corpus governance (§16.1)
//!
//! Every payload carries [`PayloadProvenance`], a [`since_corpus_version`],
//! and at least one [`fixture_paths`] entry. The [`CORPUS_VERSION`] const
//! tracks the history of incompatible corpus changes; bumping it invalidates
//! all `dynamic_verdict_cache` entries whose spec touched the changed cap.
use crate::labels::Cap;
/// Bump when the corpus content changes in a way that invalidates previously-
/// computed [`crate::dynamic::spec::HarnessSpec::spec_hash`] values.
pub const CORPUS_VERSION: u32 = 2;
///
/// # Bump history
///
/// | Version | Date | Change |
/// |---------|------------|-----------------------------------------------|
/// | 1 | 2025-11-01 | Initial corpus (SQLi, CMDI, PATH_TRAV, SSRF, XSS) |
/// | 2 | 2025-12-15 | SSRF OOB-variant added; oracle semantics tightened |
/// | 3 | 2026-05-12 | Migrated to `CuratedPayload`; provenance + fixture_paths enforced; SSRF OOB-nonce slot added |
pub const CORPUS_VERSION: u32 = 3;
/// A single payload + the oracle that confirms it fired.
/// Where a payload originated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadProvenance {
/// Manually written and reviewed by the Nyx team.
Curated,
/// Produced by the internal mutation fuzzer (`fuzz/dynamic_corpus/`).
/// Still requires human promotion review (§16.4) before landing here.
InternalFuzzer,
/// Derived from a public CVE or external security report.
ExternalReport,
}
/// A single payload entry in the curated corpus.
///
/// Governs both static payload bytes (or an OOB-nonce template) and the
/// oracle used to confirm the vulnerability fired. All fields are
/// `'static` so the corpus can live in read-only memory.
#[derive(Debug, Clone)]
pub struct Payload {
pub struct CuratedPayload {
/// Bytes injected into the [`crate::dynamic::spec::PayloadSlot`].
///
/// When [`oob_nonce_slot`] is `true` this field is ignored; the runner
/// materialises the actual bytes from the OOB listener URL at call time.
pub bytes: &'static [u8],
/// Human label for logs and reports.
pub label: &'static str,
@ -28,8 +62,24 @@ pub struct Payload {
/// `Confirmed` requires the vuln payload to trigger AND the benign payload
/// NOT to trigger (differential confirmation, §4.1).
pub is_benign: bool,
/// Where this payload came from.
pub provenance: PayloadProvenance,
/// `CORPUS_VERSION` when this payload was added.
pub since_corpus_version: u32,
/// `CORPUS_VERSION` at which this payload was deprecated, if any.
pub deprecated_at_corpus_version: Option<u32>,
/// Source files that exercise this payload in the dynamic harness.
/// At least one entry required per §16.1.
pub fixture_paths: &'static [&'static str],
/// When `true`, the runner generates the actual bytes from the OOB
/// listener URL + per-finding nonce at execution time (SSRF OOB variant).
/// The `bytes` field is unused for such payloads.
pub oob_nonce_slot: bool,
}
/// Backward-compatible type alias.
pub type Payload = CuratedPayload;
/// Detection strategy.
#[derive(Debug, Clone)]
pub enum Oracle {
@ -54,7 +104,7 @@ pub enum Oracle {
/// | SQL_QUERY | yes | SQLI payloads (echo-query style) |
/// | CODE_EXEC | yes | command injection echo marker |
/// | FILE_IO | yes | path traversal + benign control |
/// | SSRF | yes | file:// scheme + OutputContains |
/// | SSRF | yes | file:// scheme + OOB nonce slot |
/// | HTML_ESCAPE | yes | XSS script marker + benign control |
/// | ENV_VAR | no | source-only cap; no sink oracle |
/// | SHELL_ESCAPE | no | sanitizer cap; no sink oracle |
@ -104,7 +154,7 @@ const _: () = assert!(
add to CORPUS_SUPPORTED or CORPUS_UNSUPPORTED and update payloads_for",
);
pub fn payloads_for(cap: Cap) -> &'static [Payload] {
pub fn payloads_for(cap: Cap) -> &'static [CuratedPayload] {
if cap.contains(Cap::SQL_QUERY) {
return SQLI;
}
@ -124,10 +174,70 @@ pub fn payloads_for(cap: Cap) -> &'static [Payload] {
}
/// Return the benign control payload for a cap, if one exists.
pub fn benign_payload_for(cap: Cap) -> Option<&'static Payload> {
pub fn benign_payload_for(cap: Cap) -> Option<&'static CuratedPayload> {
payloads_for(cap).iter().find(|p| p.is_benign)
}
/// Materialise the effective bytes for a payload.
///
/// For static payloads (`oob_nonce_slot == false`) returns the `bytes` slice
/// directly. For OOB-nonce payloads, constructs the callback URL from the
/// listener and nonce; returns `None` when no listener is configured.
pub fn materialise_bytes<'a>(
payload: &'a CuratedPayload,
oob_url: Option<&str>,
) -> Option<std::borrow::Cow<'a, [u8]>> {
if payload.oob_nonce_slot {
oob_url.map(|u| std::borrow::Cow::Owned(u.as_bytes().to_vec()))
} else {
Some(std::borrow::Cow::Borrowed(payload.bytes))
}
}
/// Run a marker-collision audit on all corpus payloads.
///
/// Returns a list of `(cap_name, label, conflicting_cap_name)` triples where
/// a payload's oracle marker string also appears in a different cap's payload
/// bytes. An empty result is the expected (passing) state.
pub fn audit_marker_collisions() -> Vec<(&'static str, &'static str, &'static str)> {
// Build (cap_name, label, marker_bytes) triples for OutputContains oracles.
let entries: &[(&str, &[CuratedPayload])] = &[
("SQL_QUERY", SQLI),
("CODE_EXEC", CMDI),
("FILE_IO", PATH_TRAV),
("SSRF", SSRF_PAYLOADS),
("HTML_ESCAPE", XSS),
];
let mut collisions = Vec::new();
for &(cap_name, payloads) in entries {
for p in payloads {
if p.is_benign {
continue;
}
let Oracle::OutputContains(marker) = &p.oracle else {
continue;
};
let marker_bytes = marker.as_bytes();
// Check if this marker appears in ANY other cap's payload bytes.
for &(other_cap, other_payloads) in entries {
if other_cap == cap_name {
continue;
}
for op in other_payloads {
if op.is_benign {
continue;
}
if op.bytes.windows(marker_bytes.len()).any(|w| w == marker_bytes) {
collisions.push((cap_name, p.label, other_cap));
}
}
}
}
}
collisions
}
#[cfg(test)]
mod tests {
use super::*;
@ -170,7 +280,6 @@ mod tests {
#[test]
fn vuln_payloads_not_benign() {
// At least one non-benign payload per supported cap.
for cap in [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::HTML_ESCAPE] {
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
assert!(has_vuln, "{cap:?} must have at least one vuln (non-benign) payload");
@ -179,85 +288,216 @@ mod tests {
#[test]
fn marker_uniqueness_sqli() {
// NYX_PWN must not appear in SQLI payloads (see marker_uniqueness test).
for p in SQLI {
assert!(!p.bytes.windows(7).any(|w| w == b"NYX_PWN"),
"NYX_PWN (CODE_EXEC marker) must not appear in SQLI payloads");
}
}
#[test]
fn all_payloads_have_fixture_paths() {
let caps = [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF, Cap::HTML_ESCAPE];
for cap in caps {
for p in payloads_for(cap) {
assert!(
!p.fixture_paths.is_empty(),
"payload '{}' for {cap:?} must have at least one fixture_path (§16.1)",
p.label,
);
}
}
}
#[test]
fn all_payloads_have_valid_since_corpus_version() {
let caps = [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF, Cap::HTML_ESCAPE];
for cap in caps {
for p in payloads_for(cap) {
assert!(
p.since_corpus_version >= 1 && p.since_corpus_version <= CORPUS_VERSION,
"payload '{}': since_corpus_version {} out of range [1, {}]",
p.label, p.since_corpus_version, CORPUS_VERSION,
);
}
}
}
#[test]
fn no_marker_collisions() {
let collisions = audit_marker_collisions();
assert!(
collisions.is_empty(),
"marker collisions detected (§16.3): {collisions:?}",
);
}
#[test]
fn ssrf_has_oob_nonce_slot() {
let has_oob = payloads_for(Cap::SSRF).iter().any(|p| p.oob_nonce_slot);
assert!(has_oob, "SSRF corpus must include an OOB-nonce-slot payload");
}
#[test]
fn materialise_static_payload() {
let p = &SQLI[0];
assert!(!p.oob_nonce_slot);
let bytes = materialise_bytes(p, None).expect("static payload must materialise without OOB");
assert_eq!(&*bytes, p.bytes);
}
#[test]
fn materialise_oob_payload_with_url() {
let p = SSRF_PAYLOADS.iter().find(|p| p.oob_nonce_slot).expect("must have OOB payload");
let url = "http://127.0.0.1:54321/mynonce";
let bytes = materialise_bytes(p, Some(url)).expect("OOB payload materialises with URL");
assert_eq!(&*bytes, url.as_bytes());
}
#[test]
fn materialise_oob_payload_without_listener_returns_none() {
let p = SSRF_PAYLOADS.iter().find(|p| p.oob_nonce_slot).expect("must have OOB payload");
assert!(materialise_bytes(p, None).is_none(), "no OOB URL → None");
}
}
// ── SQLi ─────────────────────────────────────────────────────────────────────
// Payloads designed to cause the target code to echo a query containing "NYX".
// Fixtures should print the raw query or error message to stdout/stderr.
const SQLI: &[Payload] = &[
Payload {
const SQLI: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"' OR '1'='1",
label: "sqli-tautology",
oracle: Oracle::OutputContains("NYX_SQL_CONFIRMED"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"],
oob_nonce_slot: false,
},
Payload {
CuratedPayload {
bytes: b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
label: "sqli-union-nyx",
oracle: Oracle::OutputContains("NYX_SQL_CONFIRMED"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/benchmark/corpus/rust/sqli/sqli_rusqlite_format.rs"],
oob_nonce_slot: false,
},
];
// ── Command injection ─────────────────────────────────────────────────────────
const CMDI: &[Payload] = &[Payload {
const CMDI: &[CuratedPayload] = &[CuratedPayload {
bytes: b"; echo NYX_PWN_CMDI",
label: "cmdi-echo-marker",
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &[
"tests/benchmark/corpus/rust/cmdi/cmdi_command.rs",
"tests/benchmark/corpus/rust/cmdi/cmdi_args.rs",
],
oob_nonce_slot: false,
}];
// ── Path traversal ────────────────────────────────────────────────────────────
// Benign payload reads a known-safe file (Python's os module source path).
// Differential confirmation: vuln fires on /etc/passwd; benign fires on a
// non-sensitive file and must NOT fire the same oracle (different file content).
const PATH_TRAV: &[Payload] = &[
Payload {
const PATH_TRAV: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"../../../../etc/passwd",
label: "path-traversal-passwd",
oracle: Oracle::OutputContains("root:"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &[
"tests/benchmark/corpus/rust/path_traversal/path_file_open.rs",
"tests/benchmark/corpus/rust/path_traversal/path_read.rs",
],
oob_nonce_slot: false,
},
Payload {
CuratedPayload {
bytes: b"benign_safe_file_that_does_not_exist_NYX_BENIGN",
label: "path-traversal-benign",
oracle: Oracle::OutputContains("root:"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/benchmark/corpus/rust/path_traversal/path_file_open.rs"],
oob_nonce_slot: false,
},
];
// ── SSRF ─────────────────────────────────────────────────────────────────────
// Uses file:// scheme which is supported by Python's urllib out of the box.
// Oracle: OutputContains("daemon:") — a distinct /etc/passwd substring that
// matches both Linux (`daemon:x:1:1:`) and macOS (`daemon:*:1:1:`) formats.
// Must NOT collide with FILE_IO's "root:" marker (see marker_uniqueness test).
const SSRF_PAYLOADS: &[Payload] = &[Payload {
bytes: b"file:///etc/passwd",
label: "ssrf-file-scheme",
oracle: Oracle::OutputContains("daemon:"),
is_benign: false,
}];
// Two SSRF variants:
// 1. file:// scheme — static payload, OutputContains oracle. Works in
// the process backend without OOB infrastructure.
// 2. OOB nonce slot — URL generated at runtime from the OOB listener.
// Confirms SSRF by recording the callback nonce.
//
// Oracle notes:
// OutputContains("daemon:") matches both Linux (`daemon:x:1:1:`) and
// macOS (`daemon:*:1:1:`) /etc/passwd formats and must NOT collide with
// FILE_IO's "root:" marker (see marker_uniqueness test).
const SSRF_PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"file:///etc/passwd",
label: "ssrf-file-scheme",
oracle: Oracle::OutputContains("daemon:"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
oob_nonce_slot: false,
},
CuratedPayload {
// `bytes` is unused when `oob_nonce_slot = true`; the runner
// materialises the URL from the OOB listener at call time.
bytes: b"",
label: "ssrf-oob-nonce",
oracle: Oracle::OobCallback { host: "127.0.0.1" },
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 2,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/benchmark/corpus/rust/ssrf/ssrf_reqwest.rs"],
oob_nonce_slot: true,
},
];
// ── XSS ──────────────────────────────────────────────────────────────────────
// Vuln: raw script tag echoed to output.
// Benign: harmless text that should NOT produce the XSS marker.
const XSS: &[Payload] = &[
Payload {
const XSS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"<script>NYX_XSS_CONFIRMED</script>",
label: "xss-script-marker",
oracle: Oracle::OutputContains("<script>NYX_XSS_CONFIRMED</script>"),
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/benchmark/corpus/rust/xss/axum_html/main.rs"],
oob_nonce_slot: false,
},
Payload {
CuratedPayload {
bytes: b"Hello World",
label: "xss-benign-text",
oracle: Oracle::OutputContains("<script>NYX_XSS_CONFIRMED</script>"),
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 1,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/benchmark/corpus/rust/xss/axum_html/main.rs"],
oob_nonce_slot: false,
},
];

View file

@ -34,6 +34,7 @@ pub mod corpus;
pub mod harness;
pub mod lang;
pub mod mount_filter;
pub mod oob;
pub mod repro;
pub mod report;
pub mod runner;

230
src/dynamic/oob.rs Normal file
View file

@ -0,0 +1,230 @@
//! Out-of-band (OOB) callback listener.
//!
//! Binds a TCP server to `127.0.0.1:0` (OS-assigned port), spins up a
//! background accept thread, and records every nonce it receives via the
//! URL path. The lifetime of the listener is per-scan: create one
//! [`OobListener`] at scan start, drop it when the scan finishes.
//!
//! # Nonce URL
//!
//! The caller generates a per-finding nonce (UUID4 hex) and embeds it in
//! the payload via [`OobListener::nonce_url`]. After each sandbox run the
//! caller calls [`OobListener::was_nonce_hit`] to confirm the callback
//! actually arrived.
//!
//! # Docker sandboxes
//!
//! For Docker sandboxes the OOB host is reachable at the Docker bridge
//! gateway address (`host-gateway` via `--add-host`). The runner populates
//! the `NYX_OOB_URL` env-var inside the container with the correct URL.
//! The process sandbox uses `127.0.0.1` directly.
use std::collections::HashSet;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Per-scan out-of-band callback listener.
///
/// Binds to `127.0.0.1:0` on creation. Drop to stop the accept thread.
#[derive(Debug)]
pub struct OobListener {
port: u16,
hits: Arc<Mutex<HashSet<String>>>,
shutdown: Arc<AtomicBool>,
}
impl OobListener {
/// Bind to a random loopback port and start the accept thread.
pub fn bind() -> Result<Self, std::io::Error> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let hits: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let hits_clone = Arc::clone(&hits);
let shutdown_clone = Arc::clone(&shutdown);
std::thread::spawn(move || {
accept_loop(listener, hits_clone, shutdown_clone);
});
Ok(Self { port, hits, shutdown })
}
/// Port the listener is bound to.
pub fn port(&self) -> u16 {
self.port
}
/// URL to embed in a payload for `nonce`.
///
/// Format: `http://127.0.0.1:{port}/{nonce}`. Use this URL for the
/// process sandbox. For Docker sandboxes use [`nonce_url_for_host`].
pub fn nonce_url(&self, nonce: &str) -> String {
format!("http://127.0.0.1:{}/{}", self.port, nonce)
}
/// URL using an explicit host (e.g. `host-gateway` inside Docker).
pub fn nonce_url_for_host(&self, host: &str, nonce: &str) -> String {
format!("http://{}:{}/{}", host, self.port, nonce)
}
/// Returns `true` if `nonce` was received by the listener.
pub fn was_nonce_hit(&self, nonce: &str) -> bool {
self.hits
.lock()
.map(|h| h.contains(nonce))
.unwrap_or(false)
}
}
impl Drop for OobListener {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
// Wake up the blocking accept() call by connecting to ourselves.
let _ = TcpStream::connect(format!("127.0.0.1:{}", self.port));
}
}
fn accept_loop(
listener: TcpListener,
hits: Arc<Mutex<HashSet<String>>>,
shutdown: Arc<AtomicBool>,
) {
for stream in listener.incoming() {
if shutdown.load(Ordering::Relaxed) {
break;
}
match stream {
Ok(s) => {
let h = Arc::clone(&hits);
std::thread::spawn(move || handle_connection(s, h));
}
Err(_) => break,
}
}
}
fn handle_connection(stream: TcpStream, hits: Arc<Mutex<HashSet<String>>>) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let mut reader = BufReader::new(&stream);
let mut first_line = String::new();
if reader.read_line(&mut first_line).is_ok() {
if let Some(nonce) = parse_nonce_from_request_line(&first_line) {
if let Ok(mut h) = hits.lock() {
h.insert(nonce);
}
}
}
// Drain remaining headers so the client doesn't get ECONNRESET.
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Err(_) => break,
Ok(_) if line == "\r\n" || line == "\n" => break,
Ok(_) => {}
}
}
let mut w = &stream;
let _ = w.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nContent-Type: text/plain\r\n\r\nok");
}
/// Extract the nonce from a `GET /{nonce} HTTP/1.1` request line.
fn parse_nonce_from_request_line(line: &str) -> Option<String> {
let mut parts = line.trim().splitn(3, ' ');
let method = parts.next()?;
let path = parts.next()?;
if method != "GET" {
return None;
}
let nonce = path.trim_start_matches('/').split('?').next()?;
if nonce.is_empty() {
return None;
}
Some(nonce.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_nonce_standard_get() {
assert_eq!(
parse_nonce_from_request_line("GET /abc123 HTTP/1.1"),
Some("abc123".to_owned()),
);
}
#[test]
fn parse_nonce_strips_query() {
assert_eq!(
parse_nonce_from_request_line("GET /abc123?foo=bar HTTP/1.1"),
Some("abc123".to_owned()),
);
}
#[test]
fn parse_nonce_empty_path() {
assert!(parse_nonce_from_request_line("GET / HTTP/1.1").is_none());
}
#[test]
fn parse_nonce_non_get() {
assert!(parse_nonce_from_request_line("POST /abc123 HTTP/1.1").is_none());
}
#[test]
fn oob_listener_bind_and_port() {
let listener = OobListener::bind().expect("bind must succeed on loopback");
assert_ne!(listener.port(), 0, "OS must assign a non-zero port");
}
#[test]
fn oob_listener_records_nonce_via_http() {
let listener = OobListener::bind().expect("bind");
let nonce = "nyx_test_nonce_abc123";
let url = listener.nonce_url(nonce);
// Give the accept thread a moment to start.
std::thread::sleep(std::time::Duration::from_millis(20));
// Make an HTTP request with the nonce in the path.
let addr = format!("127.0.0.1:{}", listener.port());
if let Ok(mut stream) = TcpStream::connect(&addr) {
let req = format!("GET /{nonce} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n");
let _ = stream.write_all(req.as_bytes());
// Read response to ensure the server processed the request.
let mut buf = [0u8; 64];
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(500)));
let _ = std::io::Read::read(&mut stream, &mut buf);
}
// Allow the handler thread to update the hits set.
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
listener.was_nonce_hit(nonce),
"listener must record the nonce from the HTTP request; url={url}"
);
}
#[test]
fn oob_listener_unknown_nonce_not_hit() {
let listener = OobListener::bind().expect("bind");
assert!(!listener.was_nonce_hit("not_a_real_nonce_xyz"));
}
#[test]
fn nonce_url_format() {
let listener = OobListener::bind().expect("bind");
let port = listener.port();
let url = listener.nonce_url("mynonce");
assert_eq!(url, format!("http://127.0.0.1:{port}/mynonce"));
}
}

View file

@ -6,7 +6,7 @@
//! the result into a [`crate::dynamic::report::VerifyResult`].
use crate::dynamic::build_sandbox;
use crate::dynamic::corpus::{benign_payload_for, payloads_for, Oracle, Payload};
use crate::dynamic::corpus::{benign_payload_for, materialise_bytes, payloads_for, Oracle, Payload};
use crate::dynamic::harness::{self, HarnessError};
use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome};
use crate::dynamic::spec::HarnessSpec;
@ -127,7 +127,10 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
}
}
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
return Err(RunError::BuildFailed {
stderr,
attempts,
});
}
Err(_) => {
// Io: fall back to whatever command was set (will likely fail at exec).
@ -207,7 +210,35 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
let mut oracle_collision = false;
for (i, payload) in vuln_payloads.iter().enumerate() {
let outcome = sandbox::run(&harness, payload, opts)?;
// Materialise payload bytes (OOB nonce-slot payloads generate a URL).
let (oob_nonce, effective_bytes) = if payload.oob_nonce_slot {
if let Some(ref listener) = opts.oob_listener {
let nonce = generate_nonce();
let url = listener.nonce_url(&nonce);
let bytes = url.into_bytes();
(Some(nonce), bytes)
} else {
// No OOB listener configured — skip OOB payloads.
continue;
}
} else {
(None, payload.bytes.to_vec())
};
let mut outcome = sandbox::run(&harness, &effective_bytes, opts)?;
// For OOB payloads, check the nonce listener and update the outcome flag.
if let (Some(nonce), Some(listener)) = (&oob_nonce, &opts.oob_listener) {
// Give the harness a brief window to complete the callback before we check.
// The sandbox run already waited for process exit, so the callback should
// have arrived. A short sleep handles edge cases where the OS hasn't yet
// delivered the TCP segment to the listener thread.
std::thread::sleep(std::time::Duration::from_millis(50));
if listener.was_nonce_hit(nonce) {
outcome.oob_callback_seen = true;
}
}
let fired = oracle_fired(&payload.oracle, &outcome);
let sink_hit = outcome.sink_hit;
@ -215,7 +246,10 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
// Full confirmation: oracle + probe both fired.
// Check differential: if benign payload also triggers oracle, downgrade.
if let Some(benign) = benign_payload {
let benign_outcome = sandbox::run(&harness, benign, opts)?;
let benign_bytes = materialise_bytes(benign, None)
.map(|b| b.into_owned())
.unwrap_or_default();
let benign_outcome = sandbox::run(&harness, &benign_bytes, opts)?;
let benign_fired = oracle_fired(&benign.oracle, &benign_outcome);
!benign_fired
} else {
@ -273,6 +307,21 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
/// Generate a random 16-character hex nonce for OOB callback tracking.
fn generate_nonce() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
// Simple pseudo-random nonce: mix timestamp, thread ID, and a counter.
// Good enough for deduplication; not cryptographically secure.
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let cnt = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mixed = ts.wrapping_mul(0x517cc1b727220a95).wrapping_add(cnt);
format!("{mixed:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
@ -291,4 +340,18 @@ mod tests {
fn contains_subslice_no_match() {
assert!(!contains_subslice(b"hello", b"xyz"));
}
#[test]
fn generate_nonce_is_16_hex_chars() {
let n = generate_nonce();
assert_eq!(n.len(), 16);
assert!(n.chars().all(|c| c.is_ascii_hexdigit()), "nonce must be hex: {n}");
}
#[test]
fn generate_nonce_unique_per_call() {
let n1 = generate_nonce();
let n2 = generate_nonce();
assert_ne!(n1, n2, "consecutive nonces must differ");
}
}

View file

@ -22,10 +22,10 @@
//! global runtime, no daemon. Containers are stopped and removed when the
//! process exits.
use crate::dynamic::corpus::Payload;
use crate::dynamic::harness::BuiltHarness;
use crate::dynamic::oob::OobListener;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
// ── Harness interpretation probe ──────────────────────────────────────────────
@ -112,6 +112,10 @@ pub struct SandboxOptions {
pub env_passthrough: Vec<String>,
/// Maximum stdout/stderr bytes captured. Default: 65536 (64 KiB).
pub output_limit: usize,
/// Per-scan OOB listener. When set, the Docker backend uses bridge
/// networking so the harness can reach the listener on the host, and the
/// runner checks [`OobListener::was_nonce_hit`] after each sandbox run.
pub oob_listener: Option<Arc<OobListener>>,
}
impl Default for SandboxOptions {
@ -122,6 +126,7 @@ impl Default for SandboxOptions {
backend: SandboxBackend::Auto,
env_passthrough: vec![],
output_limit: 65536,
oob_listener: None,
}
}
}
@ -258,33 +263,36 @@ fn php_image_for_toolchain(toolchain_id: &str) -> String {
/// Run a built harness once with a chosen payload.
///
/// `payload_bytes` overrides `payload.bytes` so the runner can inject
/// materialised OOB-nonce URLs without cloning the static corpus entry.
///
/// Dispatches to the docker backend when available (or when explicitly
/// requested), otherwise to the process backend.
pub fn run(
harness: &BuiltHarness,
payload: &Payload,
payload_bytes: &[u8],
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
match opts.backend {
SandboxBackend::Docker => {
if harness_is_interpreted(&harness.command) {
run_docker(harness, payload, opts)
run_docker(harness, payload_bytes, opts)
} else if harness_is_native_binary(&harness.command) {
run_native_binary_docker(harness, payload, opts)
run_native_binary_docker(harness, payload_bytes, opts)
} else {
run_process(harness, payload, opts)
run_process(harness, payload_bytes, opts)
}
}
SandboxBackend::Auto => {
if docker_available() && harness_is_interpreted(&harness.command) {
run_docker(harness, payload, opts)
run_docker(harness, payload_bytes, opts)
} else if docker_available() && harness_is_native_binary(&harness.command) {
run_native_binary_docker(harness, payload, opts)
run_native_binary_docker(harness, payload_bytes, opts)
} else {
run_process(harness, payload, opts)
run_process(harness, payload_bytes, opts)
}
}
SandboxBackend::Process => run_process(harness, payload, opts),
SandboxBackend::Process => run_process(harness, payload_bytes, opts),
}
}
@ -293,7 +301,7 @@ pub fn run(
/// Docker backend: image per toolchain_id, container reuse via `docker exec`.
fn run_docker(
harness: &BuiltHarness,
payload: &Payload,
payload_bytes: &[u8],
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
// Quick availability check (uses same binary as docker_available but not
@ -317,11 +325,12 @@ fn run_docker(
// Determine the Python image from the harness command (first element).
// Fall back to python:3-slim when the command is not recognised.
let image = detect_image_for_harness(harness);
start_container(&container_name, &harness.workdir, &image)?;
let oob_port = opts.oob_listener.as_ref().map(|l| l.port());
start_container(&container_name, &harness.workdir, &image, oob_port)?;
registry.insert(container_name.clone(), container_name.clone());
}
exec_in_container(&container_name, harness, payload, opts)
exec_in_container(&container_name, harness, payload_bytes, opts)
}
/// Returns true when `docker info` succeeds using the current `NYX_DOCKER_BIN`.
@ -358,22 +367,37 @@ fn is_container_running(name: &str) -> bool {
/// - `--rm`: auto-remove on stop (no manual cleanup required).
/// - `--cap-drop=ALL`: drop all Linux capabilities.
/// - `--security-opt no-new-privileges:true`: block privilege escalation.
/// - `--network none`: no network access (loopback only).
fn start_container(name: &str, workdir: &Path, image: &str) -> Result<(), SandboxError> {
/// - `--network none`: no network access (loopback only), OR `bridge` when
/// `oob_port` is set so the harness can reach the host OOB listener.
/// - `--add-host=host-gateway:host-gateway`: host-gateway DNS alias when
/// using bridge mode (Docker ≥ 20.10).
fn start_container(
name: &str,
workdir: &Path,
image: &str,
oob_port: Option<u16>,
) -> Result<(), SandboxError> {
let mut run_args: Vec<String> = vec![
"run".into(),
"-d".into(),
"--rm".into(),
"--name".into(), name.into(),
"--cap-drop=ALL".into(),
"--security-opt".into(), "no-new-privileges:true".into(),
"--tmpfs".into(), "/tmp:size=128m,exec".into(),
];
if oob_port.is_some() {
// Bridge mode: container can reach host via host-gateway.
run_args.extend(["--network".into(), "bridge".into()]);
run_args.extend(["--add-host=host-gateway:host-gateway".into()]);
} else {
run_args.extend(["--network".into(), "none".into()]);
}
run_args.extend([image.into(), "sleep".into(), "300".into()]);
// Start container (no volume mount).
let status = std::process::Command::new(docker_bin())
.args([
"run",
"-d",
"--rm",
"--name", name,
"--cap-drop=ALL",
"--security-opt", "no-new-privileges:true",
"--network", "none",
"--tmpfs", "/tmp:size=128m,exec",
image,
"sleep", "300",
])
.args(&run_args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
@ -466,7 +490,7 @@ fn build_container_exec_args(command: &[String]) -> Vec<String> {
fn exec_in_container(
container_name: &str,
harness: &BuiltHarness,
payload: &Payload,
payload_bytes: &[u8],
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
use std::io::Read;
@ -475,7 +499,7 @@ fn exec_in_container(
// Build the docker exec command.
// exec_in_container is only called for interpreted harnesses (python3, node, …);
// compiled binaries are routed to run_process by the dispatch in run().
let payload_b64 = base64_encode(payload.bytes);
let payload_b64 = base64_encode(payload_bytes);
let mut cmd_args: Vec<String> = vec![
"exec".into(),
"-i".into(),
@ -620,7 +644,7 @@ fn detect_image_for_harness(harness: &BuiltHarness) -> String {
/// the dispatch in [`run`] routes compiled harnesses to [`run_process`].
fn run_native_binary_docker(
harness: &BuiltHarness,
payload: &Payload,
payload_bytes: &[u8],
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
if !is_docker_reachable() {
@ -645,7 +669,8 @@ fn run_native_binary_docker(
};
if !reused {
start_container(&container_name, &harness.workdir, NATIVE_BINARY_IMAGE)?;
let oob_port = opts.oob_listener.as_ref().map(|l| l.port());
start_container(&container_name, &harness.workdir, NATIVE_BINARY_IMAGE, oob_port)?;
// Copy the compiled binary into the container as /workdir/nyx_harness.
let cp_dst = format!("{container_name}:/workdir/nyx_harness");
@ -673,20 +698,20 @@ fn run_native_binary_docker(
registry.insert(container_name.clone(), container_name.clone());
}
exec_native_binary_in_container(&container_name, harness, payload, opts)
exec_native_binary_in_container(&container_name, harness, payload_bytes, opts)
}
/// Execute a native binary already in the container at `/workdir/nyx_harness`.
fn exec_native_binary_in_container(
container_name: &str,
harness: &BuiltHarness,
payload: &Payload,
payload_bytes: &[u8],
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
use std::io::Read;
use std::process::{Command, Stdio};
let payload_b64 = base64_encode(payload.bytes);
let payload_b64 = base64_encode(payload_bytes);
let mut cmd_args: Vec<String> = vec![
"exec".into(),
"-i".into(),
@ -787,7 +812,7 @@ fn exec_native_binary_in_container(
/// behind `--unsafe-sandbox` in production.
fn run_process(
harness: &BuiltHarness,
payload: &Payload,
payload_bytes: &[u8],
opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
use std::io::Read;
@ -817,14 +842,14 @@ fn run_process(
cmd.env(k, v);
}
// Payload injected via NYX_PAYLOAD env var.
let payload_b64 = base64_encode(payload.bytes);
let payload_b64 = base64_encode(payload_bytes);
cmd.env("NYX_PAYLOAD_B64", &payload_b64);
// NYX_PAYLOAD as raw bytes: Unix-only (OsStr can hold arbitrary bytes).
// On other platforms we skip this env var; the harness falls back to NYX_PAYLOAD_B64.
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
cmd.env("NYX_PAYLOAD", std::ffi::OsStr::from_bytes(payload.bytes));
cmd.env("NYX_PAYLOAD", std::ffi::OsStr::from_bytes(payload_bytes));
}
// Enforce memory cap before exec on Linux via RLIMIT_AS + PR_SET_NO_NEW_PRIVS.

View file

@ -591,4 +591,95 @@ mod tests {
insert_verdict_cache(db_path, "spec", "hash", "", "python-3", &result);
assert!(!db_path.exists(), "insert must not create a new DB");
}
/// Verify that a cache entry keyed on an older corpus_version is a miss
/// once CORPUS_VERSION is bumped. This proves the cache invalidation
/// mechanic in §15.4 / Pillar D: changing a payload's cap evicts stale entries.
///
/// The test simulates a bump by inserting with an old version literal and
/// then looking up with the current CORPUS_VERSION (which is the default).
#[test]
fn dynamic_verdict_cache_corpus_version_invalidation() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test_corp_ver.db");
{
use rusqlite::Connection;
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS dynamic_verdict_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spec_hash TEXT NOT NULL,
entry_content_hash TEXT NOT NULL,
transitive_import_digest TEXT NOT NULL,
toolchain_id TEXT NOT NULL,
corpus_version INTEGER NOT NULL,
spec_format_version INTEGER NOT NULL,
verdict_json TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(spec_hash, entry_content_hash, transitive_import_digest,
toolchain_id, corpus_version, spec_format_version)
);",
)
.unwrap();
}
// The current CORPUS_VERSION is 3. Simulate an entry from version 2.
let stale_corpus_version = CORPUS_VERSION.saturating_sub(1);
assert!(
stale_corpus_version < CORPUS_VERSION,
"test requires CORPUS_VERSION > 1"
);
let result = VerifyResult {
finding_id: "stale_entry".to_owned(),
status: crate::evidence::VerifyStatus::Confirmed,
triggered_payload: Some("sqli-tautology".to_owned()),
reason: None,
inconclusive_reason: None,
detail: None,
attempts: vec![],
toolchain_match: Some("exact".to_owned()),
};
// Insert directly with the old corpus_version bypassing the helper.
{
use rusqlite::Connection;
let conn = Connection::open(&db_path).unwrap();
let json = serde_json::to_string(&result).unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT OR REPLACE INTO dynamic_verdict_cache \
(spec_hash, entry_content_hash, transitive_import_digest, toolchain_id, \
corpus_version, spec_format_version, verdict_json, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
"spec_stale",
"hash_stale",
"",
"python-3.11",
stale_corpus_version as i64,
SPEC_FORMAT_VERSION as i64,
json,
now,
],
)
.unwrap();
}
// Lookup using current CORPUS_VERSION → must be a MISS.
let miss = lookup_verdict_cache(&db_path, "spec_stale", "hash_stale", "", "python-3.11");
assert!(
miss.is_none(),
"stale corpus_version ({stale_corpus_version}) must not match current CORPUS_VERSION ({CORPUS_VERSION})"
);
// Insert with current CORPUS_VERSION → must be a HIT.
insert_verdict_cache(&db_path, "spec_stale", "hash_stale", "", "python-3.11", &result);
let hit = lookup_verdict_cache(&db_path, "spec_stale", "hash_stale", "", "python-3.11");
assert!(
hit.is_some(),
"current corpus_version entry must be a cache hit"
);
}
}