use tree_sitter::Node;
use crate::security::detect::bash::ast::get_command_name;
use crate::security::detect::bash::utils::{
best_hit, cluster_has_flag, collect_args, command_basename, unwrap_command,
};
use crate::sec_bash_detector_rule_metadata;
use crate::security::detect::utils::shell_is_unix;
use crate::security::detect::{EvaluateResult, Rule, Severity, ShellContext};
const BIND_TOOLS: &[&str] = &["nc", "netcat", "ncat", "socat"];
pub struct RuleBindShell;
impl RuleBindShell {
sec_bash_detector_rule_metadata!(
"bash_bind_shell",
"Detects bind shells and network listeners (nc/ncat/socat) used to open \
remote access to the host",
Severity::Low
);
}
fn is_shell_target(prog: &str) -> bool {
let first = prog
.split(|c: char| c.is_whitespace() || c == ',')
.next()
.unwrap_or(prog);
shell_is_unix(first)
}
fn analyze_nc(raw_cmd: &str, args: &[&str]) -> Option<(Severity, String)> {
let mut listen = false;
let mut exec: Option<&str> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--listen" || a == "--listen-port" || a == "--keep-open" {
listen = true;
} else if a.starts_with('-') && a.len() > 1 {
if a.contains('l') || a.contains('L') {
listen = true;
}
if (a == "-c"
|| a == "--exec"
|| a == "--sh-exec"
|| a == "--exec-cmd"
|| cluster_has_flag(a, 'e'))
&& let Some(next) = args.get(i + 1)
{
exec = Some(next);
i += 1;
}
}
i += 1;
}
if let Some(ex) = exec {
let sev = if is_shell_target(ex) {
Severity::Critical
} else {
Severity::High
};
return Some((
sev,
format!("command={:?} bind/exec: exec={:?}", raw_cmd, ex),
));
}
if listen {
return Some((
Severity::High,
format!("command={:?} listener: args={:?}", raw_cmd, args),
));
}
None
}
fn analyze_socat(raw_cmd: &str, args: &[&str]) -> Option<(Severity, String)> {
let mut listen = false;
let mut exec: Option<&str> = None;
for a in args {
if a.to_uppercase().contains("LISTEN:") {
listen = true;
}
if let Some(rest) = a.strip_prefix("EXEC:").or_else(|| a.strip_prefix("SYSTEM:")) {
exec = Some(rest);
}
}
if let Some(ex) = exec {
let sev = if is_shell_target(ex) {
Severity::Critical
} else {
Severity::High
};
return Some((
sev,
format!("command={:?} bind/exec: exec={:?}", raw_cmd, ex),
));
}
if listen {
return Some((
Severity::High,
format!("command={:?} listener: args={:?}", raw_cmd, args),
));
}
None
}
fn analyze_command(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
let raw_cmd = get_command_name(node, source)?;
let cmd_name = command_basename(raw_cmd);
let args = collect_args(node, source);
let (real_cmd, real_args) = unwrap_command(&cmd_name, &args, BIND_TOOLS)?;
if !BIND_TOOLS.contains(&real_cmd) {
return None;
}
match real_cmd {
"nc" | "netcat" | "ncat" => analyze_nc(raw_cmd, real_args),
"socat" => analyze_socat(raw_cmd, real_args),
_ => None,
}
}
fn analyze(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
match node.kind() {
"command" => analyze_command(node, source),
_ => None,
}
}
#[async_trait::async_trait]
impl Rule for RuleBindShell {
fn meta(&self) -> &crate::security::detect::RuleMetadata {
Self::get_meta()
}
async fn evaluate(
&self,
_data: &str,
ctx: &ShellContext,
) -> anyhow::Result<EvaluateResult> {
let best = best_hit(ctx, analyze).await?;
Ok(match best {
Some((sev, evidence)) => EvaluateResult::hit_with_severity(evidence, sev),
None => EvaluateResult::Miss,
})
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use super::*;
use crate::security::detect::bash::BashDetector;
use crate::security::detect::{DetectResult, Detector, ShellContext};
fn get_detector() -> BashDetector {
let ctx = ShellContext::new("/bin/bash", HashMap::new(), 100);
BashDetector::new(ctx, 4096)
}
async fn expect_hit(detector: &BashDetector, payload: &str, expected: Severity) {
let res = detector.detect(payload.to_string(), false, true).await;
match &res {
DetectResult::ThreatDetected(hits) => {
let hit = hits
.iter()
.find(|h| h.rule_meta.name == "bash_bind_shell")
.unwrap_or_else(|| {
panic!("payload {:?} did not trigger bash_bind_shell: {:#?}", payload, res)
});
assert_eq!(
hit.final_severity, expected,
"payload {:?}, evidence {:?}",
payload, hit.evidence
);
}
_ => panic!("payload {:?} expected ThreatDetected, got {:#?}", payload, res),
}
}
async fn expect_safe(detector: &BashDetector, payload: &str) {
let res = detector.detect(payload.to_string(), false, true).await;
let hit = match &res {
DetectResult::ThreatDetected(hits) => {
hits.iter().find(|h| h.rule_meta.name == "bash_bind_shell")
}
_ => None,
};
assert!(
hit.is_none(),
"payload {:?} should not trigger bash_bind_shell, got {:#?}",
payload, res
);
}
#[tokio::test]
async fn test_bind_shell_exec_critical() {
let d = get_detector();
expect_hit(&d, "nc -lvnp 4444 -e /bin/bash", Severity::Critical).await;
expect_hit(&d, "nc -l 4444 -c /bin/sh", Severity::Critical).await;
expect_hit(&d, "ncat -lvnp 4444 --exec /bin/bash", Severity::Critical).await;
expect_hit(&d, "socat TCP-LISTEN:4444 EXEC:/bin/sh", Severity::Critical).await;
expect_hit(&d, "socat TCP-LISTEN:4444 SYSTEM:bash", Severity::Critical).await;
expect_hit(&d, "sudo nc -lvnp 4444 -e /bin/sh", Severity::Critical).await;
}
#[tokio::test]
async fn test_bind_listener_high() {
let d = get_detector();
expect_hit(&d, "nc -lvnp 4444", Severity::High).await;
expect_hit(&d, "nc -l 1234", Severity::High).await;
expect_hit(&d, "socat TCP-LISTEN:8080,fork TCP:127.0.0.1:80", Severity::High).await;
expect_hit(&d, "nc -lvnp 4444 -e /bin/ls", Severity::High).await;
}
#[tokio::test]
async fn test_bind_safe() {
let d = get_detector();
expect_safe(&d, "nc -z -v 10.0.0.1 22").await;
expect_safe(&d, "nc 10.0.0.1 80").await;
expect_safe(&d, "curl http://example.com").await;
expect_safe(&d, "echo 'nc -l 4444'").await;
}
}