mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
491 lines
18 KiB
Rust
491 lines
18 KiB
Rust
//! Verdict oracle — how a sandbox run becomes Confirmed / NotConfirmed.
|
|
//!
|
|
//! Phase 06 (Track C.1) introduces the structured [`Oracle::SinkProbe`]
|
|
//! path: each curated payload supplies a small set of
|
|
//! [`ProbePredicate`]s; the runner drains the
|
|
//! [`crate::dynamic::probe::ProbeChannel`] after every payload run and
|
|
//! evaluates the predicates against the captured arguments. A run is
|
|
//! Confirmed iff at least one drained record satisfies *every* predicate.
|
|
//!
|
|
//! Phase 08 (Track C.4) replaces the coarse [`Oracle::Crash`] with
|
|
//! [`Oracle::SinkCrash`]. The new variant only confirms when a probe
|
|
//! observation in the channel carries
|
|
//! [`crate::dynamic::probe::ProbeKind::Crash { signal }`] *and* the captured
|
|
//! signal is present in the payload's [`SignalSet`] — i.e. the SIGSEGV /
|
|
//! SIGABRT / etc. must have been caught by a sink-site signal handler, not
|
|
//! by random crashing setup code. A process-level abort that escapes the
|
|
//! sink handler leaves no Crash probe, the oracle does not fire, and the
|
|
//! runner downgrades the verdict to
|
|
//! [`crate::evidence::InconclusiveReason::UnrelatedCrash`] instead of
|
|
//! stamping `Confirmed`.
|
|
//!
|
|
//! The legacy [`Oracle::OutputContains`] and [`Oracle::Crash`] paths are
|
|
//! retained for fixtures that pre-date Phase 06 / Phase 08 and migrated
|
|
//! downstream; both are marked `#[deprecated]` so the compiler nags every
|
|
//! new use-site.
|
|
|
|
use crate::dynamic::probe::{ProbeKind, SinkProbe};
|
|
use crate::dynamic::sandbox::SandboxOutcome;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// POSIX-style signal name carried inside [`ProbeKind::Crash`] and the
|
|
/// [`Oracle::SinkCrash`] match set.
|
|
///
|
|
/// Restricted to the signals a sink-site handler can plausibly catch and
|
|
/// route back through the probe channel. Anything outside this enum (e.g.
|
|
/// `SIGKILL`, `SIGSTOP`) cannot be caught by a userspace handler and is
|
|
/// therefore not modellable as a confirmable crash signal.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum Signal {
|
|
/// Segmentation fault.
|
|
#[serde(rename = "SIGSEGV", alias = "Sigsegv", alias = "SEGV")]
|
|
Sigsegv,
|
|
/// Abort (typically from `abort(3)` or `assert(3)`).
|
|
#[serde(rename = "SIGABRT", alias = "Sigabrt", alias = "ABRT")]
|
|
Sigabrt,
|
|
/// Bus error (misaligned access, mmap fault).
|
|
#[serde(rename = "SIGBUS", alias = "Sigbus", alias = "BUS")]
|
|
Sigbus,
|
|
/// Floating-point exception (incl. integer divide-by-zero on x86).
|
|
#[serde(rename = "SIGFPE", alias = "Sigfpe", alias = "FPE")]
|
|
Sigfpe,
|
|
/// Illegal instruction.
|
|
#[serde(rename = "SIGILL", alias = "Sigill", alias = "ILL")]
|
|
Sigill,
|
|
}
|
|
|
|
impl Signal {
|
|
/// Bit position of `self` inside a [`SignalSet`]. Stable across builds
|
|
/// so the wire format of a serialised [`SignalSet`] stays compatible.
|
|
pub const fn bit(self) -> u8 {
|
|
match self {
|
|
Signal::Sigsegv => 0,
|
|
Signal::Sigabrt => 1,
|
|
Signal::Sigbus => 2,
|
|
Signal::Sigfpe => 3,
|
|
Signal::Sigill => 4,
|
|
}
|
|
}
|
|
|
|
/// Render a [`Signal`] as the conventional uppercase POSIX name (e.g.
|
|
/// `"SIGSEGV"`). Used by the per-language probe shims so their
|
|
/// captured `signal` strings are identical to what the host-side
|
|
/// [`Signal::from_name`] decoder expects.
|
|
pub const fn as_name(self) -> &'static str {
|
|
match self {
|
|
Signal::Sigsegv => "SIGSEGV",
|
|
Signal::Sigabrt => "SIGABRT",
|
|
Signal::Sigbus => "SIGBUS",
|
|
Signal::Sigfpe => "SIGFPE",
|
|
Signal::Sigill => "SIGILL",
|
|
}
|
|
}
|
|
|
|
/// Inverse of [`as_name`](Signal::as_name). Matches both the canonical
|
|
/// uppercase form and a couple of common variants emitted by language
|
|
/// runtimes (`"sigsegv"`, `"Segmentation fault"`). Returns `None` for
|
|
/// signals the oracle does not model.
|
|
pub fn from_name(s: &str) -> Option<Signal> {
|
|
let upper = s.trim().to_ascii_uppercase();
|
|
match upper.as_str() {
|
|
"SIGSEGV" | "SEGV" | "SEGMENTATION FAULT" => Some(Signal::Sigsegv),
|
|
"SIGABRT" | "ABRT" | "ABORTED" => Some(Signal::Sigabrt),
|
|
"SIGBUS" | "BUS" | "BUS ERROR" => Some(Signal::Sigbus),
|
|
"SIGFPE" | "FPE" | "FLOATING POINT EXCEPTION" => Some(Signal::Sigfpe),
|
|
"SIGILL" | "ILL" | "ILLEGAL INSTRUCTION" => Some(Signal::Sigill),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bitset of [`Signal`]s the [`Oracle::SinkCrash`] variant treats as
|
|
/// confirmable. Stored as a `u8` so a `const`-declared corpus entry can
|
|
/// build the set without runtime allocation.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SignalSet(u8);
|
|
|
|
impl SignalSet {
|
|
/// Empty set — no signal is confirmable. Mostly useful in tests as a
|
|
/// "this oracle should never fire" baseline.
|
|
pub const fn empty() -> Self {
|
|
Self(0)
|
|
}
|
|
|
|
/// Set built from a slice of [`Signal`]s, callable from `const`
|
|
/// context. Order-independent; duplicates are collapsed.
|
|
pub const fn from_slice(sigs: &[Signal]) -> Self {
|
|
let mut bits = 0u8;
|
|
let mut i = 0;
|
|
while i < sigs.len() {
|
|
bits |= 1 << sigs[i].bit();
|
|
i += 1;
|
|
}
|
|
Self(bits)
|
|
}
|
|
|
|
/// `SignalSet` containing every modelled signal. Default for payloads
|
|
/// whose crash-on-arbitrary-input is the actual vulnerability (e.g. C
|
|
/// memory corruption fuzzed via libFuzzer).
|
|
pub const fn all() -> Self {
|
|
Self::from_slice(&[
|
|
Signal::Sigsegv,
|
|
Signal::Sigabrt,
|
|
Signal::Sigbus,
|
|
Signal::Sigfpe,
|
|
Signal::Sigill,
|
|
])
|
|
}
|
|
|
|
/// True iff `sig` is in the set.
|
|
pub const fn contains(self, sig: Signal) -> bool {
|
|
(self.0 & (1 << sig.bit())) != 0
|
|
}
|
|
|
|
/// True iff the set is empty.
|
|
pub const fn is_empty(self) -> bool {
|
|
self.0 == 0
|
|
}
|
|
}
|
|
|
|
/// Predicate evaluated against a single [`SinkProbe`] when the oracle is
|
|
/// [`Oracle::SinkProbe`].
|
|
///
|
|
/// Fields use `&'static str` so the corpus can declare predicate slices
|
|
/// in `const` context — there is no allocation cost at scan time.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ProbePredicate {
|
|
/// Captured arg at `index` contains `needle` as a substring. String
|
|
/// view of the arg is taken via [`super::probe::ProbeArg::as_str`].
|
|
ArgContains { index: usize, needle: &'static str },
|
|
/// Captured arg at `index` is byte-for-byte equal to `value`.
|
|
ArgEquals { index: usize, value: &'static str },
|
|
/// At least one captured arg contains `needle`. Useful when the sink
|
|
/// signature varies (e.g. variadic `printf`).
|
|
AnyArgContains(&'static str),
|
|
/// The probe's `sink_callee` field is byte-for-byte equal to `value`.
|
|
CalleeEquals(&'static str),
|
|
/// The probe records at least `min_args` arguments. Lets a payload
|
|
/// pin the sink's arity without locking exact values.
|
|
MinArgs(usize),
|
|
}
|
|
|
|
/// How we decide a sandbox run confirmed the sink fired.
|
|
#[derive(Debug, Clone)]
|
|
pub enum Oracle {
|
|
/// Structured: drain the probe channel and apply `predicates`.
|
|
/// `predicates: &'static [ProbePredicate]` keeps the corpus
|
|
/// declaration `const`-friendly (Phase 06 deferred the
|
|
/// `Vec<ProbePredicate>` shape the plan listed because the corpus is
|
|
/// declared in static memory; a `Vec` would require runtime init).
|
|
SinkProbe { predicates: &'static [ProbePredicate] },
|
|
/// Phase 08 sink-site crash oracle. Fires iff at least one drained
|
|
/// probe has [`ProbeKind::Crash { signal }`] with `signal ∈ signals`.
|
|
/// A process-level abort that did not reach the sink handler leaves no
|
|
/// matching probe and the run does *not* confirm — the runner maps
|
|
/// that case to [`crate::evidence::InconclusiveReason::UnrelatedCrash`].
|
|
SinkCrash { signals: SignalSet },
|
|
/// Legacy stdout/stderr substring oracle. Kept for fixtures that
|
|
/// pre-date Phase 06; new payloads should prefer
|
|
/// [`Oracle::SinkProbe`] which is robust to oracle collisions.
|
|
#[deprecated(
|
|
note = "use Oracle::SinkProbe with ProbePredicate args; OutputContains is brittle to oracle collisions (§16.3)"
|
|
)]
|
|
OutputContains(&'static str),
|
|
/// Process exited with any crash signal (SIGSEGV, SIGABRT).
|
|
///
|
|
/// Coarse: fires on *any* uncaught crash, including ones unrelated to
|
|
/// the sink (e.g. `abort()` in setup code). Phase 08 introduces
|
|
/// [`Oracle::SinkCrash`] which scopes the signal to the sink handler;
|
|
/// new payloads should migrate.
|
|
#[deprecated(
|
|
note = "use Oracle::SinkCrash with a SignalSet; Crash confirms on any process abort, including setup-code failures (Phase 08 §C.4)"
|
|
)]
|
|
Crash,
|
|
/// Outbound network connection observed at the controlled sink host.
|
|
OobCallback { host: &'static str },
|
|
/// File written outside the sandbox root.
|
|
FileEscape,
|
|
/// Non-zero exit with specific status.
|
|
ExitStatus(i32),
|
|
}
|
|
|
|
/// Evaluate an oracle against a single sandbox outcome plus the records
|
|
/// drained from the run's probe channel. Returns `true` iff the run is
|
|
/// considered to have fired the sink.
|
|
#[allow(deprecated)]
|
|
pub fn oracle_fired(oracle: &Oracle, outcome: &SandboxOutcome, probes: &[SinkProbe]) -> bool {
|
|
match oracle {
|
|
Oracle::SinkProbe { predicates } => probes
|
|
.iter()
|
|
.any(|p| probe_satisfies_all(p, predicates)),
|
|
Oracle::SinkCrash { signals } => probes.iter().any(|p| match p.kind {
|
|
ProbeKind::Crash { signal } => signals.contains(signal),
|
|
ProbeKind::Normal => false,
|
|
}),
|
|
Oracle::OutputContains(needle) => {
|
|
let nb = needle.as_bytes();
|
|
contains_subslice(&outcome.stdout, nb) || contains_subslice(&outcome.stderr, nb)
|
|
}
|
|
Oracle::Crash => outcome.exit_code.is_none() && !outcome.timed_out,
|
|
Oracle::OobCallback { .. } => outcome.oob_callback_seen,
|
|
Oracle::FileEscape => false,
|
|
Oracle::ExitStatus(code) => outcome.exit_code == Some(*code),
|
|
}
|
|
}
|
|
|
|
/// Returns true when `probe` satisfies *every* predicate in `preds`.
|
|
/// An empty predicate slice satisfies vacuously — a payload that wants
|
|
/// "any probe at all" can ship an empty predicate set.
|
|
pub fn probe_satisfies_all(probe: &SinkProbe, preds: &[ProbePredicate]) -> bool {
|
|
preds.iter().all(|p| probe_satisfies_one(probe, p))
|
|
}
|
|
|
|
fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
|
|
match pred {
|
|
ProbePredicate::ArgContains { index, needle } => probe
|
|
.args
|
|
.get(*index)
|
|
.and_then(|a| a.as_str())
|
|
.map(|s| s.contains(*needle))
|
|
.unwrap_or(false),
|
|
ProbePredicate::ArgEquals { index, value } => probe
|
|
.args
|
|
.get(*index)
|
|
.and_then(|a| a.as_str())
|
|
.map(|s| s == *value)
|
|
.unwrap_or(false),
|
|
ProbePredicate::AnyArgContains(needle) => probe
|
|
.args
|
|
.iter()
|
|
.any(|a| a.as_str().map(|s| s.contains(*needle)).unwrap_or(false)),
|
|
ProbePredicate::CalleeEquals(value) => probe.sink_callee == *value,
|
|
ProbePredicate::MinArgs(n) => probe.args.len() >= *n,
|
|
}
|
|
}
|
|
|
|
fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
|
|
if needle.is_empty() {
|
|
return true;
|
|
}
|
|
if needle.len() > hay.len() {
|
|
return false;
|
|
}
|
|
hay.windows(needle.len()).any(|w| w == needle)
|
|
}
|
|
|
|
/// Convenience: returns the [`Signal`] captured by a [`SinkProbe`] when
|
|
/// its kind is `Crash`, else `None`. Used by the runner to distinguish
|
|
/// "process crashed but no matching sink-site probe" (→
|
|
/// `Inconclusive(UnrelatedCrash)`) from "process crashed and a sink-site
|
|
/// probe matched" (→ `Confirmed` via `Oracle::SinkCrash`).
|
|
pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
|
match probe.kind {
|
|
ProbeKind::Crash { signal } => Some(signal),
|
|
ProbeKind::Normal => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::dynamic::probe::{ProbeArg, ProbeKind, ProbeWitness, SinkProbe};
|
|
use std::time::Duration;
|
|
|
|
fn outcome() -> SandboxOutcome {
|
|
SandboxOutcome {
|
|
exit_code: Some(0),
|
|
stdout: vec![],
|
|
stderr: vec![],
|
|
timed_out: false,
|
|
oob_callback_seen: false,
|
|
sink_hit: false,
|
|
duration: Duration::from_millis(1),
|
|
}
|
|
}
|
|
|
|
fn probe(callee: &str, args: Vec<ProbeArg>) -> SinkProbe {
|
|
SinkProbe {
|
|
sink_callee: callee.into(),
|
|
args,
|
|
captured_at_ns: 1,
|
|
payload_id: "test".into(),
|
|
kind: ProbeKind::Normal,
|
|
witness: ProbeWitness::empty(),
|
|
}
|
|
}
|
|
|
|
fn crash_probe(callee: &str, signal: Signal) -> SinkProbe {
|
|
SinkProbe {
|
|
sink_callee: callee.into(),
|
|
args: vec![],
|
|
captured_at_ns: 1,
|
|
payload_id: "test".into(),
|
|
kind: ProbeKind::Crash { signal },
|
|
witness: ProbeWitness::empty(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sink_probe_fires_when_predicates_match() {
|
|
let oracle = Oracle::SinkProbe {
|
|
predicates: &[
|
|
ProbePredicate::CalleeEquals("os.system"),
|
|
ProbePredicate::ArgContains { index: 0, needle: "; echo" },
|
|
],
|
|
};
|
|
let probes = vec![probe(
|
|
"os.system",
|
|
vec![ProbeArg::String("; echo NYX_PWN".into())],
|
|
)];
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
}
|
|
|
|
#[test]
|
|
fn sink_probe_not_fired_with_no_probes() {
|
|
let oracle = Oracle::SinkProbe {
|
|
predicates: &[ProbePredicate::CalleeEquals("os.system")],
|
|
};
|
|
assert!(!oracle_fired(&oracle, &outcome(), &[]));
|
|
}
|
|
|
|
#[test]
|
|
fn sink_probe_requires_all_predicates() {
|
|
let oracle = Oracle::SinkProbe {
|
|
predicates: &[
|
|
ProbePredicate::CalleeEquals("os.system"),
|
|
ProbePredicate::ArgContains { index: 0, needle: "NEVER_PRESENT" },
|
|
],
|
|
};
|
|
let probes = vec![probe(
|
|
"os.system",
|
|
vec![ProbeArg::String("hello".into())],
|
|
)];
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
}
|
|
|
|
#[test]
|
|
fn any_arg_contains_matches_second_arg() {
|
|
let oracle = Oracle::SinkProbe {
|
|
predicates: &[ProbePredicate::AnyArgContains("password")],
|
|
};
|
|
let probes = vec![probe(
|
|
"exec",
|
|
vec![
|
|
ProbeArg::String("benign".into()),
|
|
ProbeArg::String("leaked password".into()),
|
|
],
|
|
)];
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
}
|
|
|
|
#[test]
|
|
fn min_args_predicate() {
|
|
let probes_two = vec![probe(
|
|
"exec",
|
|
vec![ProbeArg::String("a".into()), ProbeArg::String("b".into())],
|
|
)];
|
|
let probes_one = vec![probe("exec", vec![ProbeArg::String("a".into())])];
|
|
let oracle = Oracle::SinkProbe {
|
|
predicates: &[ProbePredicate::MinArgs(2)],
|
|
};
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes_two));
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes_one));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_predicate_set_matches_any_probe() {
|
|
let oracle = Oracle::SinkProbe { predicates: &[] };
|
|
let probes = vec![probe("anything", vec![])];
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
}
|
|
|
|
#[test]
|
|
#[allow(deprecated)]
|
|
fn output_contains_legacy_still_works() {
|
|
let mut o = outcome();
|
|
o.stdout = b"NYX_OK".to_vec();
|
|
let oracle = Oracle::OutputContains("NYX_OK");
|
|
assert!(oracle_fired(&oracle, &o, &[]));
|
|
}
|
|
|
|
#[test]
|
|
fn arg_equals_predicate() {
|
|
let oracle = Oracle::SinkProbe {
|
|
predicates: &[ProbePredicate::ArgEquals { index: 0, value: "exact" }],
|
|
};
|
|
let hit = vec![probe("f", vec![ProbeArg::String("exact".into())])];
|
|
let miss = vec![probe("f", vec![ProbeArg::String("inexact".into())])];
|
|
assert!(oracle_fired(&oracle, &outcome(), &hit));
|
|
assert!(!oracle_fired(&oracle, &outcome(), &miss));
|
|
}
|
|
|
|
#[test]
|
|
fn signal_set_round_trips_via_const_slice() {
|
|
const SIGS: SignalSet = SignalSet::from_slice(&[Signal::Sigsegv, Signal::Sigabrt]);
|
|
assert!(SIGS.contains(Signal::Sigsegv));
|
|
assert!(SIGS.contains(Signal::Sigabrt));
|
|
assert!(!SIGS.contains(Signal::Sigfpe));
|
|
assert!(!SIGS.is_empty());
|
|
assert!(SignalSet::empty().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn signal_set_all_contains_every_modelled_signal() {
|
|
let all = SignalSet::all();
|
|
for s in [
|
|
Signal::Sigsegv,
|
|
Signal::Sigabrt,
|
|
Signal::Sigbus,
|
|
Signal::Sigfpe,
|
|
Signal::Sigill,
|
|
] {
|
|
assert!(all.contains(s), "SignalSet::all missing {s:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn signal_from_name_matches_canonical_and_lowercase() {
|
|
assert_eq!(Signal::from_name("SIGSEGV"), Some(Signal::Sigsegv));
|
|
assert_eq!(Signal::from_name(" sigsegv "), Some(Signal::Sigsegv));
|
|
assert_eq!(Signal::from_name("Aborted"), Some(Signal::Sigabrt));
|
|
assert_eq!(Signal::from_name("nope"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn sink_crash_confirms_only_on_matching_signal_probe() {
|
|
let oracle = Oracle::SinkCrash {
|
|
signals: SignalSet::from_slice(&[Signal::Sigsegv]),
|
|
};
|
|
let probes = vec![crash_probe("victim", Signal::Sigsegv)];
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
}
|
|
|
|
#[test]
|
|
fn sink_crash_ignores_normal_probes() {
|
|
let oracle = Oracle::SinkCrash {
|
|
signals: SignalSet::all(),
|
|
};
|
|
let probes = vec![probe("victim", vec![ProbeArg::String("x".into())])];
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
}
|
|
|
|
#[test]
|
|
fn sink_crash_ignores_unrelated_signal() {
|
|
let oracle = Oracle::SinkCrash {
|
|
signals: SignalSet::from_slice(&[Signal::Sigsegv]),
|
|
};
|
|
let probes = vec![crash_probe("victim", Signal::Sigabrt)];
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
}
|
|
|
|
#[test]
|
|
fn sink_crash_without_probes_does_not_fire_even_on_process_crash() {
|
|
let mut o = outcome();
|
|
o.exit_code = None;
|
|
o.timed_out = false;
|
|
let oracle = Oracle::SinkCrash {
|
|
signals: SignalSet::all(),
|
|
};
|
|
assert!(!oracle_fired(&oracle, &o, &[]));
|
|
}
|
|
}
|