[pitboss/grind] deferred session-0026 (20260521T201327Z-3848)

This commit is contained in:
pitboss 2026-05-21 22:41:57 -05:00
parent cf65e73f3a
commit 0e1365455f
3 changed files with 231 additions and 6 deletions

View file

@ -15,6 +15,15 @@
//! code path redirects to the relative path `/dashboard` (no host
//! component). The captured `location` has no off-origin authority
//! and the predicate stays clear.
//!
//! OOB-nonce variant (added 2026-05-22): when the runner attaches an
//! [`crate::dynamic::oob::OobListener`] the harness follows the
//! captured `Location:` URL via a real `HttpURLConnection.getInputStream()`
//! against the loopback nonce URL so the listener records the per-finding
//! callback. Ordered first so the runner exercises the OOB observation
//! path before the absolute-URL vuln below triggers and short-circuits
//! iteration; runs without a listener skip cleanly (runner
//! `oob_nonce_slot` branch).
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
@ -22,6 +31,26 @@ use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
// `bytes` is unused when `oob_nonce_slot = true`; the runner
// substitutes a per-finding loopback URL (see runner.rs:405-413).
bytes: b"",
label: "open-redirect-java-oob-nonce",
oracle: Oracle::OobCallback { host: "127.0.0.1" },
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 15,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Vuln.java"],
oob_nonce_slot: true,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: Some(
"OOB-nonce open-redirect payload self-confirms via the per-finding listener \
callback when the harness follows the captured Location URL with \
HttpURLConnection.getInputStream; no benign URL can hit the nonce path.",
),
},
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-java-absolute",

View file

@ -1706,18 +1706,22 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource {
String captured = response.getRedirectedUrl();
if (fixtureInvoked && captured != null) {{
nyxRedirectProbe(captured, requestHost);
nyxFollowLocation(captured);
}} else {{
nyxRedirectProbe(payload, requestHost);
nyxFollowLocation(payload);
}}"#
)
} else {
r#" nyxRedirectProbe(payload, requestHost);"#.to_owned()
r#" nyxRedirectProbe(payload, requestHost);
nyxFollowLocation(payload);"#
.to_owned()
};
let imports = if has_servlet_stubs {
"import java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\n"
"import java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\nimport java.net.HttpURLConnection;\nimport java.net.URL;\n"
} else {
""
"import java.net.HttpURLConnection;\nimport java.net.URL;\n"
};
let source = format!(
@ -1757,6 +1761,31 @@ public class NyxHarness {{
}}
}}
// Phase 09 OOB closure: when the captured Location is a fully-qualified
// loopback URL, follow it with a real GET so the OOB listener records
// the per-finding nonce. Skips non-loopback hosts (no real network egress)
// and any non-HTTP scheme. Best-effort: failures do not propagate, the
// listener may still have observed the connect before the read errored.
static void nyxFollowLocation(String location) {{
if (location == null || location.isEmpty()) return;
String lower = location.toLowerCase();
if (!(lower.startsWith("http://127.0.0.1")
|| lower.startsWith("http://localhost")
|| lower.startsWith("http://host-gateway"))) {{
return;
}}
try {{
HttpURLConnection conn = (HttpURLConnection) new URL(location).openConnection();
conn.setConnectTimeout(2000);
conn.setReadTimeout(2000);
conn.setInstanceFollowRedirects(false);
conn.getInputStream().close();
conn.disconnect();
}} catch (Exception ignored) {{
// best-effort OOB fetch
}}
}}
public static void main(String[] args) {{
String payload = System.getenv("NYX_PAYLOAD");
if (payload == null) payload = "";
@ -3487,6 +3516,65 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn emit_open_redirect_harness_ships_follow_location_helper() {
let dir = std::env::temp_dir().join("nyx_phase09_test_follow_helper");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let entry = write_servlet_fixture(
&dir,
"public class Vuln { public static void run(String v) { System.out.println(v); } }\n",
);
let mut spec = make_spec(PayloadSlot::Param(0));
spec.expected_cap = Cap::OPEN_REDIRECT;
spec.entry_file = entry;
spec.entry_name = "run".into();
let h = emit_open_redirect_harness(&spec);
assert!(
h.source.contains("static void nyxFollowLocation(String location)"),
"OPEN_REDIRECT harness must declare the nyxFollowLocation helper",
);
assert!(
h.source.contains("import java.net.HttpURLConnection;"),
"OPEN_REDIRECT harness must import HttpURLConnection",
);
assert!(
h.source.contains("import java.net.URL;"),
"OPEN_REDIRECT harness must import URL",
);
assert!(
h.source.contains("http://127.0.0.1"),
"follow-location helper must whitelist loopback hosts",
);
assert!(
h.source.contains("nyxFollowLocation(payload)"),
"tier-(b) fallback must follow the synthetic payload location",
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn emit_open_redirect_harness_follows_captured_location_in_tier_a() {
let dir = std::env::temp_dir().join("nyx_phase09_test_follow_tier_a");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let entry = write_servlet_fixture(
&dir,
"import javax.servlet.http.HttpServletResponse;\n\
public class Vuln {\n public static void run(HttpServletResponse r, String v) throws Exception {\n r.sendRedirect(v);\n }\n}\n",
);
let mut spec = make_spec(PayloadSlot::Param(0));
spec.expected_cap = Cap::OPEN_REDIRECT;
spec.entry_file = entry;
spec.entry_name = "run".into();
let h = emit_open_redirect_harness(&spec);
assert!(
h.source.contains("nyxRedirectProbe(captured, requestHost);\n nyxFollowLocation(captured);"),
"tier-(a) must follow the captured Location: value, not the raw payload",
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn emit_xpath_harness_drives_fixture_through_real_xpath_when_imported() {
let dir = std::env::temp_dir().join("nyx_phase07_test_drive_fixture");

View file

@ -90,7 +90,12 @@ fn open_redirect_unsupported_caps_unchanged_for_other_langs() {
fn benign_control_resolves_within_lang_slice() {
for lang in LANGS {
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
// Skip OOB-nonce variants — they self-confirm via the per-finding
// listener and carry no paired benign control by design.
let vuln = slice
.iter()
.find(|p| !p.is_benign && !p.oob_nonce_slot)
.unwrap();
let resolved =
resolve_benign_control_lang(vuln, Cap::OPEN_REDIRECT, *lang).expect("paired control");
assert!(resolved.is_benign);
@ -103,7 +108,12 @@ fn benign_control_resolves_within_lang_slice() {
fn payload_oracle_carries_redirect_host_not_in_predicate() {
for lang in LANGS {
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
// The off-origin-URL vuln carries the RedirectHostNotIn predicate;
// OOB-nonce variants observe via the listener and use OobCallback.
let vuln = slice
.iter()
.find(|p| !p.is_benign && !p.oob_nonce_slot)
.unwrap();
match &vuln.oracle {
Oracle::SinkProbe { predicates } => {
assert!(
@ -122,7 +132,12 @@ fn payload_oracle_carries_redirect_host_not_in_predicate() {
fn vuln_payload_bytes_carry_off_origin_url_benign_bytes_do_not() {
for lang in LANGS {
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
// OOB-nonce variants ship empty `bytes` (the runner substitutes the
// loopback nonce URL at run-time); inspect only the curated vuln here.
let vuln = slice
.iter()
.find(|p| !p.is_benign && !p.oob_nonce_slot)
.unwrap();
let benign = slice.iter().find(|p| p.is_benign).unwrap();
let vuln_text = std::str::from_utf8(vuln.bytes).unwrap();
let benign_text = std::str::from_utf8(benign.bytes).unwrap();
@ -603,4 +618,97 @@ mod e2e_phase_09 {
};
assert_confirmed(Lang::Rust, &outcome);
}
/// Phase 09 OOB-loopback observation: when an [`nyx_scanner::dynamic::oob::OobListener`]
/// is attached and the runner exercises the `open-redirect-java-oob-nonce`
/// payload, the harness follows the captured `Location:` URL with a real
/// `HttpURLConnection.getInputStream()` against the loopback nonce URL and
/// the listener records the hit. Asserts both halves of the OOB closure:
/// the callback observation AND the verdict-tier promotion from
/// `Confirmed` to `ConfirmedProvenOob` (the runner's
/// `build_oob_self_confirmed_outcome` path treats the OOB-nonce payload as
/// self-confirming since a benign URL structurally cannot hit a
/// per-finding nonce).
fn run_oob(lang: Lang, fixture: &str, entry_name: &str) -> Option<RunOutcome> {
use nyx_scanner::dynamic::oob::OobListener;
use nyx_scanner::dynamic::sandbox::NetworkPolicy;
use std::sync::Arc;
let bin = toolchain_for(lang);
if !command_available(bin) {
eprintln!("SKIP {lang:?} {fixture} (oob): missing toolchain {bin}");
return None;
}
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let listener = Arc::new(OobListener::bind().expect("bind OOB listener on loopback"));
let (mut spec, _tmp) = build_spec(lang, fixture, entry_name);
// Use a distinct workdir from the non-OOB e2e tests so the probe
// channel files do not collide (both tests use the same fixture, so
// the default spec_hash would resolve to the same
// `/tmp/nyx-harness/<spec_hash>/__nyx_probes.jsonl` and the two runs
// could clobber each other's drains under parallel nextest).
spec.spec_hash = format!("{}-oob", spec.spec_hash);
spec.finding_id = spec.spec_hash.clone();
if matches!(lang, Lang::Java) {
let workdir = std::path::PathBuf::from("/tmp/nyx-harness").join(&spec.spec_hash);
let _ = std::fs::remove_dir_all(&workdir);
}
let opts = SandboxOptions {
backend: SandboxBackend::Process,
network_policy: NetworkPolicy::OobOutbound {
listener: Arc::clone(&listener),
},
..SandboxOptions::default()
};
match run_spec(&spec, &opts) {
Ok(outcome) => Some(outcome),
Err(RunError::BuildFailed { stderr, attempts }) => {
eprintln!(
"SKIP {lang:?} {fixture} (oob): harness build failed after {attempts} attempts: {stderr}",
);
None
}
Err(e) => panic!("run_spec({lang:?} {fixture} oob) errored: {e:?}"),
}
}
fn assert_oob_recorded(outcome: &RunOutcome, label: &str) {
let oob_attempt = outcome
.attempts
.iter()
.find(|a| a.payload_label == label)
.unwrap_or_else(|| {
panic!(
"OOB payload {label:?} must run when listener is attached; outcome={outcome:?}"
)
});
assert!(
oob_attempt.outcome.oob_callback_seen,
"harness must follow captured Location URL so OOB listener records the nonce; got attempt={oob_attempt:?}",
);
assert!(
oob_attempt.triggered,
"OOB attempt must mark triggered=true under the self-confirming OOB path; got attempt={oob_attempt:?}",
);
let diff = outcome
.differential
.as_ref()
.expect("self-confirming OOB run must carry a DifferentialOutcome");
assert_eq!(
diff.verdict,
DifferentialVerdict::ConfirmedProvenOob,
"OOB callback observation must promote verdict tier; got diff={diff:?}",
);
}
#[test]
fn java_open_redirect_oob_loopback_records_callback() {
let Some(outcome) = run_oob(Lang::Java, "Vuln.java", "run") else {
return;
};
assert_oob_recorded(&outcome, "open-redirect-java-oob-nonce");
}
}