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

This commit is contained in:
pitboss 2026-05-22 07:30:20 -05:00
parent e9c35150de
commit e44f6ee1af
4 changed files with 558 additions and 4 deletions

View file

@ -54,4 +54,63 @@ pub const PAYLOADS: &[CuratedPayload] = &[
benign_control: None,
no_benign_control_rationale: None,
},
// Phase 08 tier-(b): raw-socket wire-frame smuggling payload.
// Same CRLF-bearing bytes as the axum payload above, but pinned to
// the `rust_raw` fixture (a `std::net::TcpListener` driven by
// `create_server` + `run_once` that writes raw bytes via
// `TcpStream::write_all`). The wire frame captured off the
// response socket carries two distinct `Set-Cookie:` lines, so
// `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled:
// "Set-Cookie" }` fires — proving the smuggled header survived to
// the actual wire instead of being CRLF-stripped en route.
//
// Distinct payload (not just an extra predicate on the axum row)
// because every framework's response serializer strips CRLF at
// the wire-write boundary, so the wire-frame predicate would
// never fire against the canonical axum fixture. See
// `.pitboss/play/deferred.md` (Phase 08 wire-frame option A) for
// the framework-level CRLF-strip empirical from session-0018.
CuratedPayload {
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
label: "header-injection-rust-raw-wire-smuggle",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::HeaderSmuggledInWire {
primary: "Set-Cookie",
smuggled: "Set-Cookie",
}],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 12,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust_raw/vuln.rs"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::HeaderSmuggledInWire {
primary: "Set-Cookie",
smuggled: "Set-Cookie",
}],
benign_control: Some(PayloadRef {
label: "header-injection-rust-raw-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
label: "header-injection-rust-raw-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::HeaderSmuggledInWire {
primary: "Set-Cookie",
smuggled: "Set-Cookie",
}],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 12,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust_raw/vuln.rs"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -629,12 +629,25 @@ pub fn detect_shape(spec: &HarnessSpec) -> RustShape {
/// build would panic on the vuln payload before the differential
/// oracle sees the smuggled header).
///
/// Tier (b): when the fixture does not import axum, fall back to the
/// synthetic `nyx_header_probe("Set-Cookie", &payload)` call so the
/// differential oracle still flips on raw payload bytes.
/// Tier (b) — raw-socket wire frame: when the fixture uses
/// `std::net::TcpListener::bind` (the `rust_raw` fixture exports
/// `create_server` + `run_once` + `set_cookie_value`), boot the
/// listener on a loopback port via the fixture, open a `TcpStream`
/// from the harness, read the bytes the fixture wrote to the response
/// socket up to the `\r\n\r\n` boundary, and emit them as a
/// `ProbeKind::HeaderWireFrame` record. Bypasses every framework-
/// level CRLF validator since the fixture owns the write path.
///
/// Tier (c) synthetic fallback: when the fixture imports neither
/// axum nor TcpListener, fall back to the synthetic
/// `nyx_header_probe("Set-Cookie", &payload)` call so the differential
/// oracle still flips on raw payload bytes.
pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let entry_source = read_entry_source(&spec.entry_file);
if entry_source_uses_raw_socket(&entry_source) {
return emit_header_injection_wire_frame_harness(spec, &entry_source);
}
let shim = probe_shim();
let tier_a_active = entry_source_imports_axum_header(&entry_source);
let entry_fn = &spec.entry_name;
let needs_percent_encoding = entry_source.contains("percent_encoding");
@ -763,6 +776,234 @@ fn entry_source_imports_axum_header(src: &str) -> bool {
src.contains("axum::http::HeaderMap") || src.contains("http::HeaderMap")
}
/// Tier-(b) wire-frame gate for HEADER_INJECTION. Fires when the
/// fixture binds a raw `std::net::TcpListener` and exposes the
/// `set_cookie_value` / `create_server` / `run_once` triple the harness
/// drives. Distinct from the axum gate because the wire-frame branch
/// owns the response-write path itself and bypasses every framework
/// CRLF validator.
fn entry_source_uses_raw_socket(src: &str) -> bool {
src.contains("TcpListener::bind") && src.contains("set_cookie_value")
}
/// Tier-(b) wire-frame harness for HEADER_INJECTION (Phase 08 / Track
/// J.6). Stages the raw-socket fixture at `src/entry.rs`, declares
/// `mod entry;` in `main.rs`, and drives the fixture's `create_server`
/// and `run_once` API in a worker thread while the harness opens a
/// `TcpStream` against the bound port, issues one `GET / HTTP/1.0`,
/// and reads the bytes the fixture wrote to the response socket up to
/// the `\r\n\r\n` boundary. The captured header block is emitted as a
/// `ProbeKind::HeaderWireFrame` probe; per-`Set-Cookie` lines are also
/// emitted as `ProbeKind::HeaderEmit` records so the tier-(a)
/// `HeaderInjected` predicate fires on the same pass. Prints a
/// `wire_frame_len` stdout marker so e2e tests can pin the branch.
fn emit_header_injection_wire_frame_harness(
_spec: &HarnessSpec,
entry_source: &str,
) -> HarnessSource {
let shim = probe_shim();
let needs_percent_encoding = entry_source.contains("percent_encoding");
let mut extra_files: Vec<(String, String)> = Vec::new();
let cargo_toml = generate_cargo_toml_with_extras(Cap::HEADER_INJECTION, needs_percent_encoding);
extra_files.push(("Cargo.toml".into(), cargo_toml));
let main_rs = format!(
r##"//! Nyx dynamic harness — HEADER_INJECTION raw-socket wire frame (Phase 08 / Track J.6).
mod entry;
use std::env;
use std::fs::OpenOptions;
use std::io::{{Read, Write}};
use std::net::TcpStream;
use std::thread;
use std::time::{{Duration, SystemTime, UNIX_EPOCH}};
{shim}
fn nyx_json_escape(s: &str) -> String {{
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {{
match c {{
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {{
out.push_str(&format!("\\u{{:04x}}", c as u32));
}}
c => out.push(c),
}}
}}
out
}}
fn nyx_byte_list(bytes: &[u8]) -> String {{
let mut out = String::with_capacity(bytes.len() * 4 + 2);
out.push('[');
for (i, b) in bytes.iter().enumerate() {{
if i > 0 {{ out.push(','); }}
out.push_str(&b.to_string());
}}
out.push(']');
out
}}
fn nyx_emit_record(line: &str) {{
let p = match env::var("NYX_PROBE_PATH") {{ Ok(s) => s, Err(_) => return }};
if p.is_empty() {{ return; }}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&p) {{
let _ = f.write_all(line.as_bytes());
}}
}}
fn nyx_now_ns() -> u64 {{
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0)
}}
fn nyx_header_probe(name: &str, value: &str) {{
let now = nyx_now_ns();
let pid = env::var("NYX_PAYLOAD_ID").unwrap_or_default();
let mut line = String::new();
line.push_str("{{\"sink_callee\":\"TcpStream::write_all\",\"args\":[");
line.push_str("{{\"kind\":\"String\",\"value\":\"");
line.push_str(&nyx_json_escape(name));
line.push_str("\"}},{{\"kind\":\"String\",\"value\":\"");
line.push_str(&nyx_json_escape(value));
line.push_str("\"}}],");
line.push_str("\"captured_at_ns\":");
line.push_str(&now.to_string());
line.push_str(",\"payload_id\":\"");
line.push_str(&nyx_json_escape(&pid));
line.push_str("\",\"kind\":{{\"kind\":\"HeaderEmit\",\"name\":\"");
line.push_str(&nyx_json_escape(name));
line.push_str("\",\"value\":\"");
line.push_str(&nyx_json_escape(value));
line.push_str("\",\"protocol\":\"wire\"}},\"witness\":{{}}}}\n");
nyx_emit_record(&line);
}}
fn nyx_wire_frame_probe(raw_bytes: &[u8]) {{
let now = nyx_now_ns();
let pid = env::var("NYX_PAYLOAD_ID").unwrap_or_default();
let mut line = String::new();
line.push_str("{{\"sink_callee\":\"TcpStream::write_all\",\"args\":[],");
line.push_str("\"captured_at_ns\":");
line.push_str(&now.to_string());
line.push_str(",\"payload_id\":\"");
line.push_str(&nyx_json_escape(&pid));
line.push_str("\",\"kind\":{{\"kind\":\"HeaderWireFrame\",\"raw_bytes\":");
line.push_str(&nyx_byte_list(raw_bytes));
line.push_str("}},\"witness\":{{}}}}\n");
nyx_emit_record(&line);
}}
fn nyx_wire_frame_via_fixture(payload: &str) -> Option<Vec<u8>> {{
// Phase 08 tier-(b): install the cookie value on the fixture, boot
// its `TcpListener` on 127.0.0.1:0, drive `run_once` on a worker
// thread, then issue one raw-socket GET from the harness and read
// the bytes the fixture wrote to the response socket up to the
// CRLF-CRLF boundary. Returns None on connect / read failure so
// the caller can fall back to the synthetic probe.
entry::set_cookie_value(payload.as_bytes());
let listener = entry::create_server();
let addr = match listener.local_addr() {{
Ok(a) => a,
Err(_) => return None,
}};
let handle = thread::spawn(move || entry::run_once(listener));
let mut client = match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) {{
Ok(c) => c,
Err(_) => {{
let _ = handle.join();
return None;
}}
}};
let _ = client.set_read_timeout(Some(Duration::from_secs(2)));
let _ = client.set_write_timeout(Some(Duration::from_secs(2)));
if client
.write_all(b"GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
.is_err()
{{
let _ = handle.join();
return None;
}}
let mut raw: Vec<u8> = Vec::new();
let mut buf = [0u8; 4096];
while raw.len() < 65536 {{
match client.read(&mut buf) {{
Ok(0) => break,
Ok(n) => {{
raw.extend_from_slice(&buf[..n]);
if raw.windows(4).any(|w| w == b"\r\n\r\n") {{
break;
}}
}}
Err(_) => break,
}}
}}
let _ = handle.join();
let sep = raw
.windows(4)
.position(|w| w == b"\r\n\r\n")
.unwrap_or(raw.len());
Some(raw[..sep].to_vec())
}}
fn main() {{
let payload = env::var("NYX_PAYLOAD").unwrap_or_default();
if let Some(raw_bytes) = nyx_wire_frame_via_fixture(&payload) {{
nyx_wire_frame_probe(&raw_bytes);
// Derive HeaderEmit records per Set-Cookie line on the wire so
// the tier-(a) HeaderInjected predicate also fires on the same
// harness pass. The wire-frame branch owns the bytes; the
// HeaderEmit records are derived from them.
for line in raw_bytes.split(|&b| b == b'\n') {{
let trimmed: &[u8] = if line.last() == Some(&b'\r') {{
&line[..line.len() - 1]
}} else {{
line
}};
let sep = match trimmed.iter().position(|&b| b == b':') {{
Some(s) => s,
None => continue,
}};
let name = match std::str::from_utf8(&trimmed[..sep]) {{
Ok(s) => s,
Err(_) => continue,
}};
if !name.eq_ignore_ascii_case("Set-Cookie") {{
continue;
}}
let mut start = sep + 1;
if start < trimmed.len() && trimmed[start] == b' ' {{
start += 1;
}}
let value = String::from_utf8_lossy(&trimmed[start..]).into_owned();
nyx_header_probe(name, &value);
}}
println!("__NYX_SINK_HIT__");
println!("{{{{\"wire_frame_len\":{{}}}}}}", raw_bytes.len());
return;
}}
// Synthetic fallback when the fixture failed to boot — keeps the
// differential oracle live on a build/boot failure rather than
// silently shedding the attempt.
nyx_header_probe("Set-Cookie", &payload);
println!("__NYX_SINK_HIT__");
println!("{{{{\"payload_len\":{{}}}}}}", payload.len());
}}
"##
);
HarnessSource {
source: main_rs,
filename: "src/main.rs".into(),
command: vec!["target/release/nyx_harness".into()],
extra_files,
entry_subpath: Some("src/entry.rs".into()),
}
}
/// Tier-(a) gate for OPEN_REDIRECT: the fixture imports
/// `axum::response::Redirect`.
fn entry_source_imports_axum_redirect(src: &str) -> bool {
@ -2354,6 +2595,113 @@ mod tests {
assert_eq!(harness.entry_subpath.as_deref(), Some("src/entry.rs"));
}
#[test]
fn header_injection_routes_through_wire_frame_when_raw_socket_imported() {
let mut spec = make_spec(PayloadSlot::Param(0));
spec.entry_name = "run".into();
spec.expected_cap = Cap::HEADER_INJECTION;
spec.entry_file = "tests/dynamic_fixtures/header_injection/rust_raw/vuln.rs".into();
let harness = emit_header_injection_harness(&spec);
assert!(
harness.source.contains("mod entry;"),
"wire-frame harness must declare mod entry: {body}",
body = harness.source,
);
assert!(
!harness.source.contains("mod nyx_harness_stubs;"),
"wire-frame harness must not pull the axum stubs: {body}",
body = harness.source,
);
assert!(
harness
.source
.contains("fn nyx_wire_frame_via_fixture(payload: &str)"),
"wire-frame harness must declare the fixture-driving helper: {body}",
body = harness.source,
);
assert!(
harness.source.contains("entry::set_cookie_value(payload.as_bytes())"),
"wire-frame harness must install cookie value on the fixture: {body}",
body = harness.source,
);
assert!(
harness.source.contains("let listener = entry::create_server();"),
"wire-frame harness must boot the fixture's TcpListener: {body}",
body = harness.source,
);
assert!(
harness
.source
.contains("thread::spawn(move || entry::run_once(listener))"),
"wire-frame harness must drive the fixture's run_once on a worker thread: {body}",
body = harness.source,
);
assert!(
harness
.source
.contains(".write_all(b\"GET / HTTP/1.0\\r\\nHost: 127.0.0.1\\r\\n\\r\\n\")"),
"wire-frame harness must issue raw GET request: {body}",
body = harness.source,
);
assert!(
harness
.source
.contains(r#"HeaderWireFrame\",\"raw_bytes\":"#),
"wire-frame harness must emit a HeaderWireFrame probe carrying the raw header-block bytes: {body}",
body = harness.source,
);
assert!(
harness.source.contains(r#"\"protocol\":\"wire\""#),
"wire-frame harness must tag derived HeaderEmit records as wire protocol: {body}",
body = harness.source,
);
assert!(
harness.source.contains("wire_frame_len"),
"wire-frame harness must emit the wire_frame_len stdout marker: {body}",
body = harness.source,
);
assert_eq!(harness.entry_subpath.as_deref(), Some("src/entry.rs"));
// Cargo.toml must still be staged so the workdir builds.
assert!(
harness
.extra_files
.iter()
.any(|(p, _)| p == "Cargo.toml"),
"wire-frame harness must stage Cargo.toml: {files:?}",
files = harness
.extra_files
.iter()
.map(|(p, _)| p.clone())
.collect::<Vec<_>>(),
);
}
#[test]
fn header_injection_wire_frame_branch_drops_when_only_axum_imported() {
let mut spec = make_spec(PayloadSlot::Param(0));
spec.entry_name = "run".into();
spec.expected_cap = Cap::HEADER_INJECTION;
spec.entry_file = "tests/dynamic_fixtures/header_injection/rust/vuln.rs".into();
let harness = emit_header_injection_harness(&spec);
assert!(
!harness
.source
.contains("fn nyx_wire_frame_via_fixture(payload: &str)"),
"axum harness must not pull the wire-frame helper: {body}",
body = harness.source,
);
assert!(
!harness.source.contains("HeaderWireFrame"),
"axum harness must not emit the HeaderWireFrame probe shape: {body}",
body = harness.source,
);
assert!(
!harness.source.contains("wire_frame_len"),
"axum harness must not print the wire-frame stdout marker: {body}",
body = harness.source,
);
}
#[test]
fn header_injection_tier_a_pulls_percent_encoding_when_benign_uses_it() {
// Benign fixture imports `percent_encoding`; tier-(a) must pin

View file

@ -0,0 +1,58 @@
// Phase 08 (Track J.6) — Rust raw-socket HEADER_INJECTION vuln fixture.
//
// Writes the response status line and headers directly to the wire via
// `TcpStream::write_all`, bypassing the framework-level CRLF validator
// that axum / Tomcat would otherwise interpose. A payload carrying
// `\r\nSet-Cookie: ...` splits the single Set-Cookie header into two on
// the wire, producing the canonical smuggled-second-header shape that
// `ProbeKind::HeaderWireFrame` is designed to catch.
//
// The harness (`src/dynamic/lang/rust.rs::emit_header_injection_harness`)
// detects the `TcpListener::bind` token in this file and routes through
// the tier-(b) wire-frame branch: bind a loopback `TcpListener` via
// `create_server`, spawn the accept loop on a thread (`run_once`),
// issue one raw `GET / HTTP/1.0\r\n` from the harness, read the bytes
// the fixture wrote to the response socket, and emit them as a
// `ProbeKind::HeaderWireFrame` record.
use std::io::{Read, Write};
use std::net::{Shutdown, TcpListener};
use std::sync::Mutex;
/// Bytes go straight onto the wire with no encoding pass. The harness
/// installs the cookie value before booting the accept loop, mirroring
/// the JS `setCookieValue` and Python `Handler.cookie_value =` setters.
static COOKIE_VALUE: Mutex<Vec<u8>> = Mutex::new(Vec::new());
pub fn set_cookie_value(value: &[u8]) {
let mut guard = COOKIE_VALUE.lock().expect("cookie mutex poisoned");
guard.clear();
guard.extend_from_slice(value);
}
pub fn create_server() -> TcpListener {
TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port")
}
pub fn run_once(listener: TcpListener) {
let Ok((mut socket, _addr)) = listener.accept() else {
return;
};
let mut scratch = [0u8; 4096];
let _ = socket.read(&mut scratch);
let cookie = COOKIE_VALUE
.lock()
.expect("cookie mutex poisoned")
.clone();
let body = b"ok\n";
let mut raw = Vec::new();
raw.extend_from_slice(b"HTTP/1.0 200 OK\r\n");
raw.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
raw.extend_from_slice(b"Set-Cookie: ");
raw.extend_from_slice(&cookie);
raw.extend_from_slice(b"\r\n");
raw.extend_from_slice(b"\r\n");
raw.extend_from_slice(body);
let _ = socket.write_all(&raw);
let _ = socket.shutdown(Shutdown::Both);
}

View file

@ -808,6 +808,95 @@ mod e2e_phase_08 {
);
}
// Phase 08 tier-(b): Rust raw-socket wire-frame fixture.
// `tests/dynamic_fixtures/header_injection/rust_raw/vuln.rs` boots a
// `std::net::TcpListener` via `create_server` whose `run_once`
// handler writes raw bytes via `TcpStream::write_all`, bypassing
// axum's `HeaderValue::from_bytes` CRLF strip. The harness boots
// the listener on a loopback port, opens a client `TcpStream`,
// reads the response-header block off the socket, and emits a
// `ProbeKind::HeaderWireFrame` record. Asserts the test exercises
// the wire-frame branch (not the synthetic fallback) by pinning
// `wire_frame_len` in the captured stdout — that literal only
// appears in the tier-(b) write path.
fn build_rust_raw_spec(entry_name: &str) -> (HarnessSpec, TempDir) {
let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/dynamic_fixtures/header_injection/rust_raw/vuln.rs");
let tmp = TempDir::new().expect("create tempdir");
let dst = tmp.path().join("vuln.rs");
std::fs::copy(&fixture_src, &dst).expect("copy rust_raw fixture into tempdir");
let entry_file = dst.to_string_lossy().into_owned();
let mut digest = blake3::Hasher::new();
digest.update(b"phase08-e2e-header-injection|rust_raw|vuln.rs");
let spec_hash = format!("{:016x}", {
let bytes = digest.finalize();
u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap())
});
// Mirror the Java workdir wipe — Cargo's release build dir lives
// under the shared workdir at `/tmp/nyx-harness/<spec_hash>`, so
// a previous run with a different harness source can serve stale
// cached compilation results.
let workdir = std::path::PathBuf::from("/tmp/nyx-harness").join(&spec_hash);
let _ = std::fs::remove_dir_all(&workdir);
let spec = HarnessSpec {
finding_id: spec_hash.clone(),
entry_file: entry_file.clone(),
entry_name: entry_name.to_owned(),
entry_kind: EntryKind::Function,
lang: Lang::Rust,
toolchain_id: default_toolchain_id(Lang::Rust).into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::HEADER_INJECTION,
constraint_hints: vec![],
sink_file: entry_file,
sink_line: 1,
spec_hash: spec_hash.clone(),
derivation: SpecDerivationStrategy::FromFlowSteps,
stubs_required: vec![],
framework: None,
java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(),
};
(spec, tmp)
}
#[test]
fn rust_raw_socket_vuln_confirms_via_wire_frame_probe() {
if !command_available("cargo") {
eprintln!("SKIP rust_raw: missing cargo");
return;
}
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (spec, _tmp) = build_rust_raw_spec("run");
let opts = SandboxOptions {
backend: SandboxBackend::Process,
..SandboxOptions::default()
};
let outcome = match run_spec(&spec, &opts) {
Ok(outcome) => outcome,
Err(RunError::BuildFailed { stderr, attempts }) => {
eprintln!(
"SKIP rust_raw: harness build failed after {attempts} attempts: {stderr}",
);
return;
}
Err(e) => panic!("run_spec(rust_raw) errored: {e:?}"),
};
assert_confirmed(Lang::Rust, &outcome);
let any_wire_frame_marker = outcome.attempts.iter().any(|a| {
String::from_utf8_lossy(&a.outcome.stdout).contains("wire_frame_len")
});
assert!(
any_wire_frame_marker,
"rust_raw fixture must exercise the tier-(b) wire-frame harness branch; \
expected `wire_frame_len` substring in at least one attempt's stdout, got attempts={:?}",
outcome
.attempts
.iter()
.map(|a| String::from_utf8_lossy(&a.outcome.stdout).into_owned())
.collect::<Vec<_>>(),
);
}
#[test]
fn python_raw_socket_vuln_confirms_via_wire_frame_probe() {
if !command_available("python3") {