[pitboss/grind] deferred session-0014 (20260522T043516Z-29b8)

This commit is contained in:
pitboss 2026-05-22 04:20:02 -05:00
parent 6f58921a17
commit 60914be62c
2 changed files with 327 additions and 0 deletions

View file

@ -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();

View file

@ -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];