VNX-RUST-004 – Rust Command Injection via process::Command
Overview
This rule detects Rust code that constructs a std::process::Command using the format! macro to build the command string, or that invokes a shell interpreter (sh -c) through Command. When user-controlled input is interpolated into the command string, an attacker can inject shell metacharacters (;, |, &&, $(...)) to execute arbitrary commands on the host system.
Severity: High | CWE: CWE-78 – Improper Neutralization of Special Elements used in an OS Command
Why This Matters
Command injection is consistently ranked among the most critical application vulnerabilities:
- Remote Code Execution (RCE): An attacker can run any command the application’s user can — read files, install backdoors, pivot to other systems
- Rust doesn’t protect you automatically: Unlike SQL parameterization, Rust’s type system doesn’t prevent you from building command strings unsafely
- Shell invocation amplifies risk: Passing arguments through
sh -cexposes the full shell metacharacter surface (pipes, redirections, command substitution) - Exploitation is trivial: If user input flows into a command string, the attacker simply includes
;malicious_commandin their input
What Gets Flagged
A shell given a non-literal command string (the finding is on the Command::new line, whether the builder is chained on one line or split across lines):
Command::new("sh").arg("-c").arg(format!("ping -c 1 {}", host)).output() // FLAGGED
let line = format!("convert {} {}", input, output);
let _ = Command::new("sh").args(["-c", &line]).output(); // FLAGGED
let _ = Command::new("cmd").args(&["/C", &format!("copy {} backup\\", src)]); // FLAGGED
Command::new("powershell.exe") // FLAGGED
.args(["-NoProfile", "-Command", script])
let child = tokio::process::Command::new("sh") // FLAGGED
.arg("-c")
.arg(&script)
.spawn();
A program taken from the request:
pub async fn run_tool(Query(params): Query<Tool>) -> String {
let out = Command::new(¶ms.name).output().unwrap(); // FLAGGED
Not flagged: Command::new("sh").arg("-c").arg("sync") or .arg(BOOTSTRAP) (a constant), a value quoted with shlex::try_quote or shell_escape::escape before it is interpolated, Command::new("sh").arg("deploy.sh").arg(env) (a script file with arguments), and Command::new("git").arg("checkout").arg(branch) (no shell parses the argument).
Remediation
Pass user input as separate arguments, never as part of the command string.
Command::newwith.arg()does not invoke a shell and does not interpret metacharacters:use std::process::Command; // Safe: user_query is passed as a separate argument let output = Command::new("grep") .arg(&user_query) // Treated as a literal string, not shell-parsed .arg("/var/log/app.log") .output()?;Avoid shell invocation entirely. Do not use
sh -corbash -cunless absolutely necessary:// Instead of: Command::new("sh").arg("-c").arg(format!("wc -l {}", path)) // Use: let output = Command::new("wc") .arg("-l") .arg(&path) // Safe: no shell interpretation .output()?;Validate and sanitize input if it must form part of a command. Use an allowlist of acceptable characters:
fn is_safe_filename(name: &str) -> bool { name.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') } if !is_safe_filename(&user_input) { return Err(anyhow!("Invalid filename")); }Use Rust-native libraries instead of shelling out. For common operations, prefer crates:
// Instead of shelling out to `find`: use walkdir::WalkDir; for entry in WalkDir::new("/data").into_iter().filter_map(|e| e.ok()) { if entry.file_name().to_str() == Some(&target) { // found } }If you must use format! with Command, ensure the interpolated values are from trusted sources (constants, configuration files you control, enum variants) — never from user input, environment variables, or external APIs.