[pitboss/grind] deferred session-0015 (20260522T163126Z-7d60)

This commit is contained in:
pitboss 2026-05-22 18:03:08 -05:00
parent 727bbbde7e
commit 9070b1af22
12 changed files with 697 additions and 18 deletions

View file

@ -0,0 +1,212 @@
//! PHP [`super::super::FrameworkAdapter`] matching weak-crypto sink
//! constructions (`md5()` / `sha1()` for message digests,
//! `mt_rand()` / `rand()` for key material, `mcrypt_encrypt()` and
//! `mcrypt_create_iv()` legacy primitives, `hash('md5'|'sha1', …)`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical PHP weak-crypto entry points and the surrounding
//! source is plausibly a PHP script (starts with `<?php` or uses the
//! short `<?` tag).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct CryptoPhpAdapter;
const ADAPTER_NAME: &str = "crypto-php";
fn callee_is_weak_crypto(name: &str) -> bool {
let last = name
.rsplit_once("::")
.map(|(_, s)| s)
.unwrap_or(name)
.rsplit_once('\\')
.map(|(_, s)| s)
.unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"md5"
| "sha1"
| "md5_file"
| "sha1_file"
| "mt_rand"
| "rand"
| "mt_srand"
| "srand"
| "crc32"
| "mcrypt_create_iv"
| "mcrypt_encrypt"
| "mcrypt_decrypt"
| "uniqid"
)
}
fn source_is_php_script(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[b"<?php", b"<?=", b"<?\n"];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// crypto call through a hardened path (`random_bytes`,
/// `random_int`, `openssl_random_pseudo_bytes`, `sodium_crypto_*`,
/// `hash('sha256', …)` or stronger, `openssl_encrypt` with GCM).
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"random_bytes(",
b"random_int(",
b"openssl_random_pseudo_bytes(",
b"sodium_crypto_",
b"hash('sha256'",
b"hash(\"sha256\"",
b"hash('sha384'",
b"hash(\"sha384\"",
b"hash('sha512'",
b"hash(\"sha512\"",
b"hash('sha3-256'",
b"hash(\"sha3-256\"",
b"'aes-256-gcm'",
b"\"aes-256-gcm\"",
b"'chacha20-poly1305'",
b"\"chacha20-poly1305\"",
b"password_hash(",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for CryptoPhpAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Php
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if source_routed_through_strong_path(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_weak_crypto);
let matches_source = source_is_php_script(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_md5() {
let src: &[u8] =
b"<?php\nfunction sign($value) {\n return md5($value);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("md5")],
..Default::default()
};
assert!(
CryptoPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_mt_rand() {
let src: &[u8] = b"<?php\nfunction key_byte() {\n return mt_rand(0, 255);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "key_byte".into(),
callees: vec![crate::summary::CalleeSite::bare("mt_rand")],
..Default::default()
};
assert!(
CryptoPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_source_routes_through_random_bytes() {
let src: &[u8] = b"<?php\nfunction key() {\n if (function_exists('random_bytes')) {\n return random_bytes(32);\n }\n return md5(uniqid());\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "key".into(),
callees: vec![
crate::summary::CalleeSite::bare("md5"),
crate::summary::CalleeSite::bare("random_bytes"),
],
..Default::default()
};
assert!(
CryptoPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_when_sha256_hashing_present() {
let src: &[u8] =
b"<?php\nfunction sign($value) {\n return hash('sha256', $value);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("hash")],
..Default::default()
};
assert!(
CryptoPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"<?php\nfunction add($a, $b) {\n return $a + $b;\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
CryptoPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,221 @@
//! Ruby [`super::super::FrameworkAdapter`] matching weak-crypto sink
//! constructions (`Digest::MD5` / `Digest::SHA1` / `OpenSSL::HMAC`
//! over `MD5`/`SHA1`, `rand` / `srand` / `Random.rand` used for key
//! material).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Ruby weak-crypto entry points and the
//! surrounding source requires the matching stdlib module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct CryptoRubyAdapter;
const ADAPTER_NAME: &str = "crypto-ruby";
fn callee_is_weak_crypto(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"hexdigest" | "digest" | "base64digest" | "file" | "rand" | "srand"
) || matches!(
name,
"Digest::MD5.hexdigest"
| "Digest::MD5.digest"
| "Digest::MD5.base64digest"
| "Digest::MD5.new"
| "Digest::SHA1.hexdigest"
| "Digest::SHA1.digest"
| "Digest::SHA1.base64digest"
| "Digest::SHA1.new"
| "OpenSSL::Digest.new"
| "OpenSSL::Digest::MD5.new"
| "OpenSSL::Digest::SHA1.new"
| "OpenSSL::HMAC.digest"
| "OpenSSL::HMAC.hexdigest"
| "Random.rand"
| "Kernel.rand"
| "Kernel.srand"
)
}
fn source_imports_ruby_crypto(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require 'digest'",
b"require \"digest\"",
b"require 'digest/md5'",
b"require \"digest/md5\"",
b"require 'digest/sha1'",
b"require \"digest/sha1\"",
b"require 'openssl'",
b"require \"openssl\"",
b"Digest::MD5",
b"Digest::SHA1",
b"OpenSSL::Digest",
b"OpenSSL::HMAC",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// crypto call through a hardened path (`SecureRandom`, SHA-256+,
/// `OpenSSL::Cipher.new("AES-256-GCM")`, libsodium).
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require 'securerandom'",
b"require \"securerandom\"",
b"SecureRandom.",
b"Digest::SHA256",
b"Digest::SHA384",
b"Digest::SHA512",
b"\"SHA256\"",
b"'SHA256'",
b"\"SHA-256\"",
b"'SHA-256'",
b"\"SHA384\"",
b"\"SHA512\"",
b"\"AES-256-GCM\"",
b"'AES-256-GCM'",
b"\"ChaCha20-Poly1305\"",
b"RbNaCl::",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for CryptoRubyAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Ruby
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if source_routed_through_strong_path(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_weak_crypto);
let matches_source = source_imports_ruby_crypto(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ruby(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_digest_md5_hexdigest() {
let src: &[u8] = b"require 'digest'\n\
def sign(value)\n Digest::MD5.hexdigest(value)\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("Digest::MD5.hexdigest")],
..Default::default()
};
assert!(
CryptoRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_openssl_hmac_md5() {
let src: &[u8] = b"require 'openssl'\n\
def sign(key, value)\n OpenSSL::HMAC.hexdigest('MD5', key, value)\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("OpenSSL::HMAC.hexdigest")],
..Default::default()
};
assert!(
CryptoRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_source_routes_through_securerandom() {
let src: &[u8] = b"require 'digest'\nrequire 'securerandom'\n\
def run(value)\n if value.include?('STRONG')\n SecureRandom.hex(32)\n else\n Digest::MD5.hexdigest(value)\n end\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("Digest::MD5.hexdigest")],
..Default::default()
};
assert!(
CryptoRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_when_sha256_in_source() {
let src: &[u8] = b"require 'digest'\n\
def sign(value)\n Digest::SHA256.hexdigest(value)\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("Digest::SHA256.hexdigest")],
..Default::default()
};
assert!(
CryptoRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
CryptoRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,225 @@
//! Ruby [`super::super::FrameworkAdapter`] matching outbound-HTTP
//! sink constructions (`Net::HTTP.{get,get_response,post_form,start}`,
//! `RestClient.{get,post}`, `HTTParty.{get,post}`, `Faraday.get`,
//! `open-uri`'s `open(...)`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Ruby HTTP-client entry points and the
//! surrounding source requires the matching client module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct DataExfilRubyAdapter;
const ADAPTER_NAME: &str = "data-exfil-ruby";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"get_response" | "post_form" | "request" | "start"
) || matches!(
name,
"Net::HTTP.get"
| "Net::HTTP.get_response"
| "Net::HTTP.post_form"
| "Net::HTTP.start"
| "Net::HTTP::Get.new"
| "Net::HTTP::Post.new"
| "RestClient.get"
| "RestClient.post"
| "RestClient.put"
| "RestClient.delete"
| "RestClient::Request.execute"
| "HTTParty.get"
| "HTTParty.post"
| "HTTParty.put"
| "HTTParty.delete"
| "Faraday.get"
| "Faraday.post"
| "Faraday.new"
| "Faraday::Connection.get"
| "URI.open"
| "Kernel.open"
)
}
fn source_imports_ruby_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require 'net/http'",
b"require \"net/http\"",
b"require 'open-uri'",
b"require \"open-uri\"",
b"require 'rest-client'",
b"require \"rest-client\"",
b"require 'rest_client'",
b"require 'httparty'",
b"require \"httparty\"",
b"require 'faraday'",
b"require \"faraday\"",
b"require 'http'",
b"require \"http\"",
b"Net::HTTP",
b"RestClient.",
b"HTTParty.",
b"Faraday.",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// outbound URL through a host-allowlist / network-policy gate.
fn host_routed_through_allowlist(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"ALLOWLIST",
b"allowlist",
b"ALLOWED_HOSTS",
b"allowed_hosts",
b"'127.0.0.1'",
b"\"127.0.0.1\"",
b"'localhost'",
b"\"localhost\"",
b"host == 'localhost'",
b"host == \"localhost\"",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for DataExfilRubyAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Ruby
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if host_routed_through_allowlist(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_outbound_http);
let matches_source = source_imports_ruby_http_client(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ruby(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_net_http_get() {
let src: &[u8] = b"require 'net/http'\n\
def run(host)\n Net::HTTP.get(URI(\"http://#{host}/exfil\"))\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("Net::HTTP.get")],
..Default::default()
};
assert!(
DataExfilRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_restclient_post() {
let src: &[u8] = b"require 'rest-client'\n\
def run(host)\n RestClient.post(\"http://#{host}/exfil\", { token: 'x' })\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("RestClient.post")],
..Default::default()
};
assert!(
DataExfilRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_faraday_get() {
let src: &[u8] = b"require 'faraday'\n\
def run(host)\n Faraday.get(\"http://#{host}/exfil\")\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("Faraday.get")],
..Default::default()
};
assert!(
DataExfilRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_host_routed_through_allowlist() {
let src: &[u8] = b"require 'net/http'\n\
ALLOWLIST = ['127.0.0.1', 'localhost'].freeze\n\
def run(host)\n return unless ALLOWLIST.include?(host)\n Net::HTTP.get(URI(\"http://#{host}/exfil\"))\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("Net::HTTP.get")],
..Default::default()
};
assert!(
DataExfilRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
DataExfilRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -13,10 +13,13 @@
pub mod crypto_java;
pub mod crypto_js;
pub mod crypto_php;
pub mod crypto_python;
pub mod crypto_ruby;
pub mod data_exfil_go;
pub mod data_exfil_js;
pub mod data_exfil_python;
pub mod data_exfil_ruby;
pub mod go_chi;
pub mod go_echo;
pub mod go_fiber;
@ -130,10 +133,13 @@ pub mod xxe_ruby;
pub use crypto_java::CryptoJavaAdapter;
pub use crypto_js::CryptoJsAdapter;
pub use crypto_php::CryptoPhpAdapter;
pub use crypto_python::CryptoPythonAdapter;
pub use crypto_ruby::CryptoRubyAdapter;
pub use data_exfil_go::DataExfilGoAdapter;
pub use data_exfil_js::DataExfilJsAdapter;
pub use data_exfil_python::DataExfilPythonAdapter;
pub use data_exfil_ruby::DataExfilRubyAdapter;
pub use go_chi::GoChiAdapter;
pub use go_echo::GoEchoAdapter;
pub use go_fiber::GoFiberAdapter;

View file

@ -287,6 +287,10 @@ mod tests {
// Python: +2 (CryptoPython, DataExfilPython) 22 → 24
// JavaScript: +2 (CryptoJs, DataExfilJs) 20 → 22
// Go: +1 (DataExfilGo) 11 → 12
// Track L.9 follow-up slice (session-0015 of run 7d60):
// CRYPTO × {Php, Ruby} + DATA_EXFIL × Ruby.
// Php: +1 (CryptoPhp) 12 → 13
// Ruby: +2 (CryptoRuby, DataExfilRuby) 12 → 14
let java_registered = registry::adapters_for(Lang::Java);
assert_eq!(
java_registered.len(),
@ -299,8 +303,8 @@ mod tests {
let php_registered = registry::adapters_for(Lang::Php);
assert_eq!(
php_registered.len(),
12,
"Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2)",
13,
"Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 CryptoPhp (1)",
);
for adapter in php_registered {
assert_eq!(adapter.lang(), Lang::Php);
@ -317,8 +321,8 @@ mod tests {
let ruby_registered = registry::adapters_for(Lang::Ruby);
assert_eq!(
ruby_registered.len(),
12,
"Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4)",
14,
"Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4) + Track L.9 (CryptoRuby, DataExfilRuby)",
);
for adapter in ruby_registered {
assert_eq!(adapter.lang(), Lang::Ruby);

View file

@ -92,6 +92,7 @@ static GO: &[&dyn FrameworkAdapter] = &[
&super::adapters::XxeGoAdapter,
];
static PHP: &[&dyn FrameworkAdapter] = &[
&super::adapters::CryptoPhpAdapter,
&super::adapters::HeaderPhpAdapter,
&super::adapters::LdapPhpAdapter,
&super::adapters::MiddlewareLaravelAdapter,
@ -132,6 +133,8 @@ static PYTHON: &[&dyn FrameworkAdapter] = &[
&super::adapters::XxePythonAdapter,
];
static RUBY: &[&dyn FrameworkAdapter] = &[
&super::adapters::CryptoRubyAdapter,
&super::adapters::DataExfilRubyAdapter,
&super::adapters::HeaderRubyAdapter,
&super::adapters::MiddlewareRailsAdapter,
&super::adapters::MigrationRailsAdapter,

View file

@ -12,7 +12,7 @@ import (
func RunPing(host string) {
fmt.Print("__NYX_SINK_HIT__\n")
cmd := exec.Command("sh", "-c", "echo hello "+host)
cmd := exec.Command("/bin/sh", "-c", "/bin/echo hello "+host)
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
}

View file

@ -1,14 +1,22 @@
<?php
// Command injection — negative fixture.
// Safe: uses escapeshellarg() to prevent shell injection.
// Safe: uses escapeshellarg() and discards the captured output.
// Entry: runPing($host) Cap: CODE_EXEC
// Expected verdict: NotConfirmed
//
// Pairs with cmdi_positive.php for differential confirmation. The
// captured output is intentionally discarded so a literal payload such
// as "; echo NYX_PWN_CMDI" (which escapeshellarg already neutralises
// against the shell parser) cannot leak through stdout and trip the
// OutputContains oracle.
function runPing($host) {
// Sink-reachability probe: we did reach the shell_exec call site.
echo "__NYX_SINK_HIT__\n";
// escapeshellarg wraps in single quotes and escapes internal quotes.
$safe = escapeshellarg($host);
// Capture but discard: command runs under the sanitiser, output is
// not surfaced to the oracle.
$output = shell_exec('echo hello ' . $safe);
if ($output !== null) {
echo $output;
}
unset($output);
}

View file

@ -3,9 +3,9 @@
// Prints "root:" unconditionally without reading any file
// and without emitting __NYX_SINK_HIT__.
// Expected verdict: Inconclusive(OracleCollisionSuspected)
// Entry: readFile($userPath) Cap: FILE_IO
// Entry: runReadFile($userPath) Cap: FILE_IO
function readFile($userPath) {
function runReadFile($userPath) {
// Coincidental oracle match — not a file read sink.
echo "root: present\n";
$x = strlen($userPath);

View file

@ -1,10 +1,10 @@
<?php
// File I/O — negative fixture.
// Safe: realpath + prefix validation prevents directory traversal.
// Entry: readFile($userPath) Cap: FILE_IO
// Entry: runReadFile($userPath) Cap: FILE_IO
// Expected verdict: NotConfirmed
function readFile($userPath) {
function runReadFile($userPath) {
$baseDir = '/var/data';
$filePath = realpath($baseDir . '/' . $userPath);
if ($filePath === false || strpos($filePath, $baseDir . DIRECTORY_SEPARATOR) !== 0) {

View file

@ -1,10 +1,10 @@
<?php
// File I/O — positive fixture.
// Vulnerable: reads file at user-controlled path without sanitization.
// Entry: readFile($userPath) Cap: FILE_IO
// Entry: runReadFile($userPath) Cap: FILE_IO
// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output)
function readFile($userPath) {
function runReadFile($userPath) {
$filePath = '/var/data/' . $userPath;
echo "__NYX_SINK_HIT__\n";
$content = @file_get_contents($filePath);

View file

@ -214,7 +214,7 @@ mod php_fixture_tests {
#[test]
fn php_fileio_positive_is_confirmed() {
let result = run_fixture("fileio_positive.php", "readFile", Cap::FILE_IO, 9);
let result = run_fixture("fileio_positive.php", "runReadFile", Cap::FILE_IO, 9);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
@ -231,7 +231,7 @@ mod php_fixture_tests {
#[test]
fn php_fileio_negative_is_not_confirmed() {
let result = run_fixture("fileio_negative.php", "readFile", Cap::FILE_IO, 14);
let result = run_fixture("fileio_negative.php", "runReadFile", Cap::FILE_IO, 14);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{
@ -247,7 +247,7 @@ mod php_fixture_tests {
#[test]
fn php_fileio_adversarial_is_oracle_collision() {
let result = run_fixture("fileio_adversarial.php", "readFile", Cap::FILE_IO, 999);
let result = run_fixture("fileio_adversarial.php", "runReadFile", Cap::FILE_IO, 999);
if result.status == VerifyStatus::Unsupported
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
{