From 1ef650dc48182d672098f4788539ae7a0ca58c68 Mon Sep 17 00:00:00 2001 From: pitboss Date: Sat, 16 May 2026 05:18:59 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0008 (20260516T052512Z-20f8) --- src/dynamic/corpus.rs | 105 +++++++++++-- src/dynamic/telemetry.rs | 2 +- .../dynamic_fixtures/c/free_fn/setup_fault.c | 24 +++ tests/dynamic_fixtures/c/free_fn/sink_fault.c | 25 +++ tests/oracle_sink_crash.rs | 147 ++++++++++++++++++ 5 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 tests/dynamic_fixtures/c/free_fn/setup_fault.c create mode 100644 tests/dynamic_fixtures/c/free_fn/sink_fault.c diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs index a01c7a26..381307d5 100644 --- a/src/dynamic/corpus.rs +++ b/src/dynamic/corpus.rs @@ -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, + }, +]; diff --git a/src/dynamic/telemetry.rs b/src/dynamic/telemetry.rs index 5ea0da74..7a211bb5 100644 --- a/src/dynamic/telemetry.rs +++ b/src/dynamic/telemetry.rs @@ -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 diff --git a/tests/dynamic_fixtures/c/free_fn/setup_fault.c b/tests/dynamic_fixtures/c/free_fn/setup_fault.c new file mode 100644 index 00000000..fcbdc311 --- /dev/null +++ b/tests/dynamic_fixtures/c/free_fn/setup_fault.c @@ -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 +#include +#include + +__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"); +} diff --git a/tests/dynamic_fixtures/c/free_fn/sink_fault.c b/tests/dynamic_fixtures/c/free_fn/sink_fault.c new file mode 100644 index 00000000..80614157 --- /dev/null +++ b/tests/dynamic_fixtures/c/free_fn/sink_fault.c @@ -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 +#include +#include + +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; +} diff --git a/tests/oracle_sink_crash.rs b/tests/oracle_sink_crash.rs index df482f43..05b4a9f5 100644 --- a/tests/oracle_sink_crash.rs +++ b/tests/oracle_sink_crash.rs @@ -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/` 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 { + 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:?}", + ); + } +}