[pitboss] phase 08: Track C.4 + C.5 — SinkCrash oracle + per-probe witness capture

This commit is contained in:
pitboss 2026-05-14 13:10:22 -05:00
parent 4eccbd48b4
commit 93eb98edda
21 changed files with 1988 additions and 115 deletions

View file

@ -7,12 +7,145 @@
//! evaluates the predicates against the captured arguments. A run is
//! Confirmed iff at least one drained record satisfies *every* predicate.
//!
//! The legacy [`Oracle::OutputContains`] path is retained for fixtures that
//! pre-date Phase 06 and migrated downstream; it is marked
//! `#[deprecated]` so the compiler nags every new use-site.
//! 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::SinkProbe;
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`].
@ -45,6 +178,12 @@ pub enum Oracle {
/// `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.
@ -52,7 +191,15 @@ pub enum Oracle {
note = "use Oracle::SinkProbe with ProbePredicate args; OutputContains is brittle to oracle collisions (§16.3)"
)]
OutputContains(&'static str),
/// Process exited with a crash signal (SIGSEGV, SIGABRT).
/// 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 },
@ -71,6 +218,10 @@ pub fn oracle_fired(oracle: &Oracle, outcome: &SandboxOutcome, probes: &[SinkPro
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)
@ -122,10 +273,22 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
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, SinkProbe};
use crate::dynamic::probe::{ProbeArg, ProbeKind, ProbeWitness, SinkProbe};
use std::time::Duration;
fn outcome() -> SandboxOutcome {
@ -146,6 +309,19 @@ mod tests {
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(),
}
}
@ -242,4 +418,74 @@ mod tests {
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, &[]));
}
}