mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
feat(dynamic): remap command injection sink cap to CODE_EXEC, update corpus markers to NYX_PWN_791_CMDI, and enhance spec derivation strategies for wider coverage and consistency
This commit is contained in:
parent
738f1fedbc
commit
7027dbca0a
30 changed files with 524 additions and 130 deletions
|
|
@ -146,6 +146,63 @@ pub(crate) fn base_command(bin: &str) -> Command {
|
|||
cmd
|
||||
}
|
||||
|
||||
/// Hermetic Bundler / RubyGems environment pinned to a writable per-workdir
|
||||
/// vendor directory.
|
||||
///
|
||||
/// Points `GEM_HOME` and `BUNDLE_PATH` at `<workdir>/vendor/bundle` so every
|
||||
/// gem *install* lands in a directory the current user owns. This is the
|
||||
/// load-bearing fix for the harness build invoking `sudo`: legacy Bundler
|
||||
/// (1.x) shells out to `sudo` when the install target — the root-owned system
|
||||
/// gem dir (`/Library/Ruby/Gems/...`) — is not writable, which then blocks on
|
||||
/// a terminal password prompt (`sudo: a terminal is required to read the
|
||||
/// password`). With a writable target there is no privilege escalation and
|
||||
/// no prompt, ever.
|
||||
///
|
||||
/// `GEM_PATH` is deliberately left unset so RubyGems still includes the system
|
||||
/// gem path when *resolving* (paired with `BUNDLE_DISABLE_SHARED_GEMS=false`),
|
||||
/// letting an already-installed gem satisfy the Gemfile without a network
|
||||
/// fetch — while installs of missing gems still land in the writable vendor
|
||||
/// dir. `BUNDLE_APP_CONFIG` keeps Bundler's per-project config writable and
|
||||
/// inside the workdir.
|
||||
///
|
||||
/// Returned as env pairs (not applied to a `Command` here) so both the pooled
|
||||
/// path ([`ruby::RubyPool`]) and the legacy direct-spawn path
|
||||
/// ([`crate::dynamic::build_sandbox`]) layer them on identically. Setting
|
||||
/// these env vars is Bundler-version-agnostic: 1.x and 2.x both honour
|
||||
/// `BUNDLE_*` / `GEM_*`, unlike the 2.x-only `bundle config set` CLI the old
|
||||
/// path relied on (which is a silent no-op on 1.x, leaving the install target
|
||||
/// pointed at the system dir — the original root cause).
|
||||
pub(crate) fn ruby_hermetic_env(workdir: &Path) -> Vec<(&'static str, std::ffi::OsString)> {
|
||||
let gem_dir = workdir.join("vendor").join("bundle");
|
||||
let _ = std::fs::create_dir_all(&gem_dir);
|
||||
vec![
|
||||
("GEM_HOME", gem_dir.clone().into_os_string()),
|
||||
("BUNDLE_PATH", gem_dir.into_os_string()),
|
||||
("BUNDLE_DISABLE_SHARED_GEMS", "false".into()),
|
||||
("BUNDLE_FROZEN", "false".into()),
|
||||
(
|
||||
"BUNDLE_APP_CONFIG",
|
||||
workdir.join(".bundle").into_os_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Merge a process's stdout and stderr into one diagnostic blob.
|
||||
///
|
||||
/// Some build tools split their failure diagnostics across streams — Bundler
|
||||
/// in particular prints "Could not find gem …" to stdout while only an
|
||||
/// unrelated RubyGems extension warning lands on stderr. Capturing both keeps
|
||||
/// the downstream host-limitation classifier from missing the real reason.
|
||||
pub(crate) fn combine_output(stdout: &[u8], stderr: &[u8]) -> String {
|
||||
let out = String::from_utf8_lossy(stdout);
|
||||
let err = String::from_utf8_lossy(stderr);
|
||||
match (out.trim().is_empty(), err.trim().is_empty()) {
|
||||
(true, _) => err.into_owned(),
|
||||
(false, true) => out.into_owned(),
|
||||
(false, false) => format!("{out}\n{err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a runnable `ccache` binary (honouring `NYX_CCACHE_BIN`). Shared
|
||||
/// by the C and C++ pools to front their compiler with the shared object
|
||||
/// cache; `None` means "compile bare", preserving legacy parity.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@
|
|||
//! compiled require-cache across findings. Falls back to the legacy path when
|
||||
//! `bundle` is not runnable.
|
||||
|
||||
use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir};
|
||||
use super::{
|
||||
BuildPool, PoolCompileResult, base_command, binary_runnable, combine_output, pool_cache_dir,
|
||||
ruby_hermetic_env,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
|
|
@ -29,6 +32,10 @@ impl RubyPool {
|
|||
fn bundle(&self, workdir: &Path) -> std::process::Command {
|
||||
let mut cmd = base_command(&self.bundle_bin);
|
||||
cmd.current_dir(workdir);
|
||||
// Writable gem target → no privilege escalation → never `sudo`.
|
||||
for (k, v) in ruby_hermetic_env(workdir) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if let Some(cache) = pool_cache_dir("ruby", "bootsnap") {
|
||||
cmd.env("BOOTSNAP_CACHE_DIR", cache);
|
||||
}
|
||||
|
|
@ -56,31 +63,16 @@ impl BuildPool for RubyPool {
|
|||
}
|
||||
}
|
||||
|
||||
let config = self
|
||||
.bundle(workdir)
|
||||
.args(["config", "set", "--local", "path", "vendor/bundle"])
|
||||
.output();
|
||||
match config {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return PoolCompileResult {
|
||||
success: false,
|
||||
stderr: format!("ruby-pool: bundle config: {e}"),
|
||||
duration: start.elapsed(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The install target is pinned to a writable vendor dir via
|
||||
// `ruby_hermetic_env` (GEM_HOME / BUNDLE_PATH), so the legacy
|
||||
// `bundle config set --local path …` step is gone: it is 2.x-only
|
||||
// syntax that no-ops on Bundler 1.x (leaving the target pointed at
|
||||
// the root-owned system dir — the `sudo` root cause). `--local`
|
||||
// keeps the build offline: missing gems fail fast with a
|
||||
// host-limitation error instead of reaching for the network.
|
||||
let install = self
|
||||
.bundle(workdir)
|
||||
.args(["install", "--jobs", "4", "--retry", "2"])
|
||||
.args(["install", "--local", "--jobs", "4", "--retry", "0"])
|
||||
.output();
|
||||
match install {
|
||||
Ok(o) if o.status.success() => PoolCompileResult {
|
||||
|
|
@ -90,7 +82,12 @@ impl BuildPool for RubyPool {
|
|||
},
|
||||
Ok(o) => PoolCompileResult {
|
||||
success: false,
|
||||
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
|
||||
// Bundler prints its dependency-resolution diagnostics
|
||||
// ("Could not find gem '…' in any of the gem sources …") to
|
||||
// STDOUT, leaving only the RubyGems extension warning on
|
||||
// stderr. Combine both so the host-limitation classifier at
|
||||
// the verify boundary can see the real reason.
|
||||
stderr: combine_output(&o.stdout, &o.stderr),
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
Err(e) => PoolCompileResult {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use crate::dynamic::build_pool::php::PhpPool;
|
|||
use crate::dynamic::build_pool::python::PythonPool;
|
||||
use crate::dynamic::build_pool::ruby::RubyPool;
|
||||
use crate::dynamic::build_pool::rust::RustPool;
|
||||
use crate::dynamic::build_pool::{BuildPool, is_pool_enabled};
|
||||
use crate::dynamic::build_pool::{BuildPool, combine_output, is_pool_enabled, ruby_hermetic_env};
|
||||
use crate::dynamic::sandbox::ProcessHardeningProfile;
|
||||
use crate::dynamic::spec::HarnessSpec;
|
||||
use crate::symbol::Lang;
|
||||
|
|
@ -516,44 +516,49 @@ fn try_bundle_install(workdir: &Path) -> Result<(), String> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let config = Command::new(&bundle)
|
||||
.args(["config", "set", "--local", "path", "vendor/bundle"])
|
||||
.current_dir(workdir)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
.output()
|
||||
.map_err(|e| format!("bundle config: {e}"))?;
|
||||
if !config.status.success() {
|
||||
return Err(String::from_utf8_lossy(&config.stderr).into_owned());
|
||||
}
|
||||
|
||||
let output = Command::new(&bundle)
|
||||
.args(["install", "--jobs", "4", "--retry", "2"])
|
||||
.current_dir(workdir)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
// No `bundle config set …` step: it is 2.x-only syntax that silently
|
||||
// no-ops on Bundler 1.x, which then installs to the root-owned system gem
|
||||
// dir and shells out to `sudo`. `ruby_build_command` pins a writable
|
||||
// install target via env (GEM_HOME / BUNDLE_PATH) on every Bundler
|
||||
// version, and `--local` keeps the build offline so an absent gem fails
|
||||
// fast with a host-limitation error rather than reaching the network.
|
||||
let output = ruby_build_command(&bundle, workdir)
|
||||
.args(["install", "--local", "--jobs", "4", "--retry", "0"])
|
||||
.output()
|
||||
.map_err(|e| format!("bundle install: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||
// Bundler's resolution error ("Could not find gem …") goes to stdout;
|
||||
// combine both streams so the host-limitation classifier sees it.
|
||||
return Err(combine_output(&output.stdout, &output.stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundle_check(bundle: &str, workdir: &Path) -> Result<bool, String> {
|
||||
let output = Command::new(bundle)
|
||||
let output = ruby_build_command(bundle, workdir)
|
||||
.arg("check")
|
||||
.current_dir(workdir)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
.output()
|
||||
.map_err(|e| format!("bundle check: {e}"))?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
/// Build a Bundler/RubyGems `Command` with a scrubbed environment plus the
|
||||
/// hermetic gem env from [`ruby_hermetic_env`] (writable `GEM_HOME` /
|
||||
/// `BUNDLE_PATH`). This is the legacy direct-spawn sibling of
|
||||
/// [`crate::dynamic::build_pool::ruby::RubyPool::bundle`]; both guarantee the
|
||||
/// Ruby harness build never invokes `sudo` and never touches the network.
|
||||
fn ruby_build_command(bundle: &str, workdir: &Path) -> Command {
|
||||
let mut cmd = Command::new(bundle);
|
||||
cmd.current_dir(workdir)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default());
|
||||
for (k, v) in ruby_hermetic_env(workdir) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn restore_cached_ruby_bundle(cache_path: &Path, workdir: &Path) {
|
||||
let cached_vendor = cache_path.join("vendor").join("bundle");
|
||||
if cached_vendor.exists() && !workdir.join("vendor").join("bundle").exists() {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ pub use crate::dynamic::oracle::Oracle;
|
|||
/// | 13 | 2026-05-18 | Phase 09 / Track J.7: `OPEN_REDIRECT` cap lit for Java / Python / PHP / Ruby / JS / Go / Rust; `ProbeKind::Redirect` + `ProbePredicate::RedirectHostNotIn`; per-lang `sendRedirect` / `redirect()` shims |
|
||||
/// | 14 | 2026-05-18 | Phase 10 / Track J.8: `PROTOTYPE_POLLUTION` cap lit for JS / TS; `ProbeKind::PrototypePollution` + `ProbePredicate::PrototypeCanaryTouched`; Node harness installs `Proxy`-style canary trap on `Object.prototype.__nyx_canary` |
|
||||
/// | 15 | 2026-05-18 | Phase 11 / Track J.9: `CRYPTO` (Java/Python/PHP/Go/Rust) + `JSON_PARSE` (JS/Python/Ruby) + `UNAUTHORIZED_ID` (7 langs) + `DATA_EXFIL` (7 langs); `ProbeKind::{WeakKey,IdorAccess,OutboundNetwork}` + `ProbePredicate::{WeakKeyEntropy,IdorBoundaryCrossed,OutboundHostNotIn}`; `UnsupportedReason::SoundOracleUnavailable` for caps with no sound oracle |
|
||||
pub const CORPUS_VERSION: u32 = 15;
|
||||
/// | 16 | 2026-06-01 | Collision-resistant `cmdi` (`CODE_EXEC`) marker: payload `; echo NYX_PWN_$((113*7))_CMDI`, oracle `OutputContains("NYX_PWN_791_CMDI")`. The marker is now produced only by *executing* the injected `echo` (arithmetic expansion), not by a sink that merely echoes the (safely-quoted) payload — so a benign `os.system("echo " + shlex.quote(x))` control no longer false-confirms. Paired with the static `SHELL_ESCAPE` sink cap being remapped to the driveable `CODE_EXEC` at spec derivation. |
|
||||
pub const CORPUS_VERSION: u32 = 16;
|
||||
|
||||
/// Where a payload originated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-c",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -27,7 +27,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-c",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-cpp",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -30,7 +30,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-cpp",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-go",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -27,7 +27,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-go",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-java",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-java",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-javascript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-javascript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-php",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-php",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-python",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -30,7 +30,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-python",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-ruby",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -26,7 +26,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-ruby",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 1,
|
||||
|
|
@ -31,7 +31,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 4,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
|||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"; echo NYX_PWN_CMDI",
|
||||
bytes: b":; echo NYX_PWN_$((113*7))_CMDI",
|
||||
label: "cmdi-echo-marker-typescript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
@ -25,7 +25,7 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
|||
CuratedPayload {
|
||||
bytes: b"benign_safe_cmdi_NYX_BENIGN",
|
||||
label: "cmdi-benign-typescript",
|
||||
oracle: Oracle::OutputContains("NYX_PWN_CMDI"),
|
||||
oracle: Oracle::OutputContains("NYX_PWN_791_CMDI"),
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 15,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ pub enum CShape {
|
|||
/// libFuzzer-style: `int LLVMFuzzerTestOneInput(const uint8_t *data,
|
||||
/// size_t size)`. Harness invokes with `payload` bytes + length.
|
||||
LibfuzzerEntry,
|
||||
/// `int main(void)` / `int main()`. A no-argument program entry: the
|
||||
/// harness invokes it with no arguments (calling it with `(argc, argv)`
|
||||
/// is a "too many arguments to function call" compile error).
|
||||
MainVoid,
|
||||
/// Free function with `(const char *, size_t)` or `(const char *)`
|
||||
/// signature. Harness invokes directly.
|
||||
FreeFn,
|
||||
|
|
@ -80,6 +84,12 @@ impl CShape {
|
|||
if has_libfuzzer {
|
||||
return Self::LibfuzzerEntry;
|
||||
}
|
||||
// A `main(void)` / `main()` entry takes no argv; invoking it with
|
||||
// `(argc, argv)` is a compile error. Route it to MainVoid so the
|
||||
// harness calls it with no arguments.
|
||||
if entry == "main" && main_takes_no_args(source) {
|
||||
return Self::MainVoid;
|
||||
}
|
||||
if entry == "main" || has_main_argv {
|
||||
return Self::MainArgv;
|
||||
}
|
||||
|
|
@ -91,6 +101,13 @@ impl CShape {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when `source` declares a no-argument `main` (`int main(void)` or
|
||||
/// `int main()`), tolerating arbitrary internal whitespace.
|
||||
fn main_takes_no_args(source: &str) -> bool {
|
||||
let compact: String = source.split_whitespace().collect();
|
||||
compact.contains("main(void)") || compact.contains("main()")
|
||||
}
|
||||
|
||||
/// Public wrapper: detect the shape for a finalised `HarnessSpec`, reading
|
||||
/// the entry file from disk.
|
||||
pub fn detect_shape(spec: &HarnessSpec) -> CShape {
|
||||
|
|
@ -859,6 +876,11 @@ fn invoke_for_shape(spec: &HarnessSpec, shape: CShape) -> String {
|
|||
entry_fn = entry_fn,
|
||||
)
|
||||
}
|
||||
CShape::MainVoid => {
|
||||
// `int main(void)` / `int main()` — renamed to `__nyx_entry_main`
|
||||
// by the include guards; invoke with no arguments.
|
||||
format!(" (void)payload;\n {entry_fn}();\n")
|
||||
}
|
||||
CShape::MainArgv => {
|
||||
// Heap-allocate `new_argv` so a future `PayloadSlot::Argv(n)` with
|
||||
// `n >= 6` cannot overrun a fixed stack array. Slots: 1
|
||||
|
|
|
|||
|
|
@ -585,9 +585,6 @@ fn derive_from_flow_steps(
|
|||
if evidence.flow_steps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entry = outermost_entry(&evidence.flow_steps)?;
|
||||
|
||||
let lang = lang_from_path(&entry.file)?;
|
||||
let expected_cap = Cap::from_bits_truncate(evidence.sink_caps);
|
||||
if expected_cap.is_empty() {
|
||||
return None;
|
||||
|
|
@ -601,10 +598,38 @@ fn derive_from_flow_steps(
|
|||
.map(|s| (s.file.clone(), s.line))
|
||||
.unwrap_or_else(|| (diag.path.clone(), diag.line as u32));
|
||||
|
||||
// Entry resolution, in descending fidelity:
|
||||
// 1. the outermost `Source` step that carries a function annotation
|
||||
// (the original behaviour — the outermost callable receiving input),
|
||||
// 2. the first flow step carrying *any* function annotation — covers the
|
||||
// generic `taint-unsanitised-flow` shape whose flow begins at a `Call`
|
||||
// / assignment step rather than a `Source` step, so it has no
|
||||
// `Source`-kind step yet still names the enclosing function,
|
||||
// 3. the enclosing function resolved from the sink's AST span, and
|
||||
// 4. the `<unknown>` placeholder, which the per-language emitters route
|
||||
// to a synthetic direct-sink harness.
|
||||
// The sink location plus a non-empty cap is enough to drive verification,
|
||||
// so a missing `Source` step no longer aborts derivation.
|
||||
let entry = outermost_entry(&evidence.flow_steps)
|
||||
.or_else(|| first_annotated_entry(&evidence.flow_steps));
|
||||
let (entry_file, entry_name) = match entry {
|
||||
Some(e) => (e.file, e.function),
|
||||
None => {
|
||||
let name = lang_from_path(&sink_file)
|
||||
.and_then(|l| {
|
||||
resolve_enclosing_function_via_ast(&sink_file, sink_line as usize, l)
|
||||
})
|
||||
.unwrap_or_else(|| "<unknown>".to_owned());
|
||||
(sink_file.clone(), name)
|
||||
}
|
||||
};
|
||||
|
||||
let lang = lang_from_path(&entry_file).or_else(|| lang_from_path(&sink_file))?;
|
||||
|
||||
Some(finalize_spec(
|
||||
diag,
|
||||
entry.file,
|
||||
entry.function,
|
||||
entry_file,
|
||||
entry_name,
|
||||
lang,
|
||||
expected_cap,
|
||||
sink_file,
|
||||
|
|
@ -614,6 +639,26 @@ fn derive_from_flow_steps(
|
|||
))
|
||||
}
|
||||
|
||||
/// Return an [`EntryRef`] for the first flow step that carries a non-empty
|
||||
/// `function` annotation, regardless of its [`FlowStepKind`].
|
||||
///
|
||||
/// Unlike [`outermost_entry`] (which requires a `Source`-kind step), this
|
||||
/// recovers an entry from flows that begin at a `Call` / assignment step —
|
||||
/// the common shape for the generic `taint-unsanitised-flow` rule, whose
|
||||
/// steps are annotated with the enclosing function but include no explicit
|
||||
/// `Source` step.
|
||||
fn first_annotated_entry(steps: &[crate::evidence::FlowStep]) -> Option<EntryRef> {
|
||||
steps.iter().find_map(|s| {
|
||||
s.function
|
||||
.as_ref()
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(|f| EntryRef {
|
||||
file: s.file.clone(),
|
||||
function: f.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Strategy 2: from rule namespace + sink evidence ──────────────────────────
|
||||
|
||||
/// Build a spec from a rule-namespace finding (e.g. `py.cmdi.os_system`,
|
||||
|
|
@ -1490,6 +1535,15 @@ fn function_node_name(node: tree_sitter::Node, bytes: &[u8]) -> Option<String> {
|
|||
{
|
||||
return Some(text.to_owned());
|
||||
}
|
||||
// C / C++ expose the function name inside the `declarator` subtree
|
||||
// (`function_definition` -> `function_declarator` -> `identifier`), not a
|
||||
// `name` field, so the direct-child scan below misses it. Descend the
|
||||
// declarator chain first.
|
||||
if let Some(decl) = node.child_by_field_name("declarator")
|
||||
&& let Some(name) = declarator_name(decl, bytes)
|
||||
{
|
||||
return Some(name);
|
||||
}
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
let kind = child.kind();
|
||||
|
|
@ -1506,6 +1560,40 @@ fn function_node_name(node: tree_sitter::Node, bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Follow a C / C++ declarator chain (`pointer_declarator`,
|
||||
/// `function_declarator`, `parenthesized_declarator`, `array_declarator`, …)
|
||||
/// down to the leaf identifier — the declared function name.
|
||||
fn declarator_name(node: tree_sitter::Node, bytes: &[u8]) -> Option<String> {
|
||||
let mut cur = node;
|
||||
loop {
|
||||
let kind = cur.kind();
|
||||
if kind == "identifier" || kind == "field_identifier" || kind == "type_identifier" {
|
||||
return cur
|
||||
.utf8_text(bytes)
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.to_owned());
|
||||
}
|
||||
match cur.child_by_field_name("declarator") {
|
||||
Some(next) => cur = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
// Leaf was not reached via the `declarator` field (e.g. an inner
|
||||
// `parenthesized_declarator`); scan immediate children for the identifier.
|
||||
let mut cursor = cur.walk();
|
||||
for child in cur.children(&mut cursor) {
|
||||
let kind = child.kind();
|
||||
if (kind == "identifier" || kind == "field_identifier")
|
||||
&& let Ok(text) = child.utf8_text(bytes)
|
||||
&& !text.is_empty()
|
||||
{
|
||||
return Some(text.to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Lookup a `FuncSummary` by `(lang, name)` and filter to one whose
|
||||
/// `file_path` matches `diag_path`. Returns `None` on no match.
|
||||
fn find_summary_by_path<'a>(
|
||||
|
|
@ -1594,6 +1682,32 @@ fn cap_for_rule_category(category: &str) -> Option<Cap> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Remap a static *sink* capability onto the capability the dynamic corpus
|
||||
/// keys its payload set + sound oracle under.
|
||||
///
|
||||
/// The static taint engine tags a shell command-injection sink with
|
||||
/// [`Cap::SHELL_ESCAPE`] — the "data reaches a shell context" property — but
|
||||
/// the dynamic corpus keys the command-injection oracle and every cmdi payload
|
||||
/// under [`Cap::CODE_EXEC`] (see [`crate::dynamic::corpus::registry`]). Left
|
||||
/// unmapped, every command-injection finding derives a spec whose cap has no
|
||||
/// oracle and routes to `Unsupported(SoundOracleUnavailable)` instead of being
|
||||
/// executed — historically the single largest "unsupported" class.
|
||||
///
|
||||
/// `SHELL_ESCAPE` on a *sink* is always command injection, so swapping it for
|
||||
/// `CODE_EXEC` is sound now that the cmdi oracle is collision-resistant
|
||||
/// (corpus v16: the marker is produced only by executing the injected command,
|
||||
/// not by a sink that safely echoes the quoted payload — so a benign
|
||||
/// `os.system("echo " + shlex.quote(x))` control no longer false-confirms).
|
||||
/// Other set bits are preserved so a multi-cap sink keeps its other
|
||||
/// (already-driveable) capabilities.
|
||||
fn drivable_expected_cap(cap: Cap) -> Cap {
|
||||
if cap.contains(Cap::SHELL_ESCAPE) {
|
||||
(cap - Cap::SHELL_ESCAPE) | Cap::CODE_EXEC
|
||||
} else {
|
||||
cap
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn finalize_spec(
|
||||
diag: &Diag,
|
||||
|
|
@ -1606,6 +1720,10 @@ fn finalize_spec(
|
|||
derivation: SpecDerivationStrategy,
|
||||
summaries: Option<&GlobalSummaries>,
|
||||
) -> HarnessSpec {
|
||||
// Drive the finding against the cap the corpus actually keys an oracle
|
||||
// under (command injection: SHELL_ESCAPE -> CODE_EXEC) instead of routing
|
||||
// to `Unsupported(SoundOracleUnavailable)`.
|
||||
let expected_cap = drivable_expected_cap(expected_cap);
|
||||
let toolchain_id = default_toolchain_id(lang).to_owned();
|
||||
let stubs_required = StubKind::for_cap(expected_cap);
|
||||
let mut spec = HarnessSpec {
|
||||
|
|
@ -2360,7 +2478,8 @@ mod tests {
|
|||
let spec = HarnessSpec::from_finding(&diag).unwrap();
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromRuleNamespace);
|
||||
assert_eq!(spec.lang, Lang::Python);
|
||||
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
|
||||
// cmdi sink cap `SHELL_ESCAPE` remaps to the driveable `CODE_EXEC`.
|
||||
assert_eq!(spec.expected_cap, Cap::CODE_EXEC);
|
||||
assert_eq!(spec.entry_file, "app/handler.py");
|
||||
assert_eq!(spec.sink_line, 12);
|
||||
}
|
||||
|
|
@ -2374,6 +2493,24 @@ mod tests {
|
|||
assert_eq!(spec.expected_cap, Cap::DESERIALIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drivable_expected_cap_remaps_shell_escape_to_code_exec() {
|
||||
// Command injection: the oracle + payloads live under `CODE_EXEC`,
|
||||
// while the static engine tags cmdi sinks `SHELL_ESCAPE` (which has no
|
||||
// sound oracle of its own). The remap routes cmdi findings to the real
|
||||
// (collision-resistant, corpus v16) oracle instead of
|
||||
// `SoundOracleUnavailable`.
|
||||
assert_eq!(drivable_expected_cap(Cap::SHELL_ESCAPE), Cap::CODE_EXEC);
|
||||
// Multi-cap sinks keep their other (already-driveable) bits.
|
||||
assert_eq!(
|
||||
drivable_expected_cap(Cap::SHELL_ESCAPE | Cap::FILE_IO),
|
||||
Cap::CODE_EXEC | Cap::FILE_IO
|
||||
);
|
||||
// Caps without SHELL_ESCAPE pass through untouched.
|
||||
assert_eq!(drivable_expected_cap(Cap::SQL_QUERY), Cap::SQL_QUERY);
|
||||
assert_eq!(drivable_expected_cap(Cap::CODE_EXEC), Cap::CODE_EXEC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_namespace_strategy_pins_rs_auth_mapping() {
|
||||
// Regression: `rs.auth.*` must map to `Lang::Rust` + `Cap::UNAUTHORIZED_ID`.
|
||||
|
|
@ -2668,6 +2805,16 @@ mod tests {
|
|||
// name comes from the flow_steps annotation, the summary is found
|
||||
// by `(lang, name)` lookup filtered by file_path, and the spec
|
||||
// picks `tainted_sink_params[0]` as the payload slot.
|
||||
//
|
||||
// The evidence intentionally carries `sink_caps = 0`: this is the
|
||||
// scenario `func_summary_auto` exists for — recovering the cap (and
|
||||
// the tainted-param slot) from the summary when the finding's own
|
||||
// flow evidence lacks them. With a zero evidence cap, `FromFlowSteps`
|
||||
// bails (it requires a non-empty cap), so `FromFuncSummaryWalk` is the
|
||||
// strategy that supplies the cap and wins. (When the evidence *does*
|
||||
// carry a cap, `FromFlowSteps` derives the same enclosing-function
|
||||
// entry directly and outranks the summary walk per the precedence
|
||||
// ladder — that path is covered by the flow-steps tests.)
|
||||
use crate::labels::Cap;
|
||||
use crate::symbol::FuncKey;
|
||||
let mut gs = GlobalSummaries::new();
|
||||
|
|
@ -2684,7 +2831,7 @@ mod tests {
|
|||
|
||||
let ev = Evidence {
|
||||
flow_steps: vec![sink_only_step_with_function("app/handler.py", "do_request")],
|
||||
sink_caps: Cap::SHELL_ESCAPE.bits(),
|
||||
sink_caps: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let diag = crate::commands::scan::Diag {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,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 = "15";
|
||||
pub const CORPUS_VERSION: &str = "16";
|
||||
|
||||
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
||||
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
||||
|
|
|
|||
|
|
@ -462,20 +462,29 @@ fn format_spec_scoring_detail(
|
|||
detail
|
||||
}
|
||||
|
||||
/// True when the finding has *some* derivable signal (rule namespace, sink
|
||||
/// caps, or evidence) so a spec-derivation failure should be surfaced as
|
||||
/// True when the finding has *some* derivable signal (rule namespace or a
|
||||
/// drivable taint flow) so a spec-derivation failure should be surfaced as
|
||||
/// `Inconclusive` rather than `Unsupported`.
|
||||
///
|
||||
/// A finding with neither a non-zero sink capability nor any flow steps has no
|
||||
/// dynamic model at all: there is no cap to select a payload corpus / oracle
|
||||
/// and no flow to drive. This is the shape of the structural CFG / state
|
||||
/// rules (`cfg-unguarded-sink`, `cfg-resource-leak`, `state-unauthed-access`,
|
||||
/// `state-resource-leak`, `cfg-error-fallthrough`), which carry a sink span
|
||||
/// but zero cap bits. A bare sink span is therefore *not* treated as
|
||||
/// "derivation should have worked" — such findings route to `Unsupported`
|
||||
/// (a non-engine outcome), not engine-`Inconclusive(SpecDerivationFailed)`.
|
||||
fn should_be_inconclusive(diag: &Diag) -> bool {
|
||||
let has_rule_ns = diag.id.split('.').count() >= 2
|
||||
&& !diag.id.starts_with("taint-")
|
||||
&& !diag.id.starts_with("cfg-")
|
||||
&& !diag.id.starts_with("state-");
|
||||
let has_evidence = diag
|
||||
let has_drivable_evidence = diag
|
||||
.evidence
|
||||
.as_ref()
|
||||
.map(|e| e.sink_caps != 0 || !e.flow_steps.is_empty() || e.sink.is_some())
|
||||
.map(|e| e.sink_caps != 0 || !e.flow_steps.is_empty())
|
||||
.unwrap_or(false);
|
||||
has_rule_ns || has_evidence
|
||||
has_rule_ns || has_drivable_evidence
|
||||
}
|
||||
|
||||
fn derivation_failure_hint(diag: &Diag) -> String {
|
||||
|
|
@ -501,6 +510,96 @@ fn derivation_failure_hint(diag: &Diag) -> String {
|
|||
parts.join("; ")
|
||||
}
|
||||
|
||||
/// True when a build / runtime-load failure's stderr indicates a genuinely
|
||||
/// absent host dependency or toolchain rather than a defect in the harness
|
||||
/// the engine emitted.
|
||||
///
|
||||
/// These are host limitations — the dynamic verifier cannot run *this* finding
|
||||
/// on *this* host because a framework gem / Python module / npm package is not
|
||||
/// installed and could not be resolved offline, a top-level `require` /
|
||||
/// `import` / `use` failed at load time (`NYX_IMPORT_ERROR:`), or the language
|
||||
/// interpreter / compiler itself is missing. The verdict routes to
|
||||
/// `Unsupported(LangUnsupported)` so the operator sees "not verifiable on this
|
||||
/// host", not engine-`Inconclusive(BuildFailed)`.
|
||||
///
|
||||
/// The needles are deliberately specific to dependency-resolution / load
|
||||
/// failures: a malformed emitted harness produces compiler / syntax errors
|
||||
/// (`error[E…]`, `SyntaxError`, …) that match none of these and stay
|
||||
/// `Inconclusive(BuildFailed)`, preserving visibility of real engine defects.
|
||||
fn build_failure_is_host_limitation(stderr: &str) -> bool {
|
||||
const NEEDLES: &[&str] = &[
|
||||
// Top-level require/import/use failure emitted by the per-language
|
||||
// harness preambles (js_shared / ruby / php / python) with exit 77.
|
||||
"NYX_IMPORT_ERROR:",
|
||||
// Python: missing module / offline pip resolution miss.
|
||||
"No module named",
|
||||
"ModuleNotFoundError",
|
||||
"No matching distribution found",
|
||||
"Could not find a version that satisfies",
|
||||
// Ruby / Bundler: gem not installed / not resolvable offline.
|
||||
"Could not find gem",
|
||||
"Bundler::GemNotFound",
|
||||
"in any of the sources",
|
||||
"Could not find ", // bundler: "Could not find X-1.2.3 in locally installed gems"
|
||||
// Node: missing package.
|
||||
"Cannot find module",
|
||||
"ERR_MODULE_NOT_FOUND",
|
||||
// Generic missing-toolchain signatures.
|
||||
"command not found",
|
||||
"executable file not found",
|
||||
"No such file or directory (os error 2)",
|
||||
];
|
||||
if NEEDLES.iter().any(|n| stderr.contains(n)) {
|
||||
return true;
|
||||
}
|
||||
// Java / Kotlin: an `import` of a framework package that is not on the
|
||||
// host classpath produces `error: package <pkg> does not exist`. Offline
|
||||
// and without the dependency JAR (e.g. Spring on a bare host), that is a
|
||||
// host limitation, not an emitter defect.
|
||||
if stderr.contains("does not exist") && stderr.contains("package ") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True when a C / C++ harness build failure proves the emitter could not
|
||||
/// bind a standalone driver to the *resolved entry symbol*, as opposed to a
|
||||
/// fixable defect in otherwise-supported emitted code.
|
||||
///
|
||||
/// The compiler-native languages embed the fixture via `#include "entry.c"`
|
||||
/// and provide their own `main`. When the taint sink's enclosing function is
|
||||
/// an ordinary (non-`main`) symbol inside a file that *also* defines `main`,
|
||||
/// the harness `main` collides with the fixture's (`redefinition of 'main'`),
|
||||
/// or the resolved symbol's arity / signature matches no driveable shape
|
||||
/// (`too many/few arguments to function call`, `conflicting types for`).
|
||||
/// These are structural properties of the source — the entry simply is not a
|
||||
/// driveable top-level shape for the signature-blind C emitter — so the
|
||||
/// verdict is `Unsupported(EntryKindUnsupported)`, not
|
||||
/// engine-`Inconclusive(BuildFailed)`.
|
||||
///
|
||||
/// Scoped to C / C++ so the interpreted / managed languages (whose build
|
||||
/// failures are dependency / toolchain issues handled by
|
||||
/// [`build_failure_is_host_limitation`]) are unaffected, and a genuine
|
||||
/// emitter regression in a *supported* shape still surfaces as
|
||||
/// `Inconclusive(BuildFailed)` (its diagnostics carry none of these
|
||||
/// entry-binding signatures).
|
||||
fn build_failure_is_undrivable_entry(lang: crate::symbol::Lang, stderr: &str) -> bool {
|
||||
use crate::symbol::Lang;
|
||||
if !matches!(lang, Lang::C | Lang::Cpp) {
|
||||
return false;
|
||||
}
|
||||
const NEEDLES: &[&str] = &[
|
||||
"redefinition of 'main'",
|
||||
"redefinition of \u{2018}main\u{2019}", // gcc curly-quote variant
|
||||
"too many arguments to function call",
|
||||
"too few arguments to function call",
|
||||
"too many arguments to function", // gcc phrasing
|
||||
"too few arguments to function", // gcc phrasing
|
||||
"conflicting types for",
|
||||
];
|
||||
NEEDLES.iter().any(|n| stderr.contains(n))
|
||||
}
|
||||
|
||||
/// Try to dynamically confirm a static finding.
|
||||
///
|
||||
/// Never fails: every error path collapses into a [`VerifyStatus`] so the
|
||||
|
|
@ -1321,20 +1420,73 @@ fn build_verdict(
|
|||
Err(RunError::BuildFailed {
|
||||
stderr,
|
||||
attempts: build_att,
|
||||
}) => VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
status: VerifyStatus::Inconclusive,
|
||||
triggered_payload: None,
|
||||
reason: None,
|
||||
inconclusive_reason: Some(InconclusiveReason::BuildFailed),
|
||||
detail: Some(format!("build failed after {build_att} attempts: {stderr}")),
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
},
|
||||
}) => {
|
||||
// A build / runtime-load failure caused by a genuinely-absent host
|
||||
// dependency or toolchain (an offline dependency-resolution miss,
|
||||
// a missing module / gem / package, a top-level import failure, a
|
||||
// missing interpreter) is a host limitation, not a defect in the
|
||||
// harness the engine emitted. Such failures route to
|
||||
// `Unsupported(LangUnsupported)` — a non-engine outcome — rather
|
||||
// than `Inconclusive(BuildFailed)`, so the Inconclusive bucket
|
||||
// stays reserved for failures the engine could plausibly fix.
|
||||
// Real harness build defects (compiler errors, malformed emitted
|
||||
// source) carry none of these signatures and stay `Inconclusive`.
|
||||
if build_failure_is_host_limitation(&stderr) {
|
||||
VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
status: VerifyStatus::Unsupported,
|
||||
triggered_payload: None,
|
||||
reason: Some(UnsupportedReason::LangUnsupported),
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
} else if build_failure_is_undrivable_entry(spec.lang, &stderr) {
|
||||
// The toolchain is present and the emitted harness is
|
||||
// well-formed, but it cannot bind a standalone driver to the
|
||||
// resolved entry: the compiler-native langs (C / C++) reject
|
||||
// the harness because the fixture defines its own `main` that
|
||||
// collides with the harness `main`, or the entry's arity /
|
||||
// signature matches no driveable shape. That is an
|
||||
// unsupported *entry shape* for this source, not a fixable
|
||||
// engine defect — route to `Unsupported(EntryKindUnsupported)`
|
||||
// rather than engine-`Inconclusive(BuildFailed)`.
|
||||
VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
status: VerifyStatus::Unsupported,
|
||||
triggered_payload: None,
|
||||
reason: Some(UnsupportedReason::EntryKindUnsupported),
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
} else {
|
||||
VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
status: VerifyStatus::Inconclusive,
|
||||
triggered_payload: None,
|
||||
reason: None,
|
||||
inconclusive_reason: Some(InconclusiveReason::BuildFailed),
|
||||
detail: Some(format!("build failed after {build_att} attempts: {stderr}")),
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RunError::Sandbox(e)) => VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
status: VerifyStatus::Inconclusive,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import "strings"
|
|||
type ShellRunner struct{}
|
||||
|
||||
func (ShellRunner) Run(command string) string {
|
||||
return strings.ReplaceAll(command, "NYX_PWN_CMDI", "")
|
||||
return strings.ReplaceAll(command, "NYX_PWN", "")
|
||||
}
|
||||
|
||||
type UserRepository struct {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
public class Benign {
|
||||
public static class ShellRunner {
|
||||
public String run(String command) {
|
||||
return command.replace("NYX_PWN_CMDI", "");
|
||||
return command.replace("NYX_PWN", "");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Benign control for recursively constructed Ruby dependencies.
|
||||
class ShellRunner
|
||||
def run(command)
|
||||
command.gsub('NYX_PWN_CMDI', '')
|
||||
command.gsub('NYX_PWN', '')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Command injection — adversarial collision fixture.
|
||||
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
|
||||
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
|
||||
// and without emitting __NYX_SINK_HIT__.
|
||||
// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||
// Entry: RunPing(host string) Cap: CODE_EXEC
|
||||
|
|
@ -10,6 +10,6 @@ import "fmt"
|
|||
|
||||
func RunPing(host string) {
|
||||
// Coincidental oracle match — not a shell sink.
|
||||
fmt.Println("NYX_PWN_CMDI")
|
||||
fmt.Println("NYX_PWN_791_CMDI")
|
||||
_ = len(host)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Command injection — adversarial collision fixture.
|
||||
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
|
||||
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
|
||||
// and without emitting __NYX_SINK_HIT__.
|
||||
// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||
// Entry: Entry.runPing(String) Cap: CODE_EXEC
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
public class Entry {
|
||||
public static void runPing(String host) {
|
||||
// Coincidental oracle match — not a shell sink.
|
||||
System.out.println("NYX_PWN_CMDI");
|
||||
System.out.println("NYX_PWN_791_CMDI");
|
||||
int x = host.length();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
// Command injection — adversarial collision fixture.
|
||||
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
|
||||
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
|
||||
// and without emitting __NYX_SINK_HIT__.
|
||||
// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||
// Entry: runPing(host) Cap: CODE_EXEC
|
||||
|
||||
function runPing(host) {
|
||||
// Coincidental oracle match — not a shell sink.
|
||||
process.stdout.write('NYX_PWN_CMDI\n');
|
||||
process.stdout.write('NYX_PWN_791_CMDI\n');
|
||||
void host.length;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<?php
|
||||
// Command injection — adversarial collision fixture.
|
||||
// Prints NYX_PWN_CMDI unconditionally without reaching a command sink
|
||||
// Prints NYX_PWN_791_CMDI unconditionally without reaching a command sink
|
||||
// and without emitting __NYX_SINK_HIT__.
|
||||
// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||
// Entry: runPing($host) Cap: CODE_EXEC
|
||||
|
||||
function runPing($host) {
|
||||
// Coincidental oracle match — not a shell sink.
|
||||
echo "NYX_PWN_CMDI\n";
|
||||
echo "NYX_PWN_791_CMDI\n";
|
||||
$x = strlen($host);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Command injection — adversarial collision fixture.
|
||||
|
||||
Prints NYX_PWN_CMDI unconditionally without going through a shell sink.
|
||||
Prints NYX_PWN_791_CMDI unconditionally without going through a shell sink.
|
||||
Expected verdict: Inconclusive(OracleCollisionSuspected).
|
||||
"""
|
||||
|
||||
|
|
@ -8,5 +8,5 @@ Expected verdict: Inconclusive(OracleCollisionSuspected).
|
|||
def process_input(user_input):
|
||||
"""Prints oracle marker without using a command execution sink."""
|
||||
# Coincidental oracle match — not a shell sink.
|
||||
print("NYX_PWN_CMDI")
|
||||
print("NYX_PWN_791_CMDI")
|
||||
return user_input.upper()
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
/// Command injection — adversarial collision fixture.
|
||||
///
|
||||
/// Prints "NYX_PWN_CMDI" unconditionally without going through a shell sink.
|
||||
/// Prints "NYX_PWN_791_CMDI" unconditionally without going through a shell sink.
|
||||
///
|
||||
/// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||
/// oracle_fired = true (OutputContains("NYX_PWN_CMDI"))
|
||||
/// oracle_fired = true (OutputContains("NYX_PWN_791_CMDI"))
|
||||
/// sink_hit = false (__NYX_SINK_HIT__ never printed)
|
||||
/// Cap: CODE_EXEC Entry: `run(payload: &str)`
|
||||
pub fn run(payload: &str) {
|
||||
// Coincidental oracle match — not a command execution sink.
|
||||
println!("NYX_PWN_CMDI");
|
||||
println!("NYX_PWN_791_CMDI");
|
||||
let _ = payload.len();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1175,9 +1175,11 @@ mod e2e_phase_20 {
|
|||
}
|
||||
|
||||
/// Python kafka vuln must Confirm: the synthetic Kafka loopback
|
||||
/// delivers `; echo NYX_PWN_CMDI` to the handler's `os.system`
|
||||
/// which prints `NYX_PWN_CMDI` to stdout and the differential
|
||||
/// oracle reads it.
|
||||
/// delivers `; echo NYX_PWN_$((113*7))_CMDI` to the handler's
|
||||
/// `os.system`, which *executes* the injected `echo` and prints the
|
||||
/// computed marker `NYX_PWN_791_CMDI` to stdout (corpus v16 — a benign
|
||||
/// `shlex.quote` handler echoes the literal payload and never yields the
|
||||
/// marker), and the differential oracle reads it.
|
||||
#[test]
|
||||
fn kafka_python_vuln_confirms_via_run_spec() {
|
||||
let Some(outcome) = run(Lang::Python, "kafka_python", "vuln.py", "handler", "orders")
|
||||
|
|
|
|||
|
|
@ -176,7 +176,15 @@ fn assert_callgraph_rewrites_entry(
|
|||
"callgraph walk must classify the entry as HttpRoute; got {:?}",
|
||||
spec.entry_kind
|
||||
);
|
||||
assert_eq!(spec.expected_cap, cap);
|
||||
// Command injection's static sink cap `SHELL_ESCAPE` is remapped at spec
|
||||
// derivation to the driveable `CODE_EXEC` (the cap the dynamic corpus keys
|
||||
// its cmdi oracle under); every other cap passes through unchanged.
|
||||
let expected_drivable = if cap == Cap::SHELL_ESCAPE {
|
||||
Cap::CODE_EXEC
|
||||
} else {
|
||||
cap
|
||||
};
|
||||
assert_eq!(spec.expected_cap, expected_drivable);
|
||||
let _ = analysis; // accepted but not asserted on here.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ mod spec_strategies {
|
|||
let spec = HarnessSpec::from_finding(&diag).expect("flow_steps strategy must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromFlowSteps);
|
||||
assert_eq!(spec.entry_name, "handle_request");
|
||||
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
|
||||
// cmdi sink cap `SHELL_ESCAPE` remaps to the driveable `CODE_EXEC` (the
|
||||
// cap the dynamic corpus keys its command-injection oracle under).
|
||||
assert_eq!(spec.expected_cap, Cap::CODE_EXEC);
|
||||
}
|
||||
|
||||
// ── Strategy 2: FromRuleNamespace ────────────────────────────────────────
|
||||
|
|
@ -129,7 +131,8 @@ mod spec_strategies {
|
|||
|
||||
let spec = HarnessSpec::from_finding(&diag).expect("rule-namespace strategy must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromRuleNamespace);
|
||||
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
|
||||
// cmdi sink cap `SHELL_ESCAPE` remaps to the driveable `CODE_EXEC`.
|
||||
assert_eq!(spec.expected_cap, Cap::CODE_EXEC);
|
||||
assert_eq!(spec.toolchain_id, "python-3");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue