mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0008 (20260516T052512Z-20f8)
This commit is contained in:
parent
f053665a83
commit
1ef650dc48
5 changed files with 292 additions and 11 deletions
|
|
@ -22,7 +22,7 @@
|
|||
//! tracks the history of incompatible corpus changes; bumping it invalidates
|
||||
//! all `dynamic_verdict_cache` entries whose spec touched the changed cap.
|
||||
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
use crate::dynamic::oracle::{ProbePredicate, SignalSet};
|
||||
use crate::labels::Cap;
|
||||
|
||||
/// Re-exported canonical [`Oracle`] type.
|
||||
|
|
@ -45,7 +45,8 @@ pub use crate::dynamic::oracle::Oracle;
|
|||
/// | 2 | 2025-12-15 | SSRF OOB-variant added; oracle semantics tightened |
|
||||
/// | 3 | 2026-05-12 | Migrated to `CuratedPayload`; provenance + fixture_paths enforced; SSRF OOB-nonce slot added |
|
||||
/// | 4 | 2026-05-14 | Phase 07: `benign_control` paired refs + benign payloads added to SQLI / CMDI / SSRF (file-scheme) |
|
||||
pub const CORPUS_VERSION: u32 = 4;
|
||||
/// | 5 | 2026-05-16 | FMT_STRING SinkCrash payload + benign control (Phase 08 unrelated-crash acceptance fixture) |
|
||||
pub const CORPUS_VERSION: u32 = 5;
|
||||
|
||||
/// Where a payload originated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -137,11 +138,11 @@ pub type Payload = CuratedPayload;
|
|||
/// | FILE_IO | yes | path traversal + benign control |
|
||||
/// | SSRF | yes | file:// scheme + OOB nonce slot |
|
||||
/// | HTML_ESCAPE | yes | XSS script marker + benign control |
|
||||
/// | FMT_STRING | yes | SinkCrash + benign control (Phase 08) |
|
||||
/// | ENV_VAR | no | source-only cap; no sink oracle |
|
||||
/// | SHELL_ESCAPE | no | sanitizer cap; no sink oracle |
|
||||
/// | URL_ENCODE | no | sanitizer cap; no sink oracle |
|
||||
/// | JSON_PARSE | no | no reliable oracle |
|
||||
/// | FMT_STRING | no | no reliable oracle |
|
||||
/// | DESERIALIZE | no | no reliable oracle |
|
||||
/// | CRYPTO | no | no reliable oracle |
|
||||
/// | UNAUTHORIZED_ID | no | auth bypass; no oracle |
|
||||
|
|
@ -160,13 +161,13 @@ const CORPUS_SUPPORTED: u32 = Cap::SQL_QUERY.bits()
|
|||
| Cap::CODE_EXEC.bits()
|
||||
| Cap::FILE_IO.bits()
|
||||
| Cap::SSRF.bits()
|
||||
| Cap::HTML_ESCAPE.bits();
|
||||
| Cap::HTML_ESCAPE.bits()
|
||||
| Cap::FMT_STRING.bits();
|
||||
|
||||
const CORPUS_UNSUPPORTED: u32 = Cap::ENV_VAR.bits()
|
||||
| Cap::SHELL_ESCAPE.bits()
|
||||
| Cap::URL_ENCODE.bits()
|
||||
| Cap::JSON_PARSE.bits()
|
||||
| Cap::FMT_STRING.bits()
|
||||
| Cap::DESERIALIZE.bits()
|
||||
| Cap::CRYPTO.bits()
|
||||
| Cap::UNAUTHORIZED_ID.bits()
|
||||
|
|
@ -201,6 +202,9 @@ pub fn payloads_for(cap: Cap) -> &'static [CuratedPayload] {
|
|||
if cap.contains(Cap::HTML_ESCAPE) {
|
||||
return XSS;
|
||||
}
|
||||
if cap.contains(Cap::FMT_STRING) {
|
||||
return FMT_STRING;
|
||||
}
|
||||
&[]
|
||||
}
|
||||
|
||||
|
|
@ -298,13 +302,14 @@ mod tests {
|
|||
assert!(!payloads_for(Cap::FILE_IO).is_empty());
|
||||
assert!(!payloads_for(Cap::SSRF).is_empty());
|
||||
assert!(!payloads_for(Cap::HTML_ESCAPE).is_empty());
|
||||
assert!(!payloads_for(Cap::FMT_STRING).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_caps_return_empty() {
|
||||
let unsupported = [
|
||||
Cap::ENV_VAR, Cap::SHELL_ESCAPE, Cap::URL_ENCODE, Cap::JSON_PARSE,
|
||||
Cap::FMT_STRING, Cap::DESERIALIZE, Cap::CRYPTO, Cap::UNAUTHORIZED_ID,
|
||||
Cap::DESERIALIZE, Cap::CRYPTO, Cap::UNAUTHORIZED_ID,
|
||||
Cap::DATA_EXFIL, Cap::LDAP_INJECTION, Cap::XPATH_INJECTION,
|
||||
Cap::HEADER_INJECTION, Cap::OPEN_REDIRECT, Cap::SSTI, Cap::XXE,
|
||||
Cap::PROTOTYPE_POLLUTION,
|
||||
|
|
@ -329,12 +334,36 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn vuln_payloads_not_benign() {
|
||||
for cap in [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::HTML_ESCAPE] {
|
||||
for cap in [
|
||||
Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::HTML_ESCAPE,
|
||||
Cap::FMT_STRING,
|
||||
] {
|
||||
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
|
||||
assert!(has_vuln, "{cap:?} must have at least one vuln (non-benign) payload");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_string_has_sink_crash_oracle_and_benign_control() {
|
||||
let payloads = payloads_for(Cap::FMT_STRING);
|
||||
let vuln = payloads
|
||||
.iter()
|
||||
.find(|p| !p.is_benign)
|
||||
.expect("FMT_STRING must have a vuln payload");
|
||||
assert!(
|
||||
matches!(vuln.oracle, Oracle::SinkCrash { .. }),
|
||||
"FMT_STRING vuln payload oracle must be SinkCrash (Phase 08)"
|
||||
);
|
||||
let bref = vuln
|
||||
.benign_control
|
||||
.expect("FMT_STRING vuln must reference a benign control");
|
||||
assert!(
|
||||
resolve_benign_control(vuln, Cap::FMT_STRING).is_some(),
|
||||
"FMT_STRING benign-control label '{}' must resolve",
|
||||
bref.label,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_uniqueness_sqli() {
|
||||
for p in SQLI {
|
||||
|
|
@ -345,7 +374,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn all_payloads_have_fixture_paths() {
|
||||
let caps = [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF, Cap::HTML_ESCAPE];
|
||||
let caps = [
|
||||
Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF,
|
||||
Cap::HTML_ESCAPE, Cap::FMT_STRING,
|
||||
];
|
||||
for cap in caps {
|
||||
for p in payloads_for(cap) {
|
||||
assert!(
|
||||
|
|
@ -359,7 +391,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn all_payloads_have_valid_since_corpus_version() {
|
||||
let caps = [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF, Cap::HTML_ESCAPE];
|
||||
let caps = [
|
||||
Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF,
|
||||
Cap::HTML_ESCAPE, Cap::FMT_STRING,
|
||||
];
|
||||
for cap in caps {
|
||||
for p in payloads_for(cap) {
|
||||
assert!(
|
||||
|
|
@ -442,7 +477,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn benign_entries_are_terminal() {
|
||||
let caps = [Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF, Cap::HTML_ESCAPE];
|
||||
let caps = [
|
||||
Cap::SQL_QUERY, Cap::CODE_EXEC, Cap::FILE_IO, Cap::SSRF,
|
||||
Cap::HTML_ESCAPE, Cap::FMT_STRING,
|
||||
];
|
||||
for cap in caps {
|
||||
for p in payloads_for(cap).iter().filter(|p| p.is_benign) {
|
||||
assert!(
|
||||
|
|
@ -666,3 +704,50 @@ const XSS: &[CuratedPayload] = &[
|
|||
benign_control: None,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Format string (Phase 08 SinkCrash variant) ────────────────────────────────
|
||||
// The vuln payload confirms via a sink-site Crash probe rather than an
|
||||
// output marker. The bytes themselves are not load-bearing — the
|
||||
// detection contract is "process aborts inside the entry call AFTER the
|
||||
// crash-guard installs". Fixtures choose how to crash on the payload;
|
||||
// the canonical example is a printf-family sink that interprets `%n`
|
||||
// against a controlled destination pointer on a guard-compiled binary.
|
||||
//
|
||||
// The benign control must reach the same entry without crashing; its
|
||||
// bytes carry `NYX_BENIGN` so fixture code can short-circuit before
|
||||
// the fault path.
|
||||
const FMT_STRING: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"%n%n%n%n%n%n%n%n%n%n",
|
||||
label: "fmt-string-percent-n-crash",
|
||||
oracle: Oracle::SinkCrash {
|
||||
signals: SignalSet::all(),
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 5,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &[
|
||||
"tests/dynamic_fixtures/c/free_fn/sink_fault.c",
|
||||
"tests/dynamic_fixtures/c/free_fn/setup_fault.c",
|
||||
],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: Some(PayloadRef { label: "fmt-string-benign" }),
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"benign_safe_fmt_NYX_BENIGN",
|
||||
label: "fmt-string-benign",
|
||||
oracle: Oracle::SinkCrash {
|
||||
signals: SignalSet::all(),
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 5,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/c/free_fn/sink_fault.c"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
|
||||
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
|
||||
/// jointly guard drift.
|
||||
pub const CORPUS_VERSION: &str = "4";
|
||||
pub const CORPUS_VERSION: &str = "5";
|
||||
|
||||
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
||||
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
||||
|
|
|
|||
24
tests/dynamic_fixtures/c/free_fn/setup_fault.c
Normal file
24
tests/dynamic_fixtures/c/free_fn/setup_fault.c
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* Phase 08 (b) acceptance fixture — crash outside the sink.
|
||||
*
|
||||
* Cap: FMT_STRING. A global constructor (`__attribute__((constructor))`)
|
||||
* runs before `main`, so the abort fires BEFORE the harness reaches
|
||||
* `__nyx_install_crash_guard`. No Crash probe is written, the
|
||||
* `Oracle::SinkCrash` predicate sees `process_crashed &&
|
||||
* !has_sink_crash_probe`, and the verifier routes to
|
||||
* `Inconclusive(UnrelatedCrash)` instead of `Confirmed`.
|
||||
*
|
||||
* The `run` body is unreachable but must compile so the entry symbol
|
||||
* resolves at link time. */
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
__attribute__((constructor)) static void nyx_fixture_crash_in_setup(void) {
|
||||
abort();
|
||||
}
|
||||
|
||||
void run(const char *payload, size_t len) {
|
||||
(void)payload;
|
||||
(void)len;
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
}
|
||||
25
tests/dynamic_fixtures/c/free_fn/sink_fault.c
Normal file
25
tests/dynamic_fixtures/c/free_fn/sink_fault.c
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* Phase 08 (a) acceptance fixture — crash at the sink.
|
||||
*
|
||||
* Cap: FMT_STRING. Prints the `__NYX_SINK_HIT__` sentinel so the runner
|
||||
* sees the in-harness sink-hit, then NULL-dereferences when handed the
|
||||
* vuln payload. The harness's `__nyx_install_crash_guard` was installed
|
||||
* earlier in `main`, so SIGSEGV writes a Crash probe to `NYX_PROBE_PATH`,
|
||||
* which lifts the `Oracle::SinkCrash` predicate to `Confirmed`.
|
||||
*
|
||||
* Differential confirmation: the paired benign payload carries the
|
||||
* `NYX_BENIGN` marker. The short-circuit below returns cleanly on the
|
||||
* benign run so `benign_fired = false`, satisfying the §4.1 rule. */
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
void run(const char *payload, size_t len) {
|
||||
(void)len;
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
fflush(stdout);
|
||||
if (payload && strstr(payload, "NYX_BENIGN")) {
|
||||
return;
|
||||
}
|
||||
volatile char *p = NULL;
|
||||
*p = 1;
|
||||
}
|
||||
|
|
@ -11,9 +11,16 @@
|
|||
//! - (a) sink-site crash → `Confirmed`
|
||||
//! - (b) crash outside sink → `Inconclusive(UnrelatedCrash)`
|
||||
//! - (c) bounded witness capture for known payloads
|
||||
//!
|
||||
//! End-to-end fixtures at the bottom of this file drive the full
|
||||
//! [`run_spec`] pipeline against compiled C harnesses, locking in that
|
||||
//! the `__nyx_install_crash_guard` ordering inside the emitted `main.c`
|
||||
//! routes setup-fault and sink-fault crashes to the right verdicts.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
mod common;
|
||||
|
||||
use nyx_scanner::dynamic::oracle::{
|
||||
oracle_fired, probe_crash_signal, Oracle, Signal, SignalSet,
|
||||
};
|
||||
|
|
@ -279,3 +286,143 @@ fn signal_set_const_construction_is_order_independent() {
|
|||
assert!(B.contains(Signal::Sigabrt));
|
||||
assert!(!A.contains(Signal::Sigfpe));
|
||||
}
|
||||
|
||||
// ── End-to-end Phase 08 acceptance via compiled C harnesses ───────────────────
|
||||
//
|
||||
// These tests drive the full `run_spec` pipeline against the FMT_STRING
|
||||
// curated payload + paired benign control, against two purpose-built
|
||||
// fixtures under `tests/dynamic_fixtures/c/free_fn/`. Both pin the
|
||||
// install ordering inside the emitted `main.c`:
|
||||
//
|
||||
// nyx_payload() <- harness setup
|
||||
// __nyx_install_crash_guard(callee) <- install
|
||||
// run(payload, len) <- entry
|
||||
//
|
||||
// `setup_fault.c` aborts in a global constructor (before `main` runs),
|
||||
// so the handler never installs and `Oracle::SinkCrash` cannot fire —
|
||||
// the verifier downgrades to `Inconclusive(UnrelatedCrash)`.
|
||||
//
|
||||
// `sink_fault.c` prints the in-harness sink-hit sentinel and then
|
||||
// NULL-dereferences on the vuln payload only. The handler is installed
|
||||
// by the time the deref happens, a Crash probe lands in `NYX_PROBE_PATH`,
|
||||
// and the differential rule (§4.1) confirms because the benign payload
|
||||
// short-circuits without crashing.
|
||||
|
||||
mod e2e_phase_08 {
|
||||
use crate::common::fixture_harness::FIXTURE_LOCK;
|
||||
use nyx_scanner::dynamic::runner::{run_spec, RunOutcome};
|
||||
use nyx_scanner::dynamic::sandbox::SandboxOptions;
|
||||
use nyx_scanner::dynamic::spec::{
|
||||
default_toolchain_id, EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy,
|
||||
};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn cc_available() -> bool {
|
||||
let bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned());
|
||||
std::process::Command::new(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stage `tests/dynamic_fixtures/c/free_fn/<file>` into a fresh
|
||||
/// tempdir and synthesise a [`HarnessSpec`] pointing at the copy.
|
||||
/// Returns the spec plus the tempdir guard (caller drops it after
|
||||
/// `run_spec` completes so the workdir survives the test).
|
||||
fn build_spec(file: &str) -> (HarnessSpec, tempfile::TempDir) {
|
||||
let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/dynamic_fixtures/c/free_fn")
|
||||
.join(file);
|
||||
let tmp = tempfile::TempDir::new().expect("create tempdir");
|
||||
let dst = tmp.path().join(file);
|
||||
std::fs::copy(&fixture_src, &dst).expect("copy fixture");
|
||||
|
||||
let entry_file = dst.to_string_lossy().into_owned();
|
||||
let mut digest = blake3::Hasher::new();
|
||||
digest.update(b"phase08-c-e2e|");
|
||||
digest.update(file.as_bytes());
|
||||
let spec_hash = format!("{:016x}", {
|
||||
let bytes = digest.finalize();
|
||||
u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap())
|
||||
});
|
||||
|
||||
let spec = HarnessSpec {
|
||||
finding_id: spec_hash.clone(),
|
||||
entry_file: entry_file.clone(),
|
||||
entry_name: "run".to_owned(),
|
||||
entry_kind: EntryKind::Function,
|
||||
lang: Lang::C,
|
||||
toolchain_id: default_toolchain_id(Lang::C).into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::FMT_STRING,
|
||||
constraint_hints: vec![],
|
||||
sink_file: entry_file,
|
||||
sink_line: 22,
|
||||
spec_hash: spec_hash.clone(),
|
||||
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
};
|
||||
|
||||
(spec, tmp)
|
||||
}
|
||||
|
||||
fn run(file: &str) -> Option<RunOutcome> {
|
||||
if !cc_available() {
|
||||
eprintln!("SKIP {file}: cc not available");
|
||||
return None;
|
||||
}
|
||||
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (spec, _tmp) = build_spec(file);
|
||||
let opts = SandboxOptions::default();
|
||||
match run_spec(&spec, &opts) {
|
||||
Ok(outcome) => Some(outcome),
|
||||
Err(e) => panic!("run_spec({file}) errored: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_fault_routes_to_unrelated_crash() {
|
||||
let Some(outcome) = run("setup_fault.c") else { return };
|
||||
assert!(
|
||||
outcome.triggered_by.is_none(),
|
||||
"setup_fault must not Confirm — handler is never installed: {outcome:?}",
|
||||
);
|
||||
assert!(
|
||||
outcome.unrelated_crash,
|
||||
"setup_fault must set unrelated_crash so verifier downgrades to Inconclusive(UnrelatedCrash): {outcome:?}",
|
||||
);
|
||||
let any_attempt_crashed = outcome
|
||||
.attempts
|
||||
.iter()
|
||||
.any(|a| a.outcome.exit_code.is_none() && !a.outcome.timed_out);
|
||||
assert!(
|
||||
any_attempt_crashed,
|
||||
"setup_fault constructor must abort the process at least once across attempts",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sink_fault_confirms_via_sink_crash_probe() {
|
||||
let Some(outcome) = run("sink_fault.c") else { return };
|
||||
assert!(
|
||||
outcome.triggered_by.is_some(),
|
||||
"sink_fault must Confirm via SinkCrash + differential: {outcome:?}",
|
||||
);
|
||||
let label = outcome
|
||||
.triggered_by
|
||||
.and_then(|i| outcome.attempts.get(i))
|
||||
.map(|a| a.payload_label);
|
||||
assert_eq!(
|
||||
label,
|
||||
Some("fmt-string-percent-n-crash"),
|
||||
"triggering payload must be the FMT_STRING vuln entry"
|
||||
);
|
||||
assert!(
|
||||
!outcome.unrelated_crash,
|
||||
"sink_fault attempt should NOT set unrelated_crash — probe was written: {outcome:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue