mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-09 20:52:11 +02:00
feat(dynamic): add synthetic-fallback handling for partial confirmations and improve validation propagation
This commit is contained in:
parent
1ebeb233c4
commit
5615074177
9 changed files with 261 additions and 8 deletions
|
|
@ -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 }}));
|
||||
}})();
|
||||
"#
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue