feat(dynamic): add synthetic-fallback handling for partial confirmations and improve validation propagation

This commit is contained in:
elipeter 2026-06-02 20:38:59 -05:00
parent 1ebeb233c4
commit 5615074177
9 changed files with 261 additions and 8 deletions

View file

@ -2323,9 +2323,9 @@ function nyxWireFrameProbe(rawBytes) {{
};
let invoke_via_fixture = if uses_node_writer {
"const captured = nyxHeaderViaFixture(payload);\nif (Array.isArray(captured) && captured.length > 0) {\n for (const [hname, hvalue] of captured) {\n nyxHeaderProbe(hname, hvalue);\n }\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ headers: captured.map(([n, v]) => [n, v]) }));\n} else {\n // Synthetic fallback — fixture import / call failed.\n const name = 'Set-Cookie';\n const value = payload;\n nyxHeaderProbe(name, value);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ name: name, value: value }));\n}\n"
"const captured = nyxHeaderViaFixture(payload);\nif (Array.isArray(captured) && captured.length > 0) {\n for (const [hname, hvalue] of captured) {\n nyxHeaderProbe(hname, hvalue);\n }\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ headers: captured.map(([n, v]) => [n, v]) }));\n} else {\n // Synthetic fallback — fixture import / call failed. The real header\n // surface (and its guards) never ran, so the verdict must not Confirm;\n // the synthetic marker routes the runner to PartiallyConfirmed.\n const name = 'Set-Cookie';\n const value = payload;\n nyxHeaderProbe(name, value);\n console.log('__NYX_SINK_HIT__');\n console.log('__NYX_SYNTHETIC_FALLBACK__');\n console.log(JSON.stringify({ name: name, value: value }));\n}\n"
} else {
"const name = 'Set-Cookie';\nconst value = payload;\nnyxHeaderProbe(name, value);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log(JSON.stringify({ name: name, value: value }));\n"
"const name = 'Set-Cookie';\nconst value = payload;\nnyxHeaderProbe(name, value);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log('__NYX_SYNTHETIC_FALLBACK__');\nconsole.log(JSON.stringify({ name: name, value: value }));\n"
};
// Phase 08 tier-(b): when the fixture imports `net.createServer`, run
@ -2384,11 +2384,14 @@ function nyxHeaderProbe(name, value) {{
console.log(JSON.stringify({{ wire_frame_len: rawBytes.length }}));
return;
}}
// Synthetic fallback — wire-frame branch did not produce bytes.
// Synthetic fallback — wire-frame branch did not produce bytes. The real
// socket-write path never ran, so this records the raw payload at a
// synthetic sink; the marker routes the runner to PartiallyConfirmed.
const name = 'Set-Cookie';
const value = payload;
nyxHeaderProbe(name, value);
console.log('__NYX_SINK_HIT__');
console.log('__NYX_SYNTHETIC_FALLBACK__');
console.log(JSON.stringify({{ name: name, value: value }}));
}})();
"#

View file

@ -1523,6 +1523,12 @@ function _nyx_header_probe(string $name, string $value): void {{
$value = $payload;
_nyx_header_probe($name, $value);
echo "__NYX_SINK_HIT__\n";
// The real entry could not be driven (no named entry fn captured a
// header); this records the raw payload at a synthetic sink WITHOUT
// running the fixture's own guards, so the verdict must not terminally
// Confirm. The runner downgrades synthetic-marked sink hits to
// PartiallyConfirmed.
echo "__NYX_SYNTHETIC_FALLBACK__\n";
echo json_encode(['name' => $name, 'value' => $value]) . "\n";
}}
@ -1949,6 +1955,14 @@ function _nyx_follow_location(string $location): void {{
_nyx_redirect_probe($location, $requestHost);
_nyx_follow_location($location);
echo "__NYX_SINK_HIT__\n";
// Synthetic sink: the real redirect surface (with its host allowlist /
// path guard) never ran, so this raw-payload assignment proves nothing
// about the guarded code. Emit the synthetic marker so the runner
// downgrades the verdict to PartiallyConfirmed instead of terminally
// Confirming guard-bypassed code (the DVWA open_redirect over-confirm
// class). The OOB callback may still be recorded (infra signal), but the
// runner ignores it for synthetic-marked runs.
echo "__NYX_SYNTHETIC_FALLBACK__\n";
echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n";
}}

View file

@ -501,6 +501,16 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
sink_hit: bool,
oob_nonce_slot: bool,
oob_callback_seen: bool,
/// The harness reached only its SYNTHETIC fallback sink — the real
/// guarded entry could not be driven (e.g. a top-level `$_GET` PHP
/// script with no named entry fn, or a JS fixture whose response
/// import failed), so the fixture's own guards never executed. Such a
/// run must not terminally Confirm (that would claim exploitation of
/// code whose guard was bypassed — the DVWA impossible.php /
/// juiceshop prototype_pollution over-confirm class); it is routed to
/// partial confirmation instead. Set when the harness emitted the
/// `__NYX_SYNTHETIC_FALLBACK__` marker (PHP / JS synthetic branches).
synthetic_fallback: bool,
vuln_probes: Vec<SinkProbe>,
}
let mut vuln_runs: Vec<VulnRun> = Vec::with_capacity(vuln_payloads.len());
@ -603,11 +613,20 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
Some(&run_canary),
);
let sink_hit = outcome.sink_hit;
const SYNTHETIC_FALLBACK_SENTINEL: &[u8] = b"__NYX_SYNTHETIC_FALLBACK__";
let synthetic_fallback = outcome
.stdout
.windows(SYNTHETIC_FALLBACK_SENTINEL.len())
.any(|w| w == SYNTHETIC_FALLBACK_SENTINEL)
|| outcome
.stderr
.windows(SYNTHETIC_FALLBACK_SENTINEL.len())
.any(|w| w == SYNTHETIC_FALLBACK_SENTINEL);
trace_record(
trace_handle.as_ref(),
TraceStage::OracleObserved,
Some(format!(
"attempt={attempt_index} fired={vuln_fired} sink_hit={sink_hit}"
"attempt={attempt_index} fired={vuln_fired} sink_hit={sink_hit} synthetic={synthetic_fallback}"
)),
);
@ -654,6 +673,7 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
sink_hit,
oob_nonce_slot: payload.oob_nonce_slot,
oob_callback_seen,
synthetic_fallback,
vuln_probes,
});
}
@ -678,6 +698,20 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
let mut partial_signal = false;
for vr in &vuln_runs {
// Synthetic-fallback runs reached only the harness's synthetic sink —
// the fixture's real guarded entry never executed — so the attacker
// payload "reaching the sink" proves nothing about the guarded code.
// Reaching the synthetic sink is at most a partial confirmation
// (sink-reachable, exploit unproven). Routing it here (instead of the
// confirm / OOB-self-confirm paths below) yields PartiallyConfirmed
// rather than a false Confirmed, closing the guard-bypass over-confirm
// class (DVWA header_injection/open_redirect on top-level $_GET
// scripts; juiceshop prototype_pollution) without claiming the finding
// is benign.
if vr.synthetic_fallback && vr.sink_hit {
partial_signal = true;
continue;
}
let is_confirm_candidate = vr.vuln_fired && vr.sink_hit;
let is_partial_candidate = vr.sink_hit && !vr.vuln_fired;
if !is_confirm_candidate && !is_partial_candidate {