mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0014 (20260522T043516Z-29b8)
This commit is contained in:
parent
6f58921a17
commit
60914be62c
2 changed files with 327 additions and 0 deletions
|
|
@ -265,6 +265,39 @@ pub enum ProbePredicate {
|
|||
/// captured header whose value contains the CRLF pair.
|
||||
header_name: &'static str,
|
||||
},
|
||||
/// 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,
|
||||
},
|
||||
/// Phase 09 (Track J.7): open-redirect predicate.
|
||||
///
|
||||
/// Fires when at least one drained probe carries
|
||||
|
|
@ -544,6 +577,21 @@ pub fn oracle_fired_with_stubs(
|
|||
if !header_injected_ok {
|
||||
return false;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// Phase 09 (Track J.7): open-redirect cross-cutting
|
||||
// predicates. Each `RedirectHostNotIn { allowlist }`
|
||||
// consults the captured probe channel for a
|
||||
|
|
@ -634,6 +682,7 @@ pub fn oracle_fired_with_stubs(
|
|||
| ProbeKind::Ldap { .. }
|
||||
| ProbeKind::Xpath { .. }
|
||||
| ProbeKind::HeaderEmit { .. }
|
||||
| ProbeKind::HeaderWireFrame { .. }
|
||||
| ProbeKind::Redirect { .. }
|
||||
| ProbeKind::PrototypePollution { .. }
|
||||
| ProbeKind::WeakKey { .. }
|
||||
|
|
@ -666,6 +715,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
|
|||
| ProbePredicate::XxeEntityExpanded { .. }
|
||||
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||
| ProbePredicate::HeaderInjected { .. }
|
||||
| ProbePredicate::HeaderSmuggledInWire { .. }
|
||||
| ProbePredicate::RedirectHostNotIn { .. }
|
||||
| ProbePredicate::PrototypeCanaryTouched { .. }
|
||||
| ProbePredicate::WeakKeyEntropy { .. }
|
||||
|
|
@ -699,6 +749,11 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) ->
|
|||
// rather than stub events; evaluated separately in
|
||||
// [`probes_satisfy_header_injected`] below.
|
||||
ProbePredicate::HeaderInjected { .. } => true,
|
||||
// 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,
|
||||
// RedirectHostNotIn is cross-cutting against the *probe log*
|
||||
// rather than stub events; evaluated separately in
|
||||
// [`probes_satisfy_redirect_off_origin`] below.
|
||||
|
|
@ -804,6 +859,69 @@ fn probes_satisfy_header_injected(probes: &[SinkProbe], header_name: &str) -> bo
|
|||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// True when at least one drained probe is a [`ProbeKind::Redirect`]
|
||||
/// record whose extracted `location` host falls outside the
|
||||
/// `allowlist ∪ {request_host}` set. Powers
|
||||
|
|
@ -994,6 +1112,7 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
|
|||
| ProbePredicate::XxeEntityExpanded { .. }
|
||||
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||
| ProbePredicate::HeaderInjected { .. }
|
||||
| ProbePredicate::HeaderSmuggledInWire { .. }
|
||||
| ProbePredicate::RedirectHostNotIn { .. }
|
||||
| ProbePredicate::PrototypeCanaryTouched { .. }
|
||||
| ProbePredicate::WeakKeyEntropy { .. }
|
||||
|
|
@ -1026,6 +1145,7 @@ pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
|||
| ProbeKind::Ldap { .. }
|
||||
| ProbeKind::Xpath { .. }
|
||||
| ProbeKind::HeaderEmit { .. }
|
||||
| ProbeKind::HeaderWireFrame { .. }
|
||||
| ProbeKind::Redirect { .. }
|
||||
| ProbeKind::PrototypePollution { .. }
|
||||
| ProbeKind::WeakKey { .. }
|
||||
|
|
@ -1451,6 +1571,149 @@ mod tests {
|
|||
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
||||
}
|
||||
|
||||
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",
|
||||
}],
|
||||
};
|
||||
let probes = vec![header_wire_probe(
|
||||
b"Set-Cookie: a=1\r\nX-Injected: 1\r\n",
|
||||
)];
|
||||
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",
|
||||
}],
|
||||
};
|
||||
let probes = vec![header_wire_probe(
|
||||
b"SET-COOKIE: a=1\r\nX-INJECTED: 1\r\n",
|
||||
)];
|
||||
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",
|
||||
}],
|
||||
};
|
||||
let probes = vec![header_emit_probe(
|
||||
"Set-Cookie",
|
||||
"a=1\r\nX-Injected: 1",
|
||||
)];
|
||||
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",
|
||||
}],
|
||||
};
|
||||
let probes = vec![header_wire_probe(
|
||||
b"Set-Cookie: a=1\r\nX-Injected: 1\r\n",
|
||||
)];
|
||||
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",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sink_crash_without_probes_does_not_fire_even_on_process_crash() {
|
||||
let mut o = outcome();
|
||||
|
|
|
|||
|
|
@ -261,6 +261,32 @@ pub enum ProbeKind {
|
|||
#[serde(default)]
|
||||
protocol: HeaderEmitProtocol,
|
||||
},
|
||||
/// Phase 08 (Track J.6) wire-frame header-injection observation.
|
||||
///
|
||||
/// Stamped by a tier-(b) harness that boots a real Tomcat /
|
||||
/// werkzeug / `http.createServer` / `axum::serve` on a loopback
|
||||
/// port and taps the literal bytes the server wrote to the
|
||||
/// response socket. Unlike [`ProbeKind::HeaderEmit`], which
|
||||
/// captures one logical `(name, value)` pair before the host
|
||||
/// runtime's CRLF validator runs, this kind records the entire
|
||||
/// raw response-header block so the oracle can scan for two
|
||||
/// distinct `name:` lines — the proof that a CRLF-bearing
|
||||
/// attacker value actually smuggled a second header through to
|
||||
/// the wire rather than being stripped on the way out.
|
||||
///
|
||||
/// `raw_bytes` carries the bytes up to (but not including) the
|
||||
/// CRLF-CRLF that separates headers from the response body. No
|
||||
/// per-shim path produces this variant today; the schema lands
|
||||
/// now so the tier-(b) shims can write the variant without a
|
||||
/// follow-up oracle-side re-shape, matching the
|
||||
/// [`HeaderEmitProtocol::Wire`] discriminator pattern.
|
||||
HeaderWireFrame {
|
||||
/// Raw header-block bytes the underlying real server wrote
|
||||
/// to the response socket, terminated by the CRLF-CRLF
|
||||
/// boundary preceding the response body. Pre-CRLF-CRLF
|
||||
/// only; the body is not captured.
|
||||
raw_bytes: Vec<u8>,
|
||||
},
|
||||
/// Phase 09 (Track J.7) HTTP-redirect observation. Stamped by
|
||||
/// the per-language harness shim's instrumented redirect entry
|
||||
/// point (`HttpServletResponse.sendRedirect`, `flask.redirect`,
|
||||
|
|
@ -703,6 +729,44 @@ mod tests {
|
|||
assert!(!w.args_repr[0].contains("aaa-bbb-ccc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_kind_header_wire_frame_round_trips_through_channel() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ch = ProbeChannel::for_workdir(dir.path()).unwrap();
|
||||
let mut p = sample_probe("wire-smuggle");
|
||||
p.kind = ProbeKind::HeaderWireFrame {
|
||||
raw_bytes: b"HTTP/1.1 200 OK\r\nSet-Cookie: a=1\r\nX-Injected: 1\r\n".to_vec(),
|
||||
};
|
||||
ch.write(&p).unwrap();
|
||||
let drained = ch.drain();
|
||||
assert_eq!(drained.len(), 1);
|
||||
match &drained[0].kind {
|
||||
ProbeKind::HeaderWireFrame { raw_bytes } => {
|
||||
assert!(raw_bytes.windows(11).any(|w| w == b"Set-Cookie:"));
|
||||
assert!(raw_bytes.windows(11).any(|w| w == b"X-Injected:"));
|
||||
}
|
||||
other => panic!("expected HeaderWireFrame, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_kind_header_wire_frame_serdes_with_explicit_tag() {
|
||||
let p = SinkProbe {
|
||||
sink_callee: "wire".into(),
|
||||
args: vec![],
|
||||
captured_at_ns: 1,
|
||||
payload_id: "wire-1".into(),
|
||||
kind: ProbeKind::HeaderWireFrame {
|
||||
raw_bytes: b"Set-Cookie: a=1\r\nX-Injected: 1\r\n".to_vec(),
|
||||
},
|
||||
witness: ProbeWitness::empty(),
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains(r#""kind":"HeaderWireFrame""#));
|
||||
let round: SinkProbe = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(round.kind, ProbeKind::HeaderWireFrame { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_from_inputs_redacts_and_truncates() {
|
||||
let huge_payload = vec![0xAB; policy::PAYLOAD_CAPTURE_LIMIT_BYTES * 2];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue