[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

@ -0,0 +1,64 @@
//! JavaScript `Cap::PROTOTYPE_POLLUTION` payloads.
//!
//! Vuln payload: a JSON literal whose top-level key is `__proto__`.
//! When the host's deep-merge / `Object.assign` / JSON-parse-then-
//! deep-assign sink walks the key into the merge target, the
//! `__proto__` reference reaches `Object.prototype` and writes
//! `__nyx_canary`. The harness's instrumented setter trap records a
//! `ProbeKind::PrototypePollution { property: "__nyx_canary", … }`
//! probe; the predicate
//! [`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`]
//! fires on the captured canary write.
//!
//! Benign control: a JSON literal whose top-level key is the regular
//! property `data`. Naive deep-merge walks `target.data` rather than
//! `target.__proto__`, so the prototype chain stays untouched and the
//! predicate stays clear. The matching fixture
//! (`tests/dynamic_fixtures/prototype_pollution/javascript/benign.js`)
//! additionally demonstrates the canonical mitigation pattern of
//! using `Object.create(null)` as the merge target — static analysis
//! of the fixture surfaces the prototype-less target while the
//! runtime differential is driven by the absence of a `__proto__` key
//! in the payload bytes.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const CANARY: &str = "__nyx_canary";
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#,
label: "prototype-pollution-js-proto-key",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 14,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/javascript/vuln.js"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
benign_control: Some(PayloadRef {
label: "prototype-pollution-js-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#,
label: "prototype-pollution-js-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 14,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/javascript/benign.js"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -0,0 +1,20 @@
//! Prototype-pollution (`Cap::PROTOTYPE_POLLUTION`) per-language
//! payload slices.
//!
//! Phase 10 (Track J.8) carves the JavaScript / TypeScript prototype-
//! pollution gadget against three sink families: `lodash.merge`,
//! `Object.assign` with tainted RHS, and `JSON.parse`-then-deep-assign.
//! Every vuln payload binds a JSON literal whose top-level key is
//! `__proto__`; the harness's instrumented deep-merge walks the key
//! into `Object.prototype` and a `Proxy`-style setter trap on
//! `Object.prototype.__nyx_canary` records a
//! [`crate::dynamic::probe::ProbeKind::PrototypePollution`] probe. The
//! paired benign control sends a JSON literal whose top-level key is
//! the regular property `data`, leaving the prototype chain
//! untouched. The
//! [`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`]
//! predicate fires only on probes whose `property` equals the canary
//! name (`__nyx_canary`).
pub mod javascript;
pub mod typescript;

View file

@ -0,0 +1,50 @@
//! TypeScript `Cap::PROTOTYPE_POLLUTION` payloads.
//!
//! Mirrors [`super::javascript`] — the runtime is Node.js in both
//! cases, so the payload shape and oracle predicate are identical.
//! The per-language slice exists so the lang-aware corpus resolver
//! pairs TS vuln payloads against TS benign controls without crossing
//! the JS slice (and so the fixture paths point at the TS-specific
//! fixtures the static-analysis side consumes).
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const CANARY: &str = "__nyx_canary";
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#,
label: "prototype-pollution-ts-proto-key",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 14,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/typescript/vuln.ts"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
benign_control: Some(PayloadRef {
label: "prototype-pollution-ts-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#,
label: "prototype-pollution-ts-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 14,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/prototype_pollution/typescript/benign.ts"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -24,8 +24,8 @@ use std::collections::HashMap;
use std::sync::OnceLock;
use super::{
cmdi, deserialize, fmt_string, header_injection, ldap, open_redirect, path_trav, sqli, ssrf,
ssti, xpath, xss, xxe,
cmdi, deserialize, fmt_string, header_injection, ldap, open_redirect, path_trav,
prototype_pollution, sqli, ssrf, ssti, xpath, xss, xxe,
};
use super::{CapCorpus, CuratedPayload, Oracle};
use crate::dynamic::oracle::ProbePredicate;
@ -42,8 +42,7 @@ pub const CORPUS_UNSUPPORTED_LANG_NEUTRAL: u32 = Cap::ENV_VAR.bits()
| Cap::JSON_PARSE.bits()
| Cap::CRYPTO.bits()
| Cap::UNAUTHORIZED_ID.bits()
| Cap::DATA_EXFIL.bits()
| Cap::PROTOTYPE_POLLUTION.bits();
| Cap::DATA_EXFIL.bits();
/// Flat `(Cap, Lang, slice)` table. A single cap can carry per-language
/// variants — that's the whole reason this layer exists.
@ -89,6 +88,16 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[
(Cap::OPEN_REDIRECT, Lang::JavaScript, open_redirect::js::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Go, open_redirect::go::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Rust, open_redirect::rust::PAYLOADS),
(
Cap::PROTOTYPE_POLLUTION,
Lang::JavaScript,
prototype_pollution::javascript::PAYLOADS,
),
(
Cap::PROTOTYPE_POLLUTION,
Lang::TypeScript,
prototype_pollution::typescript::PAYLOADS,
),
];
/// Reserved for per-cap oracle defaults. Empty in Phase 02; populated by
@ -302,6 +311,7 @@ mod tests {
assert!(!payloads_for(Cap::XPATH_INJECTION).is_empty());
assert!(!payloads_for(Cap::HEADER_INJECTION).is_empty());
assert!(!payloads_for(Cap::OPEN_REDIRECT).is_empty());
assert!(!payloads_for(Cap::PROTOTYPE_POLLUTION).is_empty());
}
#[test]
@ -314,7 +324,6 @@ mod tests {
Cap::CRYPTO,
Cap::UNAUTHORIZED_ID,
Cap::DATA_EXFIL,
Cap::PROTOTYPE_POLLUTION,
];
for cap in unsupported {
assert!(
@ -349,6 +358,7 @@ mod tests {
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
Cap::PROTOTYPE_POLLUTION,
] {
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
assert!(has_vuln, "{cap:?} must have at least one vuln payload");
@ -402,6 +412,7 @@ mod tests {
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
Cap::PROTOTYPE_POLLUTION,
];
for cap in caps {
for p in payloads_for(cap) {
@ -430,6 +441,7 @@ mod tests {
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
Cap::PROTOTYPE_POLLUTION,
];
for cap in caps {
for p in payloads_for(cap) {
@ -545,6 +557,7 @@ mod tests {
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
Cap::PROTOTYPE_POLLUTION,
];
for cap in caps {
for p in payloads_for(cap).iter().filter(|p| p.is_benign) {
@ -849,6 +862,50 @@ mod tests {
}
}
#[test]
fn prototype_pollution_has_per_lang_slices_for_phase_10() {
// Phase 10 (Track J.8) acceptance: PROTOTYPE_POLLUTION
// registers payloads in JavaScript / TypeScript and the
// lang-aware lookup never returns empty for either.
for lang in [Lang::JavaScript, Lang::TypeScript] {
assert!(
!payloads_for_lang(Cap::PROTOTYPE_POLLUTION, lang).is_empty(),
"PROTOTYPE_POLLUTION must have at least one payload for {lang:?}",
);
}
// Other langs not covered.
for lang in [
Lang::Rust,
Lang::C,
Lang::Cpp,
Lang::Go,
Lang::Java,
Lang::Php,
Lang::Python,
Lang::Ruby,
] {
assert!(
payloads_for_lang(Cap::PROTOTYPE_POLLUTION, lang).is_empty(),
"PROTOTYPE_POLLUTION has unexpected payloads for {lang:?}",
);
}
}
#[test]
fn prototype_pollution_payloads_pair_benign_controls_per_lang() {
for lang in [Lang::JavaScript, Lang::TypeScript] {
let slice = payloads_for_lang(Cap::PROTOTYPE_POLLUTION, lang);
let vuln = slice
.iter()
.find(|p| !p.is_benign)
.expect("each lang must have a PROTOTYPE_POLLUTION vuln payload");
let resolved =
super::resolve_benign_control_lang(vuln, Cap::PROTOTYPE_POLLUTION, lang)
.expect("lang-aware benign control must resolve");
assert!(resolved.is_benign);
}
}
#[test]
fn deserialize_payloads_pair_benign_controls_per_lang() {
// The lang-aware resolver must find the paired benign control