[pitboss] phase 10: Track J.8 + Track L.8 — PROTOTYPE_POLLUTION corpus + JS/TS prototype chain hook

This commit is contained in:
pitboss 2026-05-18 08:02:10 -05:00
parent 97e4dfff30
commit d8f88d97bb
20 changed files with 1406 additions and 22 deletions

View file

@ -288,6 +288,33 @@ pub enum ProbePredicate {
/// from this slice.
allowlist: &'static [&'static str],
},
/// 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,
},
/// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count
/// predicate shared by LDAP-filter and XPath-expression injection.
///
@ -482,6 +509,21 @@ pub fn oracle_fired_with_stubs(
if !redirect_ok {
return false;
}
// 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;
}
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
// predicates. Each `TemplateEvalEqual { expected }` consults
// the captured stdout body — see [`stdout_template_equals`].
@ -515,7 +557,8 @@ pub fn oracle_fired_with_stubs(
| ProbeKind::Ldap { .. }
| ProbeKind::Xpath { .. }
| ProbeKind::HeaderEmit { .. }
| ProbeKind::Redirect { .. } => false,
| ProbeKind::Redirect { .. }
| ProbeKind::PrototypePollution { .. } => false,
}),
Oracle::OutputContains(needle) => {
let nb = needle.as_bytes();
@ -544,6 +587,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
| ProbePredicate::QueryResultCountGreaterThan { .. }
| ProbePredicate::HeaderInjected { .. }
| ProbePredicate::RedirectHostNotIn { .. }
| ProbePredicate::PrototypeCanaryTouched { .. }
)
}
@ -576,6 +620,10 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) ->
// rather than stub events; evaluated separately in
// [`probes_satisfy_redirect_off_origin`] below.
ProbePredicate::RedirectHostNotIn { .. } => true,
// PrototypeCanaryTouched is cross-cutting against the *probe
// log* rather than stub events; evaluated separately in
// [`probes_satisfy_prototype_canary`] below.
ProbePredicate::PrototypeCanaryTouched { .. } => true,
_ => true,
}
}
@ -685,6 +733,17 @@ fn probes_satisfy_redirect_off_origin(probes: &[SinkProbe], allowlist: &[&str])
})
}
/// 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,
})
}
/// Returns `true` when `location` redirects to a host that is neither
/// `request_host` nor any entry of `allowlist`. Crate-visible so the
/// in-crate predicate above and the colocated tests can share one
@ -791,7 +850,8 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
| ProbePredicate::XxeEntityExpanded { .. }
| ProbePredicate::QueryResultCountGreaterThan { .. }
| ProbePredicate::HeaderInjected { .. }
| ProbePredicate::RedirectHostNotIn { .. } => true,
| ProbePredicate::RedirectHostNotIn { .. }
| ProbePredicate::PrototypeCanaryTouched { .. } => true,
}
}
@ -819,7 +879,8 @@ pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
| ProbeKind::Ldap { .. }
| ProbeKind::Xpath { .. }
| ProbeKind::HeaderEmit { .. }
| ProbeKind::Redirect { .. } => None,
| ProbeKind::Redirect { .. }
| ProbeKind::PrototypePollution { .. } => None,
}
}
@ -1181,6 +1242,53 @@ mod tests {
));
}
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));
}
#[test]
fn sink_crash_without_probes_does_not_fire_even_on_process_crash() {
let mut o = outcome();