mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
feat(tests): support partial confirmations with synthetic-fallback handling in header injection and open redirect scenarios
This commit is contained in:
parent
4c824ed543
commit
c29cf69d42
5 changed files with 80 additions and 41 deletions
|
|
@ -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__');
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue