diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 36862388..048584f6 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -2548,9 +2548,9 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { }; let invoke_via_fixture = if uses_node_writer { - "const captured = nyxRedirectViaFixture(payload);\nif (Array.isArray(captured)) {\n const [location, requestHost] = captured;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n} else {\n // Synthetic fallback — fixture import / call failed.\n const requestHost = 'example.com';\n const location = payload;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n}\n" + "const captured = nyxRedirectViaFixture(payload);\nif (Array.isArray(captured)) {\n const [location, requestHost] = captured;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n} else {\n // Synthetic fallback — fixture import / call failed. The real redirect\n // surface (host allowlist / path guard) never ran, so the synthetic marker\n // routes the runner to PartiallyConfirmed instead of an OOB self-confirm.\n const requestHost = 'example.com';\n const location = payload;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log('__NYX_SYNTHETIC_FALLBACK__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n}\n" } else { - "const requestHost = 'example.com';\nconst location = payload;\nnyxRedirectProbe(location, requestHost);\nnyxFollowLocation(location);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log(JSON.stringify({ location: location, request_host: requestHost }));\n" + "const requestHost = 'example.com';\nconst location = payload;\nnyxRedirectProbe(location, requestHost);\nnyxFollowLocation(location);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log('__NYX_SYNTHETIC_FALLBACK__');\nconsole.log(JSON.stringify({ location: location, request_host: requestHost }));\n" }; let body = format!( @@ -2784,6 +2784,11 @@ const payload = process.env.NYX_PAYLOAD || ''; // lodash.merge can throw on weird inputs; the canary observation // already wrote any probe before the throw. } + // This block drove the sink DIRECTLY, bypassing any caller-side mitigation + // (the fixture's own entry could not be driven). A canary observation here + // therefore does not prove the guarded code is exploitable, so the runner + // must downgrade to PartiallyConfirmed rather than Confirm. + console.log('__NYX_SYNTHETIC_FALLBACK__'); "#; let tail = r#"console.log('__NYX_SINK_HIT__'); diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 9c7e21cb..91ecdc60 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -3456,6 +3456,9 @@ def _nyx_header_probe(name, value): value = payload _nyx_header_probe(name, value) print("__NYX_SINK_HIT__", flush=True) + # Synthetic sink: the real header surface (and its guards) never ran, so + # the runner downgrades this to PartiallyConfirmed rather than Confirm. + print("__NYX_SYNTHETIC_FALLBACK__", flush=True) sys.stdout.write(json.dumps({{"name": name, "value": value}}) + "\n") sys.stdout.flush() @@ -3607,6 +3610,10 @@ def _nyx_follow_location(location): _nyx_redirect_probe(location, request_host) _nyx_follow_location(location) print("__NYX_SINK_HIT__", flush=True) + # Synthetic sink: the real redirect surface (host allowlist / path guard) + # never ran, so the runner downgrades to PartiallyConfirmed rather than an + # OOB self-confirm. + print("__NYX_SYNTHETIC_FALLBACK__", flush=True) sys.stdout.write(json.dumps({{"location": location, "request_host": request_host}}) + "\n") sys.stdout.flush() diff --git a/src/taint/ssa_transfer/mod.rs b/src/taint/ssa_transfer/mod.rs index 547cb194..8daa5721 100644 --- a/src/taint/ssa_transfer/mod.rs +++ b/src/taint/ssa_transfer/mod.rs @@ -8417,6 +8417,7 @@ fn try_curl_url_propagation( /// sets `const_values: Some(&callee_body.opt.const_values)` on the child /// transfer, so callee-local constants are resolved. /// - Unknown / non-integer / out-of-bounds: falls back to `HeapSlot::Elements`. +/// /// Map a proven constant index/key to its precise `HeapSlot`, or `None` /// (caller falls back to `HeapSlot::Elements`). /// @@ -8490,6 +8491,7 @@ fn resolve_container_index(index_val: SsaValue, transfer: &SsaTaintTransfer) -> /// 2. the parallel `arg_string_literals` slot (a *literal* index/key, e.g. /// `map.get("keyB")`, which carries no SSA value because it is not a /// variable — the dominant OWASP shape). +/// /// Otherwise returns `HeapSlot::Elements`. fn resolve_op_slot( index_arg: Option, diff --git a/tests/header_injection_corpus.rs b/tests/header_injection_corpus.rs index 726ec2a1..e9811f33 100644 --- a/tests/header_injection_corpus.rs +++ b/tests/header_injection_corpus.rs @@ -627,6 +627,19 @@ mod e2e_phase_08 { assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); } + /// Accepts Confirmed OR PartiallyConfirmed. A fixture whose real entry + /// imports a framework dependency absent from the harness build env (e.g. + /// Flask/Werkzeug) cannot be driven through its real guarded path, so the + /// harness reaches only its synthetic sink — PartiallyConfirmed after the + /// synthetic-fallback over-confirm fix. With the dependency present (CI + /// image) the real drive still Confirms. Both are valid positive detections. + fn assert_confirmed_or_partial(lang: Lang, outcome: &RunOutcome) { + assert!( + outcome.triggered_by.is_some() || outcome.sink_reached_no_oracle, + "{lang:?} HEADER_INJECTION vuln must Confirm or PartiallyConfirm; got {outcome:?}", + ); + } + #[test] fn java_vuln_confirms_via_run_spec() { let Some(outcome) = run(Lang::Java, "Vuln.java", "run") else { @@ -640,7 +653,9 @@ mod e2e_phase_08 { let Some(outcome) = run(Lang::Python, "vuln.py", "run") else { return; }; - assert_confirmed(Lang::Python, &outcome); + // Flask/Werkzeug absent in the harness build env → synthetic path → + // PartiallyConfirmed (Confirmed when the dep is present in CI). + assert_confirmed_or_partial(Lang::Python, &outcome); } #[test] diff --git a/tests/open_redirect_corpus.rs b/tests/open_redirect_corpus.rs index 45bd4991..bc79cb73 100644 --- a/tests/open_redirect_corpus.rs +++ b/tests/open_redirect_corpus.rs @@ -563,6 +563,48 @@ mod e2e_phase_09 { assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); } + /// Accepts Confirmed OR PartiallyConfirmed. A fixture whose real entry + /// imports a framework dependency absent from the harness build env + /// (Symfony / Flask / express, …) cannot be driven through its real guarded + /// path, so the harness reaches only its synthetic sink. After the + /// synthetic-fallback over-confirm fix that yields PartiallyConfirmed + /// (sink-reachable, exploit unproven) rather than a Confirmed claiming + /// exploitation of guarded code that never ran. With the dependency present + /// (CI image) the real drive still Confirms. Both are valid positive + /// detections; only a clean NotConfirmed/Unsupported is a miss. + fn assert_confirmed_or_partial(lang: Lang, outcome: &RunOutcome) { + assert!( + outcome.triggered_by.is_some() || outcome.sink_reached_no_oracle, + "{lang:?} OPEN_REDIRECT vuln must Confirm or PartiallyConfirm; got {outcome:?}", + ); + } + + /// OOB-loopback variant tolerant of the synthetic fallback: the nonce + /// callback is still followed and recorded (infra signal), but when the + /// real entry could not be driven (dependency absent → synthetic path) the + /// verdict is PartiallyConfirmed rather than the self-confirming + /// ConfirmedProvenOob — the synthetic sink cannot prove the guarded code is + /// exploitable. With the dependency present the real drive promotes to + /// ConfirmedProvenOob. + fn assert_oob_recorded_or_partial(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; outcome={outcome:?}")); + assert!( + oob_attempt.outcome.oob_callback_seen, + "harness must follow captured Location URL so OOB listener records the nonce; got {oob_attempt:?}", + ); + match outcome.differential.as_ref() { + Some(diff) => assert_eq!(diff.verdict, DifferentialVerdict::ConfirmedProvenOob), + None => assert!( + outcome.sink_reached_no_oracle, + "synthetic-fallback OOB run must PartiallyConfirm (not self-confirm); got {outcome:?}", + ), + } + } + #[test] fn java_vuln_confirms_via_run_spec() { let Some(outcome) = run(Lang::Java, "Vuln.java", "run") else { @@ -576,7 +618,7 @@ mod e2e_phase_09 { let Some(outcome) = run(Lang::Python, "vuln.py", "run") else { return; }; - assert_confirmed(Lang::Python, &outcome); + assert_confirmed_or_partial(Lang::Python, &outcome); } #[test] @@ -584,17 +626,7 @@ mod e2e_phase_09 { let Some(outcome) = run(Lang::Php, "vuln.php", "run") else { return; }; - // The fixture's real entry imports Symfony `RedirectResponse`, which is - // absent from the harness build env, so the eval/invoke fails and the - // harness reaches only its synthetic sink. After the synthetic-fallback - // over-confirm fix that yields PartiallyConfirmed (sink-reachable, - // exploit unproven) rather than a Confirmed claiming exploitation of - // guarded code that never executed. With Symfony present (CI image) the - // real drive still Confirms. Both are valid positive detections. - assert!( - outcome.triggered_by.is_some() || outcome.sink_reached_no_oracle, - "PHP OPEN_REDIRECT vuln must Confirm or PartiallyConfirm; got {outcome:?}", - ); + assert_confirmed_or_partial(Lang::Php, &outcome); } #[test] @@ -610,7 +642,7 @@ mod e2e_phase_09 { let Some(outcome) = run(Lang::JavaScript, "vuln.js", "run") else { return; }; - assert_confirmed(Lang::JavaScript, &outcome); + assert_confirmed_or_partial(Lang::JavaScript, &outcome); } #[test] @@ -734,7 +766,7 @@ mod e2e_phase_09 { let Some(outcome) = run_oob(Lang::Python, "vuln.py", "run") else { return; }; - assert_oob_recorded(&outcome, "open-redirect-python-oob-nonce"); + assert_oob_recorded_or_partial(&outcome, "open-redirect-python-oob-nonce"); } #[test] @@ -742,7 +774,7 @@ mod e2e_phase_09 { let Some(outcome) = run_oob(Lang::JavaScript, "vuln.js", "run") else { return; }; - assert_oob_recorded(&outcome, "open-redirect-js-oob-nonce"); + assert_oob_recorded_or_partial(&outcome, "open-redirect-js-oob-nonce"); } #[test] @@ -758,29 +790,7 @@ mod e2e_phase_09 { let Some(outcome) = run_oob(Lang::Php, "vuln.php", "run") else { return; }; - // The OOB nonce URL is still followed and recorded (infra signal), but - // because the fixture's real entry (Symfony `RedirectResponse`) can't be - // driven in this env, the harness reaches only its synthetic sink. After - // the over-confirm fix a synthetic sink hit no longer self-confirms via - // the OOB nonce (that would confirm code whose guard never ran) — it - // PartiallyConfirms instead. With Symfony present the real drive promotes - // to ConfirmedProvenOob. - let oob_attempt = outcome - .attempts - .iter() - .find(|a| a.payload_label == "open-redirect-php-oob-nonce") - .unwrap_or_else(|| panic!("OOB payload must run; outcome={outcome:?}")); - assert!( - oob_attempt.outcome.oob_callback_seen, - "harness must follow captured Location URL so OOB listener records the nonce; got {oob_attempt:?}", - ); - match outcome.differential.as_ref() { - Some(diff) => assert_eq!(diff.verdict, DifferentialVerdict::ConfirmedProvenOob), - None => assert!( - outcome.sink_reached_no_oracle, - "synthetic-fallback OOB run must PartiallyConfirm (not self-confirm); got {outcome:?}", - ), - } + assert_oob_recorded_or_partial(&outcome, "open-redirect-php-oob-nonce"); } #[test]