use regex::Regex;
use std::sync::OnceLock;
const MAX_DEPTH: usize = 3;
const MAX_VIEWS: usize = 24;
const INTERPRETERS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh"];
const SEPARATORS: &[&str] = &["|", "||", "&&", ";", ";;", "&", "|&", "(", "{"];
const PRINT_LEADS: &[&str] = &["echo", "printf", ":"];
const MAX_TOKENS: usize = 64;
fn read_word(s: &str) -> Option<(String, &str)> {
let s = s.trim_start();
let first = s.chars().next()?;
if first == '\'' || first == '"' {
let rest = &s[first.len_utf8()..];
match rest.find(first) {
Some(end) => Some((rest[..end].to_string(), &rest[end + first.len_utf8()..])),
None => Some((rest.to_string(), "")),
}
} else {
let end = s.find(char::is_whitespace).unwrap_or(s.len());
Some((s[..end].to_string(), &s[end..]))
}
}
fn tokenize(cmd: &str) -> Vec<String> {
let mut toks = Vec::new();
let mut rest = cmd;
while let Some((w, next)) = read_word(rest) {
toks.push(w);
if toks.len() >= MAX_TOKENS || next.len() >= rest.len() {
break; }
rest = next;
}
toks
}
fn extract_exec_payloads(cmd: &str) -> Vec<String> {
let mut out = Vec::new();
for line in cmd.split(['\n', '\r']) {
extract_line_payloads(line, &mut out);
}
out
}
fn extract_line_payloads(cmd: &str, out: &mut Vec<String>) {
let toks = tokenize(cmd);
let mut group_lead: Option<&str> = None;
for (i, t) in toks.iter().enumerate() {
if is_separator(t) {
group_lead = None;
continue;
}
if group_lead.is_none() && !is_env_assignment(t) {
group_lead = Some(t);
}
if group_lead.is_some_and(|v| PRINT_LEADS.contains(&v)) {
continue; }
if t == "eval" {
if let Some(p) = toks.get(i + 1) {
if !p.is_empty() {
out.push(p.clone());
}
}
}
if INTERPRETERS.contains(&t.as_str()) {
if let Some(pos) = toks[i + 1..].iter().position(|x| is_dash_c_flag(x)) {
let crossed = toks[i + 1..i + 1 + pos].iter().any(|x| is_separator(x));
if !crossed {
if let Some(p) = toks.get(i + 1 + pos + 1) {
if !p.is_empty() {
out.push(p.clone());
}
}
}
}
}
}
}
fn is_separator(tok: &str) -> bool {
SEPARATORS.contains(&tok)
}
fn is_dash_c_flag(tok: &str) -> bool {
tok.starts_with('-')
&& !tok.starts_with("--")
&& tok.len() >= 2
&& tok[1..].chars().all(|c| c.is_ascii_alphabetic())
&& tok.contains('c')
}
fn is_env_assignment(tok: &str) -> bool {
match tok.find('=') {
Some(eq) if eq > 0 => {
let name = &tok[..eq];
name.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
}
}
const MAX_DECODES: usize = 6;
fn b64_decode(s: &str) -> Option<String> {
fn val(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some((c - b'A') as u32),
b'a'..=b'z' => Some((c - b'a' + 26) as u32),
b'0'..=b'9' => Some((c - b'0' + 52) as u32),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut out = Vec::new();
let mut buf = 0u32;
let mut bits = 0u32;
let mut n = 0usize;
for c in s.bytes().filter(|&b| b != b'=') {
buf = (buf << 6) | val(c)?;
bits += 6;
n += 1;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
if n < 2 {
return None;
}
String::from_utf8(out).ok()
}
fn decode_base64_payloads(cmd: &str) -> Vec<String> {
static DECODE: OnceLock<Regex> = OnceLock::new();
let decode =
DECODE.get_or_init(|| Regex::new(r"(?i)\bbase64\b[^|\n]*\s(?:-d|-D|--decode)\b").unwrap());
if !decode.is_match(cmd) {
return Vec::new();
}
static TOKEN: OnceLock<Regex> = OnceLock::new();
let token = TOKEN.get_or_init(|| Regex::new(r"[A-Za-z0-9+/]{8,}={0,2}").unwrap());
let mut out = Vec::new();
for m in token.find_iter(cmd).take(MAX_DECODES) {
if let Some(decoded) = b64_decode(m.as_str()) {
out.push(decoded);
}
}
out
}
pub fn command_views(cmd: &str) -> Vec<String> {
let mut views: Vec<String> = Vec::new();
let mut queue: Vec<(String, usize)> = vec![(cmd.to_string(), 0)];
while let Some((c, depth)) = queue.pop() {
if views.len() >= MAX_VIEWS {
break;
}
if views.iter().any(|v| v == &c) {
continue;
}
views.push(c.clone());
if depth >= MAX_DEPTH {
continue;
}
let normalized = super::normalize_for_match(&c);
if normalized != c {
queue.push((normalized, depth + 1));
}
for payload in extract_exec_payloads(&c) {
queue.push((payload, depth + 1));
}
for decoded in decode_base64_payloads(&c) {
queue.push((decoded, depth + 1));
}
}
views
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_line_is_always_view_zero() {
let v = command_views("rm -rf /");
assert_eq!(v[0], "rm -rf /");
}
#[test]
fn deduplicates_identical_views() {
let v = command_views("ls");
assert_eq!(v.iter().filter(|s| *s == "ls").count(), 1);
}
#[test]
fn reads_single_quoted_word() {
let (w, rest) = read_word("'rm -rf /' next").unwrap();
assert_eq!(w, "rm -rf /");
assert_eq!(rest.trim_start(), "next");
}
#[test]
fn reads_bare_word() {
let (w, rest) = read_word("bash -c x").unwrap();
assert_eq!(w, "bash");
assert_eq!(rest.trim_start(), "-c x");
}
#[test]
fn extracts_shell_dash_c_payload() {
let p = extract_exec_payloads("bash -c 'rm -rf /'");
assert!(p.contains(&"rm -rf /".to_string()), "got {p:?}");
}
#[test]
fn extracts_eval_payload() {
let p = extract_exec_payloads("eval \"rm -rf /\"");
assert!(p.contains(&"rm -rf /".to_string()), "got {p:?}");
}
#[test]
fn echo_is_not_an_interpreter() {
let p = extract_exec_payloads("echo \"rm -rf /\"");
assert!(p.is_empty(), "echo must not be unwrapped: {p:?}");
}
#[test]
fn unwrapped_payload_becomes_a_view() {
let v = command_views("bash -c 'rm -rf /'");
assert!(v.contains(&"rm -rf /".to_string()), "views: {v:?}");
}
#[test]
fn print_led_interpreter_mention_is_not_extracted() {
assert!(extract_exec_payloads("echo bash -c 'rm -rf /'").is_empty());
assert!(extract_exec_payloads("echo eval 'rm -rf /'").is_empty());
assert!(extract_exec_payloads("printf '%s' bash -c 'rm -rf /'").is_empty());
}
#[test]
fn interpreter_after_a_pipe_is_extracted() {
let p = extract_exec_payloads("echo foo | bash -c 'rm -rf /'");
assert!(p.contains(&"rm -rf /".to_string()), "got {p:?}");
}
#[test]
fn wrapper_prefixed_interpreter_is_extracted() {
for cmd in [
"sudo bash -c 'rm -rf /'",
"env bash -c 'rm -rf /'",
"timeout 5 bash -c 'rm -rf /'",
"FOO=bar bash -c 'rm -rf /'",
] {
let p = extract_exec_payloads(cmd);
assert!(p.contains(&"rm -rf /".to_string()), "{cmd} -> {p:?}");
}
}
#[test]
fn interpreter_on_a_later_line_is_extracted() {
let p = extract_exec_payloads("echo hi\nbash -c 'rm -rf /'");
assert!(p.contains(&"rm -rf /".to_string()), "got {p:?}");
}
#[test]
fn dash_c_scan_does_not_cross_a_newline() {
let p = extract_exec_payloads("sh\nfoo -c 'rm -rf /'");
assert!(
!p.contains(&"rm -rf /".to_string()),
"must not cross line: {p:?}"
);
}
#[test]
fn bundled_dash_c_flags_are_extracted() {
for cmd in ["bash -xc 'rm -rf /'", "bash -cx 'rm -rf /'"] {
let p = extract_exec_payloads(cmd);
assert!(p.contains(&"rm -rf /".to_string()), "{cmd} -> {p:?}");
}
}
#[test]
fn is_dash_c_flag_recognizes_bundles_only() {
assert!(is_dash_c_flag("-c"));
assert!(is_dash_c_flag("-xc"));
assert!(is_dash_c_flag("-cx"));
assert!(!is_dash_c_flag("--command")); assert!(!is_dash_c_flag("-x")); assert!(!is_dash_c_flag("-c=x")); assert!(!is_dash_c_flag("c")); }
#[test]
fn obfuscated_interpreter_verb_is_unwrapped() {
for cmd in [
r"\bash -c 'rm -rf /'",
"b''ash -c 'rm -rf /'",
"e''val 'rm -rf /'",
] {
let v = command_views(cmd);
assert!(v.contains(&"rm -rf /".to_string()), "{cmd} -> views {v:?}");
}
}
#[test]
fn decodes_standard_base64() {
assert_eq!(b64_decode("cm0gLXJmIC8=").as_deref(), Some("rm -rf /"));
}
#[test]
fn base64_only_decoded_when_decode_flag_present() {
assert!(decode_base64_payloads("echo cm0gLXJmIC8=").is_empty());
let p = decode_base64_payloads("echo cm0gLXJmIC8= | base64 -d | sh");
assert!(p.contains(&"rm -rf /".to_string()), "got {p:?}");
}
#[test]
fn encoded_payload_becomes_a_view() {
let v = command_views("echo cm0gLXJmIC8= | base64 -d | sh");
assert!(v.contains(&"rm -rf /".to_string()), "views: {v:?}");
}
use proptest::prelude::*;
proptest! {
#[test]
fn command_views_never_panics(s in "[\\s\\S]{0,300}") {
let v = command_views(&s);
prop_assert!(v.len() <= super::MAX_VIEWS);
}
}
}