2026-05-14 05:35:28 -05:00
|
|
|
|
//! 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.
|
|
|
|
|
|
//!
|
2026-05-14 13:10:22 -05:00
|
|
|
|
//! 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.
|
2026-05-14 05:35:28 -05:00
|
|
|
|
|
2026-05-14 13:10:22 -05:00
|
|
|
|
use crate::dynamic::probe::{ProbeKind, SinkProbe};
|
2026-05-14 05:35:28 -05:00
|
|
|
|
use crate::dynamic::sandbox::SandboxOutcome;
|
2026-05-14 14:18:09 -05:00
|
|
|
|
use crate::dynamic::stubs::{StubEvent, StubKind};
|
2026-05-14 13:10:22 -05:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-14 05:35:28 -05:00
|
|
|
|
|
|
|
|
|
|
/// 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),
|
2026-05-14 14:18:09 -05:00
|
|
|
|
/// Phase 10 (Track D.3): predicate that fires when at least one
|
|
|
|
|
|
/// [`StubEvent`] of kind `kind` carries a summary containing
|
|
|
|
|
|
/// `needle`. Lets a payload assert that a boundary stub (SQL, HTTP,
|
|
|
|
|
|
/// Redis, filesystem) actually observed the sink's effect — e.g.
|
|
|
|
|
|
/// `StubEventMatches { kind: StubKind::Sql, needle: "SELECT" }`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Evaluation is *cross-cutting*: predicates that target stub events
|
|
|
|
|
|
/// satisfy vacuously when no stub events were drained (they cannot
|
|
|
|
|
|
/// fail against a single probe). Callers wanting per-probe pinning
|
|
|
|
|
|
/// pair this with another predicate that does anchor to the probe.
|
|
|
|
|
|
StubEventMatches {
|
|
|
|
|
|
/// Which stub kind to look at.
|
|
|
|
|
|
kind: StubKind,
|
|
|
|
|
|
/// Substring to find in `StubEvent::summary`.
|
|
|
|
|
|
needle: &'static str,
|
|
|
|
|
|
},
|
2026-05-17 16:37:20 -05:00
|
|
|
|
/// Phase 03 (Track J.1): predicate that fires when at least one
|
|
|
|
|
|
/// drained probe carries [`ProbeKind::Deserialize`] with
|
|
|
|
|
|
/// `gadget_chain_invoked` matching `require_invoked`. Cross-cutting
|
|
|
|
|
|
/// in the same sense as [`Self::StubEventMatches`] — evaluation
|
|
|
|
|
|
/// looks across every drained probe rather than asserting against a
|
|
|
|
|
|
/// single record.
|
|
|
|
|
|
DeserializeGadgetInvoked {
|
|
|
|
|
|
/// `true` requires at least one Deserialize probe with
|
|
|
|
|
|
/// `gadget_chain_invoked == true` (a benign control passing
|
|
|
|
|
|
/// well-formed serialized data should never satisfy this).
|
|
|
|
|
|
/// `false` lets a payload that intentionally exercises the
|
|
|
|
|
|
/// "caught at boundary" path still confirm.
|
|
|
|
|
|
require_invoked: bool,
|
|
|
|
|
|
},
|
2026-05-17 18:51:13 -05:00
|
|
|
|
/// Phase 04 (Track J.2): SSTI render-equality predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when the harness's captured stdout body parses as JSON
|
|
|
|
|
|
/// `{"render": "<integer>"}` and the integer equals `expected`. The
|
|
|
|
|
|
/// payload sends a template expression that resolves to a fixed
|
|
|
|
|
|
/// constant only when the engine actually evaluates it (e.g.
|
|
|
|
|
|
/// `{{7*7}}` → `49`); a benign control sends literal text that the
|
|
|
|
|
|
/// engine echoes, producing a non-matching render value.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting: evaluated against [`SandboxOutcome::stdout`]
|
|
|
|
|
|
/// rather than any single [`SinkProbe`], so the predicate satisfies
|
|
|
|
|
|
/// globally once per run.
|
|
|
|
|
|
TemplateEvalEqual {
|
|
|
|
|
|
/// Integer the rendered template body must equal for the
|
|
|
|
|
|
/// oracle to fire. Stored as `u64` so the corpus can pin
|
|
|
|
|
|
/// engine-portable constants ranging up to `2^64 − 1` without
|
|
|
|
|
|
/// signed-overflow concerns.
|
|
|
|
|
|
expected: u64,
|
|
|
|
|
|
},
|
2026-05-17 20:39:12 -05:00
|
|
|
|
/// Phase 05 (Track J.3): XXE entity-expansion predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::Xxe`] with `entity_expanded` matching
|
|
|
|
|
|
/// `require_expanded`. The vuln payload ships an XML document
|
|
|
|
|
|
/// with a `<!ENTITY xxe SYSTEM "file:///…">` declaration; the
|
|
|
|
|
|
/// per-language harness's instrumented parser writes
|
|
|
|
|
|
/// `entity_expanded: true` once the entity body materialises
|
|
|
|
|
|
/// inside the parsed tree. The benign control disables
|
|
|
|
|
|
/// doctype / external-entity resolution so the parser refuses the
|
|
|
|
|
|
/// expansion and writes `entity_expanded: false`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] — evaluated across every
|
|
|
|
|
|
/// drained probe rather than against a single record.
|
|
|
|
|
|
XxeEntityExpanded {
|
|
|
|
|
|
/// `true` requires at least one [`ProbeKind::Xxe`] probe with
|
|
|
|
|
|
/// `entity_expanded == true` (the differential confirmation
|
|
|
|
|
|
/// path); `false` lets a payload that intentionally exercises
|
|
|
|
|
|
/// the parser-refusal benign control still confirm.
|
|
|
|
|
|
require_expanded: bool,
|
|
|
|
|
|
},
|
2026-05-18 01:08:32 -05:00
|
|
|
|
/// Phase 08 (Track J.6): HTTP response-header CRLF-injection
|
|
|
|
|
|
/// predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::HeaderEmit`] whose `name` equals `header_name` (or
|
|
|
|
|
|
/// `header_name` is the wildcard `"*"`) and whose `value` contains
|
|
|
|
|
|
/// a literal `\r\n` byte pair. The vuln payload splices `\r\n`
|
|
|
|
|
|
/// followed by an injected header line into the response writer's
|
|
|
|
|
|
/// value argument; the per-language harness's instrumented
|
|
|
|
|
|
/// `setHeader` records the unmodified bytes the host process
|
|
|
|
|
|
/// passed in. The benign control passes the same logical value
|
|
|
|
|
|
/// through `URLEncoder.encode` / `urllib.parse.quote`, so the
|
|
|
|
|
|
/// captured value carries `%0d%0a` (not the raw bytes) and the
|
|
|
|
|
|
/// predicate stays clear.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] /
|
|
|
|
|
|
/// [`Self::QueryResultCountGreaterThan`] — evaluated across every
|
|
|
|
|
|
/// drained probe rather than against a single record.
|
|
|
|
|
|
HeaderInjected {
|
|
|
|
|
|
/// Header name the malicious payload targets (e.g.
|
|
|
|
|
|
/// `"Set-Cookie"`, `"Location"`). Use `"*"` to satisfy on any
|
|
|
|
|
|
/// captured header whose value contains the CRLF pair.
|
|
|
|
|
|
header_name: &'static str,
|
|
|
|
|
|
},
|
2026-05-22 04:20:02 -05:00
|
|
|
|
/// Phase 08 (Track J.6): wire-frame header-smuggling predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::HeaderWireFrame`] whose `raw_bytes` contains two
|
|
|
|
|
|
/// distinct header lines on the wire — one starting with
|
|
|
|
|
|
/// `primary:` and a separate line starting with `smuggled:`.
|
|
|
|
|
|
/// Both names are matched case-insensitively against the leading
|
|
|
|
|
|
/// token of each `\r\n`-terminated header line.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Distinct from [`Self::HeaderInjected`], which fires on a
|
|
|
|
|
|
/// single in-process `HeaderEmit` whose value contains a literal
|
|
|
|
|
|
/// CRLF pair: a vulnerable host process can pass `\r\n`-bearing
|
|
|
|
|
|
/// bytes into its framework's header setter *and* the framework
|
|
|
|
|
|
/// can then CRLF-strip the bytes on the way to the wire, leaving
|
|
|
|
|
|
/// the in-process probe satisfied but the actual response frame
|
|
|
|
|
|
/// clean. This predicate proves the smuggled header survived to
|
|
|
|
|
|
/// the underlying server's response socket.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] /
|
|
|
|
|
|
/// [`Self::HeaderInjected`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
|
|
|
|
|
HeaderSmuggledInWire {
|
|
|
|
|
|
/// Header name the original payload set legitimately (e.g.
|
|
|
|
|
|
/// `"Set-Cookie"`). Must appear as the leading token of at
|
|
|
|
|
|
/// least one `\r\n`-terminated wire line.
|
|
|
|
|
|
primary: &'static str,
|
|
|
|
|
|
/// Header name the attacker smuggled past the CRLF boundary
|
|
|
|
|
|
/// (e.g. `"X-Injected"`). Must appear as the leading token
|
|
|
|
|
|
/// of a separate `\r\n`-terminated wire line.
|
|
|
|
|
|
smuggled: &'static str,
|
|
|
|
|
|
},
|
2026-05-18 02:32:13 -05:00
|
|
|
|
/// Phase 09 (Track J.7): open-redirect predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::Redirect`] whose extracted `location` host falls
|
|
|
|
|
|
/// outside `allowlist`. Same-origin redirects (the `location`
|
|
|
|
|
|
/// host equals `request_host`, or the location is a relative
|
|
|
|
|
|
/// path) never fire — they cannot leave the application origin
|
|
|
|
|
|
/// regardless of allowlist contents. Hosts are compared
|
|
|
|
|
|
/// case-insensitively against the allowlist entries; schemeless
|
|
|
|
|
|
/// `//host/...` references are parsed as off-origin.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] /
|
|
|
|
|
|
/// [`Self::HeaderInjected`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
|
|
|
|
|
RedirectHostNotIn {
|
|
|
|
|
|
/// Allowlist of origin hosts the application is willing to
|
|
|
|
|
|
/// redirect into (e.g. `&["example.com", "www.example.com"]`).
|
|
|
|
|
|
/// `request_host` is implicitly allowed even when absent
|
|
|
|
|
|
/// from this slice.
|
|
|
|
|
|
allowlist: &'static [&'static str],
|
|
|
|
|
|
},
|
2026-05-18 08:02:10 -05:00
|
|
|
|
/// Phase 10 (Track J.8): prototype-pollution canary predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::PrototypePollution`] whose `property` matches
|
|
|
|
|
|
/// `canary` (defaults to `"__nyx_canary"`). The Node harness
|
|
|
|
|
|
/// installs a `Proxy`-style setter trap on
|
|
|
|
|
|
/// `Object.prototype.__nyx_canary`; any deep-merge / `Object.assign`
|
|
|
|
|
|
/// / `JSON.parse`-then-deep-assign sink that walks an
|
|
|
|
|
|
/// attacker-controlled `__proto__` key into the prototype chain
|
|
|
|
|
|
/// trips the trap and writes a `PrototypePollution` probe. A
|
|
|
|
|
|
/// benign payload whose object literal has no `__proto__` key, or
|
|
|
|
|
|
/// whose target is constructed via `Object.create(null)`, never
|
|
|
|
|
|
/// reaches the canary so the predicate stays clear.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] /
|
|
|
|
|
|
/// [`Self::HeaderInjected`] /
|
|
|
|
|
|
/// [`Self::RedirectHostNotIn`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
|
|
|
|
|
PrototypeCanaryTouched {
|
|
|
|
|
|
/// Canary property name the harness installed on
|
|
|
|
|
|
/// `Object.prototype` (typically `"__nyx_canary"`). Compared
|
|
|
|
|
|
/// case-sensitively against
|
|
|
|
|
|
/// [`ProbeKind::PrototypePollution::property`].
|
|
|
|
|
|
canary: &'static str,
|
|
|
|
|
|
},
|
2026-05-18 09:37:37 -05:00
|
|
|
|
/// Phase 11 (Track J.9): CRYPTO weak-key entropy predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::WeakKey`] whose `key_int` is strictly less than
|
|
|
|
|
|
/// `2^max_bits` — i.e. the produced key actually fits inside the
|
|
|
|
|
|
/// search-space budget the predicate models. A benign control
|
|
|
|
|
|
/// generated by a cryptographically strong RNG produces a
|
|
|
|
|
|
/// `key_int` whose magnitude trivially exceeds the budget, so the
|
|
|
|
|
|
/// predicate stays clear.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
|
|
|
|
|
WeakKeyEntropy {
|
|
|
|
|
|
/// Maximum effective-entropy budget (in bits) the produced
|
|
|
|
|
|
/// key may consume before the oracle fires. 16 matches the
|
|
|
|
|
|
/// brief's "fits in a 16-bit search space" oracle.
|
|
|
|
|
|
max_bits: u32,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// Phase 11 (Track J.9): UNAUTHORIZED_ID IDOR boundary predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::IdorAccess`] whose `caller_id` differs from
|
|
|
|
|
|
/// `owner_id`. Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
|
|
|
|
|
IdorBoundaryCrossed,
|
|
|
|
|
|
/// Phase 11 (Track J.9): DATA_EXFIL outbound-host predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::OutboundNetwork`] whose `host` falls outside the
|
|
|
|
|
|
/// `allowlist`. Hosts are compared case-insensitively; the
|
|
|
|
|
|
/// canonical allowlist for benign controls is `&["127.0.0.1",
|
|
|
|
|
|
/// "localhost"]` so a vulnerable host that exfiltrates to
|
|
|
|
|
|
/// `attacker.test` resolves off-list and confirms.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
|
|
|
|
|
OutboundHostNotIn {
|
|
|
|
|
|
/// Allowlist of permitted egress hosts (e.g.
|
|
|
|
|
|
/// `&["127.0.0.1", "localhost"]`). A probe whose `host`
|
|
|
|
|
|
/// matches any entry is treated as same-origin.
|
|
|
|
|
|
allowlist: &'static [&'static str],
|
|
|
|
|
|
},
|
2026-05-17 23:47:12 -05:00
|
|
|
|
/// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count
|
|
|
|
|
|
/// predicate shared by LDAP-filter and XPath-expression injection.
|
2026-05-17 22:32:44 -05:00
|
|
|
|
///
|
2026-05-17 23:47:12 -05:00
|
|
|
|
/// Fires when at least one drained probe carries a count-bearing
|
|
|
|
|
|
/// kind — [`ProbeKind::Ldap`] with `entries_returned > n` or
|
|
|
|
|
|
/// [`ProbeKind::Xpath`] with `nodes_returned > n`. The malicious
|
|
|
|
|
|
/// payload inflates the host expression (`*)(uid=*` for LDAP, `'
|
|
|
|
|
|
/// or '1'='1` for XPath) so the in-sandbox directory / staged XML
|
|
|
|
|
|
/// document matches every provisioned record (> 1 entry / node).
|
|
|
|
|
|
/// The benign control quotes the filter / expression so the sink
|
|
|
|
|
|
/// returns exactly one record, leaving the predicate clear.
|
2026-05-17 22:32:44 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
2026-05-17 23:47:12 -05:00
|
|
|
|
QueryResultCountGreaterThan {
|
|
|
|
|
|
/// Threshold the captured `entries_returned` /
|
|
|
|
|
|
/// `nodes_returned` count must exceed to fire the predicate.
|
|
|
|
|
|
/// Typically `1`: the originally-intended record is one
|
|
|
|
|
|
/// match, any additional matches prove the filter /
|
|
|
|
|
|
/// expression expanded into an over-broad selector.
|
2026-05-17 22:32:44 -05:00
|
|
|
|
n: u32,
|
|
|
|
|
|
},
|
2026-05-22 06:48:32 -05:00
|
|
|
|
/// Phase 11 (Track J.9): JSON_PARSE depth-bomb predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fires when at least one drained probe carries
|
|
|
|
|
|
/// [`ProbeKind::JsonParse`] whose `depth > max_depth` OR whose
|
|
|
|
|
|
/// `excessive_depth` flag is set. The canonical attacker payload
|
|
|
|
|
|
/// is a deeply-nested JSON document (`[[[[[...]]]]]`) that drives
|
|
|
|
|
|
/// the host's parser to a recursion limit or stack-exhaustion
|
|
|
|
|
|
/// shape; the benign control is a flat or shallowly-nested
|
|
|
|
|
|
/// document that leaves the predicate clear.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-cutting in the same sense as
|
|
|
|
|
|
/// [`Self::DeserializeGadgetInvoked`] /
|
|
|
|
|
|
/// [`Self::XxeEntityExpanded`] — evaluated across every drained
|
|
|
|
|
|
/// probe rather than against a single record.
|
|
|
|
|
|
JsonParseExcessiveDepth {
|
|
|
|
|
|
/// Maximum legal nesting depth. A captured probe with
|
|
|
|
|
|
/// `depth > max_depth` (or `excessive_depth = true`) fires the
|
|
|
|
|
|
/// predicate. Typical benign depths are under 8; depth-bomb
|
|
|
|
|
|
/// payloads ship 256+ nested arrays.
|
|
|
|
|
|
max_depth: u32,
|
|
|
|
|
|
},
|
2026-05-14 05:35:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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).
|
2026-05-21 14:35:42 -05:00
|
|
|
|
SinkProbe {
|
|
|
|
|
|
predicates: &'static [ProbePredicate],
|
|
|
|
|
|
},
|
2026-05-14 13:10:22 -05:00
|
|
|
|
/// 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 },
|
2026-05-14 05:35:28 -05:00
|
|
|
|
/// 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),
|
2026-05-14 13:10:22 -05:00
|
|
|
|
/// 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)"
|
|
|
|
|
|
)]
|
2026-05-14 05:35:28 -05:00
|
|
|
|
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),
|
2026-05-14 14:18:09 -05:00
|
|
|
|
/// Phase 10 (Track D.3): boundary-stub-driven oracle. Fires when the
|
|
|
|
|
|
/// per-kind [`StubEvent`] log drained from
|
|
|
|
|
|
/// [`crate::dynamic::stubs::StubHarness`] contains an event of
|
|
|
|
|
|
/// `kind` whose summary contains `needle`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Distinct from the [`ProbePredicate::StubEventMatches`] *inside*
|
|
|
|
|
|
/// `SinkProbe` evaluation: this variant lets a payload skip probe
|
|
|
|
|
|
/// instrumentation entirely and confirm purely on the stub's
|
|
|
|
|
|
/// observed effect, which is the only signal available for sinks
|
|
|
|
|
|
/// the harness cannot wrap (e.g. opaque ORM calls).
|
|
|
|
|
|
StubEvent {
|
|
|
|
|
|
/// Which stub kind to look at.
|
|
|
|
|
|
kind: StubKind,
|
|
|
|
|
|
/// Substring to find in `StubEvent::summary`.
|
|
|
|
|
|
needle: &'static str,
|
|
|
|
|
|
},
|
2026-05-14 05:35:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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.
|
2026-05-14 14:18:09 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// Backwards-compatible entry point — preserved verbatim for the
|
|
|
|
|
|
/// runner's vuln + benign-control loops that pre-date Phase 10's stub
|
|
|
|
|
|
/// layer. When the active oracle inspects stub events (i.e.
|
|
|
|
|
|
/// [`Oracle::StubEvent`]) callers should use
|
|
|
|
|
|
/// [`oracle_fired_with_stubs`] which threads in a `&[StubEvent]`
|
|
|
|
|
|
/// slice; this function treats the stub-event log as empty so the
|
|
|
|
|
|
/// `Oracle::StubEvent` branch never fires under the legacy entry.
|
2026-05-14 05:35:28 -05:00
|
|
|
|
#[allow(deprecated)]
|
|
|
|
|
|
pub fn oracle_fired(oracle: &Oracle, outcome: &SandboxOutcome, probes: &[SinkProbe]) -> bool {
|
2026-05-14 14:18:09 -05:00
|
|
|
|
oracle_fired_with_stubs(oracle, outcome, probes, &[])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Phase 10: evaluate an oracle with the boundary-stub event log in
|
|
|
|
|
|
/// scope. See [`Oracle::StubEvent`] for the semantics of the new
|
|
|
|
|
|
/// branch and [`ProbePredicate::StubEventMatches`] for the new
|
|
|
|
|
|
/// `Oracle::SinkProbe` cross-cutting predicate.
|
|
|
|
|
|
#[allow(deprecated)]
|
|
|
|
|
|
pub fn oracle_fired_with_stubs(
|
|
|
|
|
|
oracle: &Oracle,
|
|
|
|
|
|
outcome: &SandboxOutcome,
|
|
|
|
|
|
probes: &[SinkProbe],
|
|
|
|
|
|
stub_events: &[StubEvent],
|
|
|
|
|
|
) -> bool {
|
2026-05-14 05:35:28 -05:00
|
|
|
|
match oracle {
|
2026-05-14 14:18:09 -05:00
|
|
|
|
Oracle::SinkProbe { predicates } => {
|
|
|
|
|
|
// Predicate set split: per-probe vs cross-cutting (stub
|
2026-05-17 16:37:20 -05:00
|
|
|
|
// events, deserialize gadget invocation). Cross-cutting
|
|
|
|
|
|
// predicates cannot be evaluated against a single probe —
|
|
|
|
|
|
// they satisfy once globally when the matching log shape is
|
|
|
|
|
|
// present. Per-probe predicates must still hold for at
|
|
|
|
|
|
// least one captured probe.
|
2026-05-14 14:18:09 -05:00
|
|
|
|
let (cross, per_probe): (Vec<_>, Vec<_>) =
|
|
|
|
|
|
predicates.iter().partition(|p| is_cross_cutting(p));
|
2026-05-17 16:37:20 -05:00
|
|
|
|
// Stub-event cross-cutting predicates.
|
|
|
|
|
|
let stub_cross_ok = cross
|
2026-05-14 14:18:09 -05:00
|
|
|
|
.iter()
|
|
|
|
|
|
.all(|p| cross_cutting_satisfied(p, stub_events));
|
2026-05-17 16:37:20 -05:00
|
|
|
|
if !stub_cross_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Deserialize cross-cutting predicates.
|
|
|
|
|
|
let deserialize_cross_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::DeserializeGadgetInvoked { require_invoked } => {
|
|
|
|
|
|
probes_satisfy_deserialize(probes, *require_invoked)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !deserialize_cross_ok {
|
2026-05-14 14:18:09 -05:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-17 20:39:12 -05:00
|
|
|
|
// Phase 05 (Track J.3): XXE entity-expansion cross-cutting
|
|
|
|
|
|
// predicates. Each `XxeEntityExpanded { require_expanded }`
|
|
|
|
|
|
// consults the captured probe channel for a
|
|
|
|
|
|
// [`ProbeKind::Xxe`] record whose `entity_expanded` flag
|
|
|
|
|
|
// matches.
|
|
|
|
|
|
let xxe_cross_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::XxeEntityExpanded { require_expanded } => {
|
|
|
|
|
|
probes_satisfy_xxe(probes, *require_expanded)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !xxe_cross_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-17 23:47:12 -05:00
|
|
|
|
// Phase 06 (Track J.4) + Phase 07 (Track J.5): result-
|
|
|
|
|
|
// count cross-cutting predicates. Each
|
|
|
|
|
|
// `QueryResultCountGreaterThan { n }` consults the captured
|
2026-05-17 22:32:44 -05:00
|
|
|
|
// probe channel for a [`ProbeKind::Ldap`] record whose
|
2026-05-17 23:47:12 -05:00
|
|
|
|
// `entries_returned` exceeds `n` *or* a [`ProbeKind::Xpath`]
|
|
|
|
|
|
// record whose `nodes_returned` exceeds `n`.
|
|
|
|
|
|
let query_count_cross_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::QueryResultCountGreaterThan { n } => {
|
|
|
|
|
|
probes_satisfy_count_gt(probes, *n)
|
2026-05-17 22:32:44 -05:00
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
2026-05-17 23:47:12 -05:00
|
|
|
|
if !query_count_cross_ok {
|
2026-05-17 22:32:44 -05:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-18 01:08:32 -05:00
|
|
|
|
// Phase 08 (Track J.6): header-injection cross-cutting
|
|
|
|
|
|
// predicates. Each `HeaderInjected { header_name }`
|
|
|
|
|
|
// consults the captured probe channel for a
|
|
|
|
|
|
// [`ProbeKind::HeaderEmit`] record whose `name` matches
|
|
|
|
|
|
// and whose `value` contains a literal CRLF byte pair.
|
|
|
|
|
|
let header_injected_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::HeaderInjected { header_name } => {
|
|
|
|
|
|
probes_satisfy_header_injected(probes, header_name)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !header_injected_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-22 04:20:02 -05:00
|
|
|
|
// Phase 08 (Track J.6): wire-frame header-smuggling
|
|
|
|
|
|
// cross-cutting predicates. Each
|
|
|
|
|
|
// `HeaderSmuggledInWire { primary, smuggled }` consults
|
|
|
|
|
|
// the captured probe channel for a
|
|
|
|
|
|
// [`ProbeKind::HeaderWireFrame`] record whose `raw_bytes`
|
|
|
|
|
|
// contain two distinct `name:` lines.
|
|
|
|
|
|
let header_wire_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::HeaderSmuggledInWire { primary, smuggled } => {
|
|
|
|
|
|
probes_satisfy_header_smuggled_in_wire(probes, primary, smuggled)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !header_wire_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-18 02:32:13 -05:00
|
|
|
|
// Phase 09 (Track J.7): open-redirect cross-cutting
|
|
|
|
|
|
// predicates. Each `RedirectHostNotIn { allowlist }`
|
|
|
|
|
|
// consults the captured probe channel for a
|
|
|
|
|
|
// [`ProbeKind::Redirect`] record whose `location` host
|
|
|
|
|
|
// resolves off-origin relative to `allowlist ∪
|
|
|
|
|
|
// {request_host}`.
|
|
|
|
|
|
let redirect_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::RedirectHostNotIn { allowlist } => {
|
|
|
|
|
|
probes_satisfy_redirect_off_origin(probes, allowlist)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !redirect_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-18 08:02:10 -05:00
|
|
|
|
// Phase 10 (Track J.8): prototype-pollution canary
|
|
|
|
|
|
// cross-cutting predicates. Each
|
|
|
|
|
|
// `PrototypeCanaryTouched { canary }` consults the
|
|
|
|
|
|
// captured probe channel for a
|
|
|
|
|
|
// [`ProbeKind::PrototypePollution`] record whose
|
|
|
|
|
|
// `property` matches the canary name.
|
|
|
|
|
|
let canary_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::PrototypeCanaryTouched { canary } => {
|
|
|
|
|
|
probes_satisfy_prototype_canary(probes, canary)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !canary_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-18 09:37:37 -05:00
|
|
|
|
// Phase 11 (Track J.9): CRYPTO weak-key, UNAUTHORIZED_ID
|
|
|
|
|
|
// IDOR, DATA_EXFIL outbound-host cross-cutting predicates.
|
|
|
|
|
|
let weak_key_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::WeakKeyEntropy { max_bits } => {
|
|
|
|
|
|
probes_satisfy_weak_key(probes, *max_bits)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !weak_key_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let idor_ok = cross.iter().all(|p| match p {
|
2026-05-21 14:35:42 -05:00
|
|
|
|
ProbePredicate::IdorBoundaryCrossed => probes_satisfy_idor_crossed(probes),
|
2026-05-18 09:37:37 -05:00
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !idor_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let outbound_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::OutboundHostNotIn { allowlist } => {
|
|
|
|
|
|
probes_satisfy_outbound_off_list(probes, allowlist)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !outbound_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-22 06:48:32 -05:00
|
|
|
|
// Phase 11 (Track J.9): JSON_PARSE depth-bomb cross-cutting
|
|
|
|
|
|
// predicates. Each `JsonParseExcessiveDepth { max_depth }`
|
|
|
|
|
|
// consults the captured probe channel for a
|
|
|
|
|
|
// [`ProbeKind::JsonParse`] record whose `depth > max_depth`
|
|
|
|
|
|
// OR whose `excessive_depth` flag is set.
|
|
|
|
|
|
let json_parse_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::JsonParseExcessiveDepth { max_depth } => {
|
|
|
|
|
|
probes_satisfy_json_parse_excessive(probes, *max_depth)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !json_parse_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-17 18:51:13 -05:00
|
|
|
|
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
|
|
|
|
|
|
// predicates. Each `TemplateEvalEqual { expected }` consults
|
|
|
|
|
|
// the captured stdout body — see [`stdout_template_equals`].
|
|
|
|
|
|
let template_eval_ok = cross.iter().all(|p| match p {
|
|
|
|
|
|
ProbePredicate::TemplateEvalEqual { expected } => {
|
|
|
|
|
|
stdout_template_equals(&outcome.stdout, *expected)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if !template_eval_ok {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-14 14:18:09 -05:00
|
|
|
|
match (cross.is_empty(), per_probe.is_empty()) {
|
|
|
|
|
|
// Empty predicate slice — legacy semantics: fire when
|
|
|
|
|
|
// at least one probe exists.
|
|
|
|
|
|
(true, true) => !probes.is_empty(),
|
|
|
|
|
|
// Only cross-cutting predicates, all satisfied → fire.
|
|
|
|
|
|
(false, true) => true,
|
|
|
|
|
|
// Per-probe predicates present — at least one probe
|
|
|
|
|
|
// must satisfy every per-probe predicate.
|
|
|
|
|
|
(_, false) => probes
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|p| per_probe.iter().all(|pred| probe_satisfies_one(p, pred))),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 01:08:32 -05:00
|
|
|
|
Oracle::SinkCrash { signals } => probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::Crash { signal } => signals.contains(*signal),
|
2026-05-17 22:32:44 -05:00
|
|
|
|
ProbeKind::Normal
|
|
|
|
|
|
| ProbeKind::Deserialize { .. }
|
|
|
|
|
|
| ProbeKind::Xxe { .. }
|
2026-05-17 23:47:12 -05:00
|
|
|
|
| ProbeKind::Ldap { .. }
|
2026-05-18 01:08:32 -05:00
|
|
|
|
| ProbeKind::Xpath { .. }
|
2026-05-18 02:32:13 -05:00
|
|
|
|
| ProbeKind::HeaderEmit { .. }
|
2026-05-22 04:20:02 -05:00
|
|
|
|
| ProbeKind::HeaderWireFrame { .. }
|
2026-05-18 08:02:10 -05:00
|
|
|
|
| ProbeKind::Redirect { .. }
|
2026-05-18 09:37:37 -05:00
|
|
|
|
| ProbeKind::PrototypePollution { .. }
|
|
|
|
|
|
| ProbeKind::WeakKey { .. }
|
|
|
|
|
|
| ProbeKind::IdorAccess { .. }
|
2026-05-22 06:48:32 -05:00
|
|
|
|
| ProbeKind::OutboundNetwork { .. }
|
|
|
|
|
|
| ProbeKind::JsonParse { .. } => false,
|
2026-05-14 13:10:22 -05:00
|
|
|
|
}),
|
2026-05-14 05:35:28 -05:00
|
|
|
|
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),
|
2026-05-14 14:18:09 -05:00
|
|
|
|
Oracle::StubEvent { kind, needle } => stub_events
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|e| e.kind == *kind && e.summary.contains(*needle)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// True when `pred` evaluates against the stub-event log rather than
|
|
|
|
|
|
/// any single [`SinkProbe`]. Used to partition predicate slices in
|
|
|
|
|
|
/// [`oracle_fired_with_stubs`].
|
|
|
|
|
|
fn is_cross_cutting(pred: &ProbePredicate) -> bool {
|
2026-05-17 16:37:20 -05:00
|
|
|
|
matches!(
|
|
|
|
|
|
pred,
|
|
|
|
|
|
ProbePredicate::StubEventMatches { .. }
|
|
|
|
|
|
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
2026-05-17 18:51:13 -05:00
|
|
|
|
| ProbePredicate::TemplateEvalEqual { .. }
|
2026-05-17 20:39:12 -05:00
|
|
|
|
| ProbePredicate::XxeEntityExpanded { .. }
|
2026-05-17 23:47:12 -05:00
|
|
|
|
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
2026-05-18 01:08:32 -05:00
|
|
|
|
| ProbePredicate::HeaderInjected { .. }
|
2026-05-22 04:20:02 -05:00
|
|
|
|
| ProbePredicate::HeaderSmuggledInWire { .. }
|
2026-05-18 02:32:13 -05:00
|
|
|
|
| ProbePredicate::RedirectHostNotIn { .. }
|
2026-05-18 08:02:10 -05:00
|
|
|
|
| ProbePredicate::PrototypeCanaryTouched { .. }
|
2026-05-18 09:37:37 -05:00
|
|
|
|
| ProbePredicate::WeakKeyEntropy { .. }
|
|
|
|
|
|
| ProbePredicate::IdorBoundaryCrossed
|
|
|
|
|
|
| ProbePredicate::OutboundHostNotIn { .. }
|
2026-05-22 06:48:32 -05:00
|
|
|
|
| ProbePredicate::JsonParseExcessiveDepth { .. }
|
2026-05-17 16:37:20 -05:00
|
|
|
|
)
|
2026-05-14 14:18:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) -> bool {
|
|
|
|
|
|
match pred {
|
|
|
|
|
|
ProbePredicate::StubEventMatches { kind, needle } => stub_events
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|e| e.kind == *kind && e.summary.contains(*needle)),
|
2026-05-17 16:37:20 -05:00
|
|
|
|
// DeserializeGadgetInvoked is cross-cutting against the *probe
|
|
|
|
|
|
// log* rather than stub events; evaluated separately in
|
|
|
|
|
|
// [`probes_satisfy_deserialize`] below.
|
|
|
|
|
|
ProbePredicate::DeserializeGadgetInvoked { .. } => true,
|
2026-05-17 18:51:13 -05:00
|
|
|
|
// TemplateEvalEqual is cross-cutting against the *sandbox
|
|
|
|
|
|
// outcome stdout* rather than stub events; evaluated separately
|
|
|
|
|
|
// via [`stdout_template_equals`] in [`oracle_fired_with_stubs`].
|
|
|
|
|
|
ProbePredicate::TemplateEvalEqual { .. } => true,
|
2026-05-17 20:39:12 -05:00
|
|
|
|
// XxeEntityExpanded is cross-cutting against the *probe log*
|
|
|
|
|
|
// rather than stub events; evaluated separately in
|
|
|
|
|
|
// [`probes_satisfy_xxe`] below.
|
|
|
|
|
|
ProbePredicate::XxeEntityExpanded { .. } => true,
|
2026-05-17 23:47:12 -05:00
|
|
|
|
// QueryResultCountGreaterThan is cross-cutting against the
|
2026-05-17 22:32:44 -05:00
|
|
|
|
// *probe log* rather than stub events; evaluated separately
|
2026-05-17 23:47:12 -05:00
|
|
|
|
// in [`probes_satisfy_count_gt`] below.
|
|
|
|
|
|
ProbePredicate::QueryResultCountGreaterThan { .. } => true,
|
2026-05-18 01:08:32 -05:00
|
|
|
|
// HeaderInjected is cross-cutting against the *probe log*
|
|
|
|
|
|
// rather than stub events; evaluated separately in
|
|
|
|
|
|
// [`probes_satisfy_header_injected`] below.
|
|
|
|
|
|
ProbePredicate::HeaderInjected { .. } => true,
|
2026-05-22 04:20:02 -05:00
|
|
|
|
// HeaderSmuggledInWire is cross-cutting against the
|
|
|
|
|
|
// *probe log* rather than stub events; evaluated
|
|
|
|
|
|
// separately in [`probes_satisfy_header_smuggled_in_wire`]
|
|
|
|
|
|
// below.
|
|
|
|
|
|
ProbePredicate::HeaderSmuggledInWire { .. } => true,
|
2026-05-18 02:32:13 -05:00
|
|
|
|
// RedirectHostNotIn is cross-cutting against the *probe log*
|
|
|
|
|
|
// rather than stub events; evaluated separately in
|
|
|
|
|
|
// [`probes_satisfy_redirect_off_origin`] below.
|
|
|
|
|
|
ProbePredicate::RedirectHostNotIn { .. } => true,
|
2026-05-18 08:02:10 -05:00
|
|
|
|
// PrototypeCanaryTouched is cross-cutting against the *probe
|
|
|
|
|
|
// log* rather than stub events; evaluated separately in
|
|
|
|
|
|
// [`probes_satisfy_prototype_canary`] below.
|
|
|
|
|
|
ProbePredicate::PrototypeCanaryTouched { .. } => true,
|
2026-05-18 09:37:37 -05:00
|
|
|
|
// Phase 11 (Track J.9) cross-cutters are all probe-log
|
|
|
|
|
|
// backed and evaluated by their dedicated helpers below.
|
|
|
|
|
|
ProbePredicate::WeakKeyEntropy { .. } => true,
|
|
|
|
|
|
ProbePredicate::IdorBoundaryCrossed => true,
|
|
|
|
|
|
ProbePredicate::OutboundHostNotIn { .. } => true,
|
2026-05-14 14:18:09 -05:00
|
|
|
|
_ => true,
|
2026-05-14 05:35:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 18:51:13 -05:00
|
|
|
|
/// Phase 04 (Track J.2): extract the `render` field from a JSON body
|
|
|
|
|
|
/// printed on the harness's stdout and compare it against `expected`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The harness writes one JSON object per run shaped like
|
|
|
|
|
|
/// `{"render": "<integer>"}`. The integer is encoded as a string so
|
|
|
|
|
|
/// engines that render integers as `"49"` (every supported engine does)
|
|
|
|
|
|
/// match the same wire format. A run satisfies the predicate when:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 1. `stdout` contains at least one JSON object whose top-level
|
|
|
|
|
|
/// `render` field is a string, AND
|
|
|
|
|
|
/// 2. that string parses to a `u64` byte-for-byte equal to `expected`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Stdout may contain other lines (warnings, debug prints) — the
|
|
|
|
|
|
/// matcher scans line-by-line and accepts the first parseable record.
|
|
|
|
|
|
/// A malformed body or missing field returns `false` rather than
|
|
|
|
|
|
/// surfacing an error so a benign control that never emitted any JSON
|
|
|
|
|
|
/// at all (the engine echoed plain text) does not accidentally fire.
|
|
|
|
|
|
fn stdout_template_equals(stdout: &[u8], expected: u64) -> bool {
|
|
|
|
|
|
let text = match std::str::from_utf8(stdout) {
|
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
|
Err(_) => return false,
|
|
|
|
|
|
};
|
|
|
|
|
|
for line in text.lines() {
|
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
|
if trimmed.is_empty() || !trimmed.starts_with('{') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let parsed: serde_json::Result<serde_json::Value> = serde_json::from_str(trimmed);
|
|
|
|
|
|
let Ok(v) = parsed else { continue };
|
2026-05-21 14:35:42 -05:00
|
|
|
|
let Some(render) = v.get("render") else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
2026-05-17 18:51:13 -05:00
|
|
|
|
let Some(s) = render.as_str() else { continue };
|
2026-05-21 03:39:36 -05:00
|
|
|
|
if let Ok(n) = s.trim().parse::<u64>()
|
2026-05-21 14:35:42 -05:00
|
|
|
|
&& n == expected
|
|
|
|
|
|
{
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-05-17 18:51:13 -05:00
|
|
|
|
}
|
|
|
|
|
|
false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 16:37:20 -05:00
|
|
|
|
/// True when at least one drained probe is a
|
|
|
|
|
|
/// [`ProbeKind::Deserialize`] record matching `require_invoked`.
|
|
|
|
|
|
fn probes_satisfy_deserialize(probes: &[SinkProbe], require_invoked: bool) -> bool {
|
2026-05-18 01:08:32 -05:00
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
2026-05-21 14:35:42 -05:00
|
|
|
|
ProbeKind::Deserialize {
|
|
|
|
|
|
gadget_chain_invoked,
|
|
|
|
|
|
} => *gadget_chain_invoked == require_invoked,
|
2026-05-17 16:37:20 -05:00
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 20:39:12 -05:00
|
|
|
|
/// True when at least one drained probe is a [`ProbeKind::Xxe`]
|
|
|
|
|
|
/// record matching `require_expanded`.
|
|
|
|
|
|
fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
|
2026-05-18 01:08:32 -05:00
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::Xxe { entity_expanded } => *entity_expanded == require_expanded,
|
2026-05-17 20:39:12 -05:00
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 23:47:12 -05:00
|
|
|
|
/// True when at least one drained probe carries a query-count kind
|
|
|
|
|
|
/// whose count exceeds `n`. Matches both [`ProbeKind::Ldap`]
|
|
|
|
|
|
/// (`entries_returned > n`) and [`ProbeKind::Xpath`]
|
|
|
|
|
|
/// (`nodes_returned > n`).
|
|
|
|
|
|
fn probes_satisfy_count_gt(probes: &[SinkProbe], n: u32) -> bool {
|
2026-05-18 01:08:32 -05:00
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::Ldap { entries_returned } => *entries_returned > n,
|
|
|
|
|
|
ProbeKind::Xpath { nodes_returned } => *nodes_returned > n,
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// True when at least one drained probe is a
|
|
|
|
|
|
/// [`ProbeKind::HeaderEmit`] record whose `name` matches `header_name`
|
|
|
|
|
|
/// (or `header_name == "*"`) and whose `value` contains a literal
|
|
|
|
|
|
/// `\r\n` byte pair. Powers
|
|
|
|
|
|
/// [`ProbePredicate::HeaderInjected`] (Phase 08 — Track J.6).
|
|
|
|
|
|
fn probes_satisfy_header_injected(probes: &[SinkProbe], header_name: &str) -> bool {
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
2026-05-22 03:30:48 -05:00
|
|
|
|
ProbeKind::HeaderEmit { name, value, .. } => {
|
2026-05-21 14:35:42 -05:00
|
|
|
|
(header_name == "*" || name.eq_ignore_ascii_case(header_name)) && value.contains("\r\n")
|
2026-05-18 01:08:32 -05:00
|
|
|
|
}
|
2026-05-17 22:32:44 -05:00
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 04:20:02 -05:00
|
|
|
|
/// True when at least one drained probe is a
|
|
|
|
|
|
/// [`ProbeKind::HeaderWireFrame`] whose `raw_bytes` carries two
|
|
|
|
|
|
/// distinct `\r\n`-terminated header lines whose leading tokens
|
|
|
|
|
|
/// (everything before the first `:`) match `primary` and `smuggled`
|
|
|
|
|
|
/// case-insensitively. Powers
|
|
|
|
|
|
/// [`ProbePredicate::HeaderSmuggledInWire`] (Phase 08 — Track J.6).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Same line must not satisfy both names; the predicate models two
|
|
|
|
|
|
/// independent header lines, not a single line whose value happens
|
|
|
|
|
|
/// to contain a `:` substring.
|
|
|
|
|
|
fn probes_satisfy_header_smuggled_in_wire(
|
|
|
|
|
|
probes: &[SinkProbe],
|
|
|
|
|
|
primary: &str,
|
|
|
|
|
|
smuggled: &str,
|
|
|
|
|
|
) -> bool {
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::HeaderWireFrame { raw_bytes } => {
|
|
|
|
|
|
wire_frame_has_distinct_header_lines(raw_bytes, primary, smuggled)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns `true` when `bytes` contains a `\r\n`-terminated line
|
|
|
|
|
|
/// whose leading `name:` token matches `primary` (case-insensitive)
|
|
|
|
|
|
/// *and* a separate `\r\n`-terminated line whose leading `name:`
|
|
|
|
|
|
/// token matches `smuggled`. The two matches must come from
|
|
|
|
|
|
/// distinct lines. Lines without a `:` are skipped.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Used by [`probes_satisfy_header_smuggled_in_wire`]; pulled out so
|
|
|
|
|
|
/// the colocated tests can exercise the wire-byte scan directly.
|
|
|
|
|
|
pub(crate) fn wire_frame_has_distinct_header_lines(
|
|
|
|
|
|
bytes: &[u8],
|
|
|
|
|
|
primary: &str,
|
|
|
|
|
|
smuggled: &str,
|
|
|
|
|
|
) -> bool {
|
|
|
|
|
|
let text = match std::str::from_utf8(bytes) {
|
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
|
Err(_) => return false,
|
|
|
|
|
|
};
|
|
|
|
|
|
let primary_lower = primary.trim().to_ascii_lowercase();
|
|
|
|
|
|
let smuggled_lower = smuggled.trim().to_ascii_lowercase();
|
|
|
|
|
|
if primary_lower.is_empty() || smuggled_lower.is_empty() {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut saw_primary = false;
|
|
|
|
|
|
let mut saw_smuggled = false;
|
|
|
|
|
|
for line in text.split("\r\n") {
|
|
|
|
|
|
let Some(colon) = line.find(':') else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
let name = line[..colon].trim().to_ascii_lowercase();
|
|
|
|
|
|
if !saw_primary && name == primary_lower {
|
|
|
|
|
|
saw_primary = true;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !saw_smuggled && name == smuggled_lower {
|
|
|
|
|
|
saw_smuggled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
saw_primary && saw_smuggled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:32:13 -05:00
|
|
|
|
/// True when at least one drained probe is a [`ProbeKind::Redirect`]
|
|
|
|
|
|
/// record whose extracted `location` host falls outside the
|
|
|
|
|
|
/// `allowlist ∪ {request_host}` set. Powers
|
|
|
|
|
|
/// [`ProbePredicate::RedirectHostNotIn`] (Phase 09 — Track J.7).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Same-origin redirects (relative path, or absolute URL whose host
|
|
|
|
|
|
/// equals `request_host`) never fire — they cannot leave the
|
|
|
|
|
|
/// application origin regardless of allowlist contents. Schemeless
|
|
|
|
|
|
/// `//host/...` references are parsed as off-origin.
|
|
|
|
|
|
fn probes_satisfy_redirect_off_origin(probes: &[SinkProbe], allowlist: &[&str]) -> bool {
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
2026-05-21 14:35:42 -05:00
|
|
|
|
ProbeKind::Redirect {
|
|
|
|
|
|
location,
|
|
|
|
|
|
request_host,
|
|
|
|
|
|
} => redirect_is_off_origin(location, request_host, allowlist),
|
2026-05-18 02:32:13 -05:00
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 08:02:10 -05:00
|
|
|
|
/// True when at least one drained probe is a
|
|
|
|
|
|
/// [`ProbeKind::PrototypePollution`] record whose `property` matches
|
|
|
|
|
|
/// `canary`. Powers
|
|
|
|
|
|
/// [`ProbePredicate::PrototypeCanaryTouched`] (Phase 10 — Track J.8).
|
|
|
|
|
|
fn probes_satisfy_prototype_canary(probes: &[SinkProbe], canary: &str) -> bool {
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::PrototypePollution { property, .. } => property == canary,
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 09:37:37 -05:00
|
|
|
|
/// True when at least one drained probe is a [`ProbeKind::WeakKey`]
|
|
|
|
|
|
/// record whose `key_int` is strictly less than `2^max_bits`. Powers
|
|
|
|
|
|
/// [`ProbePredicate::WeakKeyEntropy`] (Phase 11 — Track J.9).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `max_bits >= 64` is treated as "never fires" — a 64-bit key
|
|
|
|
|
|
/// trivially exceeds any sub-search-space budget once you cap the
|
|
|
|
|
|
/// integer view at `u64`. The brief calls for a 16-bit search-space
|
|
|
|
|
|
/// oracle, so the real threshold sits far below `2^64`.
|
|
|
|
|
|
fn probes_satisfy_weak_key(probes: &[SinkProbe], max_bits: u32) -> bool {
|
|
|
|
|
|
if max_bits == 0 {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if max_bits >= 64 {
|
|
|
|
|
|
return probes
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|p| matches!(p.kind, ProbeKind::WeakKey { .. }));
|
|
|
|
|
|
}
|
|
|
|
|
|
let budget = 1u64 << max_bits;
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::WeakKey { key_int } => *key_int < budget,
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// True when at least one drained probe is a
|
|
|
|
|
|
/// [`ProbeKind::IdorAccess`] record whose `caller_id` differs from
|
|
|
|
|
|
/// `owner_id`. Powers
|
|
|
|
|
|
/// [`ProbePredicate::IdorBoundaryCrossed`] (Phase 11 — Track J.9).
|
|
|
|
|
|
fn probes_satisfy_idor_crossed(probes: &[SinkProbe]) -> bool {
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
2026-05-21 14:35:42 -05:00
|
|
|
|
ProbeKind::IdorAccess {
|
|
|
|
|
|
caller_id,
|
|
|
|
|
|
owner_id,
|
|
|
|
|
|
} => caller_id != owner_id,
|
2026-05-18 09:37:37 -05:00
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// True when at least one drained probe is a
|
|
|
|
|
|
/// [`ProbeKind::OutboundNetwork`] record whose `host` falls outside
|
|
|
|
|
|
/// `allowlist` (case-insensitive). Powers
|
|
|
|
|
|
/// [`ProbePredicate::OutboundHostNotIn`] (Phase 11 — Track J.9).
|
|
|
|
|
|
fn probes_satisfy_outbound_off_list(probes: &[SinkProbe], allowlist: &[&str]) -> bool {
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::OutboundNetwork { host } => {
|
|
|
|
|
|
let h = host.trim().to_ascii_lowercase();
|
|
|
|
|
|
if h.is_empty() {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-21 14:35:42 -05:00
|
|
|
|
!allowlist.iter().any(|a| h == a.trim().to_ascii_lowercase())
|
2026-05-18 09:37:37 -05:00
|
|
|
|
}
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 06:48:32 -05:00
|
|
|
|
/// True when at least one drained probe is a
|
|
|
|
|
|
/// [`ProbeKind::JsonParse`] record whose `depth > max_depth` OR whose
|
|
|
|
|
|
/// `excessive_depth` flag is set. Powers
|
|
|
|
|
|
/// [`ProbePredicate::JsonParseExcessiveDepth`] (Phase 11 — Track J.9).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `excessive_depth` short-circuits — a shim that already caught the
|
|
|
|
|
|
/// parser's own recursion-limit signal can emit
|
|
|
|
|
|
/// `JsonParse { depth: 0, excessive_depth: true }` without counting
|
|
|
|
|
|
/// nesting manually and still trip the predicate.
|
|
|
|
|
|
fn probes_satisfy_json_parse_excessive(probes: &[SinkProbe], max_depth: u32) -> bool {
|
|
|
|
|
|
probes.iter().any(|p| match &p.kind {
|
|
|
|
|
|
ProbeKind::JsonParse {
|
|
|
|
|
|
depth,
|
|
|
|
|
|
excessive_depth,
|
|
|
|
|
|
} => *excessive_depth || *depth > max_depth,
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:32:13 -05:00
|
|
|
|
/// Returns `true` when `location` redirects to a host that is neither
|
2026-05-18 02:44:19 -05:00
|
|
|
|
/// `request_host` nor any entry of `allowlist`. Crate-visible so the
|
|
|
|
|
|
/// in-crate predicate above and the colocated tests can share one
|
|
|
|
|
|
/// canonical off-origin check.
|
|
|
|
|
|
pub(crate) fn redirect_is_off_origin(
|
2026-05-18 02:32:13 -05:00
|
|
|
|
location: &str,
|
|
|
|
|
|
request_host: &str,
|
|
|
|
|
|
allowlist: &[&str],
|
|
|
|
|
|
) -> bool {
|
|
|
|
|
|
let Some(host) = extract_redirect_host(location) else {
|
|
|
|
|
|
// No host component (relative path) → same-origin → safe.
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
let host_lower = host.to_ascii_lowercase();
|
2026-05-21 14:35:42 -05:00
|
|
|
|
if !request_host.is_empty() && host_lower == request_host.trim().to_ascii_lowercase() {
|
2026-05-18 02:32:13 -05:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
!allowlist
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|h| host_lower == h.trim().to_ascii_lowercase())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Extract the host component from a `Location:` value. Returns
|
|
|
|
|
|
/// `None` for a relative path (no scheme, no leading `//`).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Recognises three shapes:
|
|
|
|
|
|
/// 1. `scheme://host/path` — yields `host`.
|
|
|
|
|
|
/// 2. `//host/path` (schemeless / protocol-relative) — yields `host`.
|
|
|
|
|
|
/// 3. `/path` or `path` — yields `None` (same-origin).
|
|
|
|
|
|
fn extract_redirect_host(location: &str) -> Option<String> {
|
|
|
|
|
|
let trimmed = location.trim();
|
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let rest = if let Some(after_scheme) = trimmed.find("://") {
|
|
|
|
|
|
&trimmed[after_scheme + 3..]
|
|
|
|
|
|
} else if let Some(stripped) = trimmed.strip_prefix("//") {
|
|
|
|
|
|
stripped
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
};
|
|
|
|
|
|
// Strip path / query / fragment from the host segment.
|
2026-05-21 14:35:42 -05:00
|
|
|
|
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
2026-05-18 02:32:13 -05:00
|
|
|
|
let authority = &rest[..end];
|
2026-05-18 02:44:19 -05:00
|
|
|
|
// Strip userinfo + port. Bracketed IPv6 authorities (`[::1]` or
|
|
|
|
|
|
// `[::1]:8080`) must keep the brackets together — splitting on the
|
|
|
|
|
|
// last `:` inside the literal would slice the address apart.
|
2026-05-21 14:35:42 -05:00
|
|
|
|
let after_userinfo = authority
|
|
|
|
|
|
.rsplit_once('@')
|
|
|
|
|
|
.map(|(_, h)| h)
|
|
|
|
|
|
.unwrap_or(authority);
|
2026-05-18 02:44:19 -05:00
|
|
|
|
let host_only = if let Some(rest) = after_userinfo.strip_prefix('[') {
|
|
|
|
|
|
match rest.find(']') {
|
|
|
|
|
|
Some(end) => &after_userinfo[..end + 2],
|
|
|
|
|
|
None => after_userinfo,
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
after_userinfo
|
|
|
|
|
|
.rsplit_once(':')
|
|
|
|
|
|
.map(|(h, _)| h)
|
|
|
|
|
|
.unwrap_or(after_userinfo)
|
|
|
|
|
|
};
|
2026-05-18 02:32:13 -05:00
|
|
|
|
let h = host_only.trim();
|
|
|
|
|
|
if h.is_empty() {
|
|
|
|
|
|
None
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Some(h.to_owned())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 05:35:28 -05:00
|
|
|
|
/// 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,
|
2026-05-17 16:37:20 -05:00
|
|
|
|
// Cross-cutting predicates; not evaluable against a single probe.
|
|
|
|
|
|
// [`oracle_fired_with_stubs`] handles them via the partition path.
|
|
|
|
|
|
ProbePredicate::StubEventMatches { .. }
|
2026-05-17 18:51:13 -05:00
|
|
|
|
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
2026-05-17 20:39:12 -05:00
|
|
|
|
| ProbePredicate::TemplateEvalEqual { .. }
|
2026-05-17 22:32:44 -05:00
|
|
|
|
| ProbePredicate::XxeEntityExpanded { .. }
|
2026-05-18 01:08:32 -05:00
|
|
|
|
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
2026-05-18 02:32:13 -05:00
|
|
|
|
| ProbePredicate::HeaderInjected { .. }
|
2026-05-22 04:20:02 -05:00
|
|
|
|
| ProbePredicate::HeaderSmuggledInWire { .. }
|
2026-05-18 08:02:10 -05:00
|
|
|
|
| ProbePredicate::RedirectHostNotIn { .. }
|
2026-05-18 09:37:37 -05:00
|
|
|
|
| ProbePredicate::PrototypeCanaryTouched { .. }
|
|
|
|
|
|
| ProbePredicate::WeakKeyEntropy { .. }
|
|
|
|
|
|
| ProbePredicate::IdorBoundaryCrossed
|
2026-05-22 06:48:32 -05:00
|
|
|
|
| ProbePredicate::OutboundHostNotIn { .. }
|
|
|
|
|
|
| ProbePredicate::JsonParseExcessiveDepth { .. } => true,
|
2026-05-14 05:35:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 13:10:22 -05:00
|
|
|
|
/// 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> {
|
2026-05-18 01:08:32 -05:00
|
|
|
|
match &probe.kind {
|
|
|
|
|
|
ProbeKind::Crash { signal } => Some(*signal),
|
2026-05-17 22:32:44 -05:00
|
|
|
|
ProbeKind::Normal
|
|
|
|
|
|
| ProbeKind::Deserialize { .. }
|
|
|
|
|
|
| ProbeKind::Xxe { .. }
|
2026-05-17 23:47:12 -05:00
|
|
|
|
| ProbeKind::Ldap { .. }
|
2026-05-18 01:08:32 -05:00
|
|
|
|
| ProbeKind::Xpath { .. }
|
2026-05-18 02:32:13 -05:00
|
|
|
|
| ProbeKind::HeaderEmit { .. }
|
2026-05-22 04:20:02 -05:00
|
|
|
|
| ProbeKind::HeaderWireFrame { .. }
|
2026-05-18 08:02:10 -05:00
|
|
|
|
| ProbeKind::Redirect { .. }
|
2026-05-18 09:37:37 -05:00
|
|
|
|
| ProbeKind::PrototypePollution { .. }
|
|
|
|
|
|
| ProbeKind::WeakKey { .. }
|
|
|
|
|
|
| ProbeKind::IdorAccess { .. }
|
2026-05-22 06:48:32 -05:00
|
|
|
|
| ProbeKind::OutboundNetwork { .. }
|
|
|
|
|
|
| ProbeKind::JsonParse { .. } => None,
|
2026-05-14 13:10:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 05:35:28 -05:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
2026-05-14 13:10:22 -05:00
|
|
|
|
use crate::dynamic::probe::{ProbeArg, ProbeKind, ProbeWitness, SinkProbe};
|
2026-05-14 05:35:28 -05:00
|
|
|
|
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),
|
2026-05-15 11:28:47 -05:00
|
|
|
|
hardening_outcome: None,
|
2026-05-14 05:35:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn probe(callee: &str, args: Vec<ProbeArg>) -> SinkProbe {
|
|
|
|
|
|
SinkProbe {
|
|
|
|
|
|
sink_callee: callee.into(),
|
|
|
|
|
|
args,
|
|
|
|
|
|
captured_at_ns: 1,
|
|
|
|
|
|
payload_id: "test".into(),
|
2026-05-14 13:10:22 -05:00
|
|
|
|
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(),
|
2026-05-14 05:35:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn sink_probe_fires_when_predicates_match() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[
|
|
|
|
|
|
ProbePredicate::CalleeEquals("os.system"),
|
2026-05-21 14:35:42 -05:00
|
|
|
|
ProbePredicate::ArgContains {
|
|
|
|
|
|
index: 0,
|
|
|
|
|
|
needle: "; echo",
|
|
|
|
|
|
},
|
2026-05-14 05:35:28 -05:00
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
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"),
|
2026-05-21 14:35:42 -05:00
|
|
|
|
ProbePredicate::ArgContains {
|
|
|
|
|
|
index: 0,
|
|
|
|
|
|
needle: "NEVER_PRESENT",
|
|
|
|
|
|
},
|
2026-05-14 05:35:28 -05:00
|
|
|
|
],
|
|
|
|
|
|
};
|
2026-05-21 14:35:42 -05:00
|
|
|
|
let probes = vec![probe("os.system", vec![ProbeArg::String("hello".into())])];
|
2026-05-14 05:35:28 -05:00
|
|
|
|
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 {
|
2026-05-21 14:35:42 -05:00
|
|
|
|
predicates: &[ProbePredicate::ArgEquals {
|
|
|
|
|
|
index: 0,
|
|
|
|
|
|
value: "exact",
|
|
|
|
|
|
}],
|
2026-05-14 05:35:28 -05:00
|
|
|
|
};
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-05-14 13:10:22 -05:00
|
|
|
|
|
|
|
|
|
|
#[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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 18:51:13 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn template_eval_equal_fires_on_matching_render_json() {
|
|
|
|
|
|
let mut o = outcome();
|
|
|
|
|
|
o.stdout = br#"{"render":"49"}"#.to_vec();
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(oracle_fired(&oracle, &o, &[]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn template_eval_equal_ignores_non_matching_render() {
|
|
|
|
|
|
let mut o = outcome();
|
|
|
|
|
|
o.stdout = br#"{"render":"7*7"}"#.to_vec();
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &o, &[]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn template_eval_equal_returns_false_when_stdout_empty() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &[]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn template_eval_equal_skips_non_json_lines() {
|
|
|
|
|
|
let mut o = outcome();
|
|
|
|
|
|
o.stdout = b"warning: hello\n{\"render\":\"49\"}\n".to_vec();
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::TemplateEvalEqual { expected: 49 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(oracle_fired(&oracle, &o, &[]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:32:13 -05:00
|
|
|
|
fn redirect_probe(location: &str, request_host: &str) -> SinkProbe {
|
|
|
|
|
|
SinkProbe {
|
|
|
|
|
|
sink_callee: "HttpServletResponse.sendRedirect".into(),
|
|
|
|
|
|
args: vec![],
|
|
|
|
|
|
captured_at_ns: 1,
|
|
|
|
|
|
payload_id: "phase09".into(),
|
|
|
|
|
|
kind: ProbeKind::Redirect {
|
|
|
|
|
|
location: location.into(),
|
|
|
|
|
|
request_host: request_host.into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
witness: ProbeWitness::empty(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn redirect_off_origin_fires_when_host_outside_allowlist() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::RedirectHostNotIn {
|
|
|
|
|
|
allowlist: &["example.com", "www.example.com"],
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![redirect_probe("https://attacker.test/", "example.com")];
|
|
|
|
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn redirect_off_origin_clears_on_same_origin_path() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::RedirectHostNotIn {
|
|
|
|
|
|
allowlist: &["example.com"],
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![redirect_probe("/dashboard", "example.com")];
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn redirect_off_origin_clears_on_allowlisted_host() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::RedirectHostNotIn {
|
|
|
|
|
|
allowlist: &["example.com", "cdn.example.com"],
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
2026-05-21 14:35:42 -05:00
|
|
|
|
let probes = vec![redirect_probe(
|
|
|
|
|
|
"https://cdn.example.com/asset",
|
|
|
|
|
|
"example.com",
|
|
|
|
|
|
)];
|
2026-05-18 02:32:13 -05:00
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn redirect_off_origin_clears_when_host_matches_request_host() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: &[] }],
|
|
|
|
|
|
};
|
2026-05-21 14:35:42 -05:00
|
|
|
|
let probes = vec![redirect_probe(
|
|
|
|
|
|
"https://example.com/dashboard",
|
|
|
|
|
|
"example.com",
|
|
|
|
|
|
)];
|
2026-05-18 02:32:13 -05:00
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn redirect_off_origin_fires_on_schemeless_authority() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::RedirectHostNotIn {
|
|
|
|
|
|
allowlist: &["example.com"],
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![redirect_probe("//attacker.test/path", "example.com")];
|
|
|
|
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn redirect_off_origin_ignores_unrelated_probes() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::RedirectHostNotIn {
|
|
|
|
|
|
allowlist: &["example.com"],
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![probe("noop", vec![])];
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_redirect_host_handles_authority_variants() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
extract_redirect_host("https://attacker.test/path"),
|
|
|
|
|
|
Some("attacker.test".to_owned()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
extract_redirect_host("//attacker.test:8080/path"),
|
|
|
|
|
|
Some("attacker.test".to_owned()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
extract_redirect_host("https://user:pass@evil.example/?q=1"),
|
|
|
|
|
|
Some("evil.example".to_owned()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(extract_redirect_host("/dashboard"), None);
|
|
|
|
|
|
assert_eq!(extract_redirect_host(""), None);
|
2026-05-18 02:44:19 -05:00
|
|
|
|
// IPv6 bracketed authorities — host literal must keep brackets
|
|
|
|
|
|
// and not be split on the colons inside the address.
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
extract_redirect_host("https://[::1]/path"),
|
|
|
|
|
|
Some("[::1]".to_owned()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
extract_redirect_host("https://[::1]:8080/path"),
|
|
|
|
|
|
Some("[::1]".to_owned()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
extract_redirect_host("https://[2001:db8::1]/x"),
|
|
|
|
|
|
Some("[2001:db8::1]".to_owned()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
extract_redirect_host("//[fe80::1]:443/y"),
|
|
|
|
|
|
Some("[fe80::1]".to_owned()),
|
|
|
|
|
|
);
|
|
|
|
|
|
// IPv6 literal in allowlist round-trips through the off-origin
|
|
|
|
|
|
// check now that the host fragment is well-formed.
|
|
|
|
|
|
assert!(!redirect_is_off_origin(
|
|
|
|
|
|
"https://[::1]/admin",
|
|
|
|
|
|
"example.com",
|
|
|
|
|
|
&["[::1]"],
|
|
|
|
|
|
));
|
|
|
|
|
|
assert!(redirect_is_off_origin(
|
|
|
|
|
|
"https://[2001:db8::dead]/x",
|
|
|
|
|
|
"example.com",
|
|
|
|
|
|
&["[::1]"],
|
|
|
|
|
|
));
|
2026-05-18 02:32:13 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 08:02:10 -05:00
|
|
|
|
fn prototype_pollution_probe(property: &str, value: &str) -> SinkProbe {
|
|
|
|
|
|
SinkProbe {
|
|
|
|
|
|
sink_callee: "__nyx_pp_canary_set".into(),
|
|
|
|
|
|
args: vec![],
|
|
|
|
|
|
captured_at_ns: 1,
|
|
|
|
|
|
payload_id: "phase10".into(),
|
|
|
|
|
|
kind: ProbeKind::PrototypePollution {
|
|
|
|
|
|
property: property.into(),
|
|
|
|
|
|
value: value.into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
witness: ProbeWitness::empty(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn prototype_canary_touched_fires_on_matching_property() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::PrototypeCanaryTouched {
|
|
|
|
|
|
canary: "__nyx_canary",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![prototype_pollution_probe("__nyx_canary", "pwned")];
|
|
|
|
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn prototype_canary_touched_ignores_mismatched_property() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::PrototypeCanaryTouched {
|
|
|
|
|
|
canary: "__nyx_canary",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![prototype_pollution_probe("__other__", "x")];
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn prototype_canary_touched_clears_when_no_pp_probe() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::PrototypeCanaryTouched {
|
|
|
|
|
|
canary: "__nyx_canary",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![probe("noop", vec![])];
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 04:20:02 -05:00
|
|
|
|
fn header_emit_probe(name: &str, value: &str) -> SinkProbe {
|
|
|
|
|
|
SinkProbe {
|
|
|
|
|
|
sink_callee: "HttpServletResponse.setHeader".into(),
|
|
|
|
|
|
args: vec![],
|
|
|
|
|
|
captured_at_ns: 1,
|
|
|
|
|
|
payload_id: "phase08".into(),
|
|
|
|
|
|
kind: ProbeKind::HeaderEmit {
|
|
|
|
|
|
name: name.into(),
|
|
|
|
|
|
value: value.into(),
|
|
|
|
|
|
protocol: crate::dynamic::probe::HeaderEmitProtocol::InProcess,
|
|
|
|
|
|
},
|
|
|
|
|
|
witness: ProbeWitness::empty(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn header_wire_probe(raw: &[u8]) -> SinkProbe {
|
|
|
|
|
|
SinkProbe {
|
|
|
|
|
|
sink_callee: "wire-tap".into(),
|
|
|
|
|
|
args: vec![],
|
|
|
|
|
|
captured_at_ns: 1,
|
|
|
|
|
|
payload_id: "phase08-wire".into(),
|
|
|
|
|
|
kind: ProbeKind::HeaderWireFrame {
|
|
|
|
|
|
raw_bytes: raw.to_vec(),
|
|
|
|
|
|
},
|
|
|
|
|
|
witness: ProbeWitness::empty(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn header_smuggled_in_wire_fires_on_two_distinct_header_lines() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
|
|
|
|
|
primary: "Set-Cookie",
|
|
|
|
|
|
smuggled: "X-Injected",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
2026-05-22 09:42:18 -05:00
|
|
|
|
let probes = vec![header_wire_probe(b"Set-Cookie: a=1\r\nX-Injected: 1\r\n")];
|
2026-05-22 04:20:02 -05:00
|
|
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn header_smuggled_in_wire_clears_when_only_primary_line_present() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
|
|
|
|
|
primary: "Set-Cookie",
|
|
|
|
|
|
smuggled: "X-Injected",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
|
|
|
|
|
// Benign control: framework URL-encoded the CRLF on the way
|
|
|
|
|
|
// to the wire, leaving the original Set-Cookie intact and no
|
|
|
|
|
|
// sibling X-Injected line.
|
|
|
|
|
|
let probes = vec![header_wire_probe(
|
|
|
|
|
|
b"Set-Cookie: a=1%0d%0aX-Injected:%201\r\n",
|
|
|
|
|
|
)];
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn header_smuggled_in_wire_matches_case_insensitively() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
|
|
|
|
|
primary: "set-cookie",
|
|
|
|
|
|
smuggled: "x-injected",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
2026-05-22 09:42:18 -05:00
|
|
|
|
let probes = vec![header_wire_probe(b"SET-COOKIE: a=1\r\nX-INJECTED: 1\r\n")];
|
2026-05-22 04:20:02 -05:00
|
|
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn header_smuggled_in_wire_ignores_header_emit_probes() {
|
|
|
|
|
|
// A tier-(a) HeaderEmit probe whose value carries `\r\n`
|
|
|
|
|
|
// satisfies HeaderInjected but must not satisfy
|
|
|
|
|
|
// HeaderSmuggledInWire — that predicate proves the bytes
|
|
|
|
|
|
// survived to the response socket.
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::HeaderSmuggledInWire {
|
|
|
|
|
|
primary: "Set-Cookie",
|
|
|
|
|
|
smuggled: "X-Injected",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
2026-05-22 09:42:18 -05:00
|
|
|
|
let probes = vec![header_emit_probe("Set-Cookie", "a=1\r\nX-Injected: 1")];
|
2026-05-22 04:20:02 -05:00
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn header_injected_ignores_header_wire_frame_probes() {
|
|
|
|
|
|
// Symmetric: the existing HeaderInjected predicate must keep
|
|
|
|
|
|
// ignoring wire-frame probes — those only satisfy the new
|
|
|
|
|
|
// wire-smuggling predicate.
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::HeaderInjected {
|
|
|
|
|
|
header_name: "Set-Cookie",
|
|
|
|
|
|
}],
|
|
|
|
|
|
};
|
2026-05-22 09:42:18 -05:00
|
|
|
|
let probes = vec![header_wire_probe(b"Set-Cookie: a=1\r\nX-Injected: 1\r\n")];
|
2026-05-22 04:20:02 -05:00
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn wire_frame_helper_handles_repeated_primary_name_via_self_smuggling() {
|
|
|
|
|
|
// Classic CRLF smuggling attack: attacker injects a second
|
|
|
|
|
|
// `Set-Cookie` line by tunnelling through the original. The
|
|
|
|
|
|
// helper accepts same-name twice as proof when `primary`
|
|
|
|
|
|
// and `smuggled` are configured to the same name.
|
|
|
|
|
|
assert!(wire_frame_has_distinct_header_lines(
|
|
|
|
|
|
b"Set-Cookie: original=1\r\nSet-Cookie: attacker=1\r\n",
|
|
|
|
|
|
"Set-Cookie",
|
|
|
|
|
|
"Set-Cookie",
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn wire_frame_helper_rejects_single_line_with_inline_colon_value() {
|
|
|
|
|
|
// A line like `Set-Cookie: foo=bar; ext=baz` contains a `:`
|
|
|
|
|
|
// in the value segment but only one true header line; the
|
|
|
|
|
|
// helper splits on `\r\n` so the value's `:` cannot satisfy
|
|
|
|
|
|
// the smuggled predicate by itself.
|
|
|
|
|
|
assert!(!wire_frame_has_distinct_header_lines(
|
|
|
|
|
|
b"Set-Cookie: foo=bar; ext=baz\r\n",
|
|
|
|
|
|
"Set-Cookie",
|
|
|
|
|
|
"X-Injected",
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn wire_frame_helper_rejects_non_utf8_bytes() {
|
|
|
|
|
|
assert!(!wire_frame_has_distinct_header_lines(
|
|
|
|
|
|
&[0xff, 0xfe, 0xfd],
|
|
|
|
|
|
"Set-Cookie",
|
|
|
|
|
|
"X-Injected",
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 13:10:22 -05:00
|
|
|
|
#[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, &[]));
|
|
|
|
|
|
}
|
2026-05-22 06:48:32 -05:00
|
|
|
|
|
|
|
|
|
|
fn json_parse_probe(depth: u32, excessive_depth: bool) -> SinkProbe {
|
|
|
|
|
|
SinkProbe {
|
|
|
|
|
|
sink_callee: "json.loads".into(),
|
|
|
|
|
|
args: vec![],
|
|
|
|
|
|
captured_at_ns: 1,
|
|
|
|
|
|
payload_id: "phase11-json".into(),
|
|
|
|
|
|
kind: ProbeKind::JsonParse {
|
|
|
|
|
|
depth,
|
|
|
|
|
|
excessive_depth,
|
|
|
|
|
|
},
|
|
|
|
|
|
witness: ProbeWitness::empty(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn json_parse_excessive_depth_fires_when_depth_exceeds_budget() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::JsonParseExcessiveDepth { max_depth: 64 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
let probes = vec![json_parse_probe(512, false)];
|
|
|
|
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn json_parse_excessive_depth_fires_on_short_circuit_flag_even_with_zero_depth() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::JsonParseExcessiveDepth { max_depth: 64 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
// Shim caught the parser's own recursion limit and emitted
|
|
|
|
|
|
// `excessive_depth: true` without counting nesting — predicate
|
|
|
|
|
|
// should still fire.
|
|
|
|
|
|
let probes = vec![json_parse_probe(0, true)];
|
|
|
|
|
|
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn json_parse_excessive_depth_clears_when_depth_within_budget() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::JsonParseExcessiveDepth { max_depth: 64 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
// Benign control: shallowly nested object.
|
|
|
|
|
|
let probes = vec![json_parse_probe(3, false)];
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn json_parse_excessive_depth_ignores_unrelated_probe_kinds() {
|
|
|
|
|
|
let oracle = Oracle::SinkProbe {
|
|
|
|
|
|
predicates: &[ProbePredicate::JsonParseExcessiveDepth { max_depth: 64 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
// A HeaderEmit probe (different kind) must not satisfy the
|
|
|
|
|
|
// predicate even if the shim emitted both for the same payload.
|
|
|
|
|
|
let probes = vec![header_emit_probe("Set-Cookie", "noise")];
|
|
|
|
|
|
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
|
|
|
|
|
}
|
2026-05-14 05:35:28 -05:00
|
|
|
|
}
|