flawopen.com/command-injection/Rust
OS Command Injection in Rust (std::process::Command)
Wrapping input in Command::new("sh").arg("-c"), passing it to a Windows .bat file on a toolchain older than Rust 1.77.2 (CVE-2024-24576), or passing a value that starts with a dash lets a request run code or add options on your Rust server (CWE-78). Why Command is safe by default, where that stops, and how to bound a child process with a deadline.
Think of a train ticket with separate boxes for route and passenger name. Rust Command::new(...).arg() puts each value in its own box, so punctuation in a name does not add a stop. Command::new("sh").arg("-c") is different: it hands a free-form instruction sheet to /bin/sh, which reads separators as commands. Windows .bat files were a special exception before Rust 1.77.2: cmd.exe reparsed arguments, the flaw tracked as CVE-2024-24576. And a value beginning with - can still be read as a Git option. Fix the executable in code, allowlist values, add --end-of-options, and set a child-process deadline. For a Git revision, git rev-parse --verify --end-of-options "$revision" ends option parsing; -- has separate, command-specific operand or path semantics.
Core Concepts & Subsystem Terms
std::process::Command- Starts a program directly: each
.arg()or.args()element becomes one argument and no shell is involved, so;,|and$( )inside a value are plain characters. Command::new("sh").arg("-c")- Explicitly starting a shell hands the formatted string to
/bin/sh(orcmd /Con Windows), which parses separators and substitutions.format!with request data then lets the data add commands. CVE-2024-24576 (BatBadBut)- On Windows, running a
.bator.cmdfile goes throughcmd.exe, which parses arguments with its own rules. Before Rust 1.77.2 the standard library escaped them incorrectly, so an argument could run commands (CVSS 10.0). Since 1.77.2,spawnreturns anInvalidInputerror when it cannot quote an argument safely. Argument injection and Git option boundaries- Separate argv entries prevent shell parsing, but the target program still parses its own options. For an untrusted Git revision, use
git rev-parse --verify --end-of-options "$revision": this ends option parsing.--has command-specific operand or path semantics; ingit log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use. Child::try_wait and Child::kill- The standard library has no wait-with-timeout. Poll
try_waitagainst a deadline and callkillwhen it passes, and read stdout on a separate thread so a full pipe cannot stall the child.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-78
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Request value reaches the helper
An Axum or Actix handler extracts host, name or git_ref from Query<...> and passes the &str to a function that starts a process.
The value becomes part of a command
format!("ping -c 1 {host}") is handed to sh -c, name is passed to a batch file that cmd.exe parses, or a value that looks like an option is passed to git. The benign test value 127.0.0.1; id illustrates the shell case.
The shell or the program interprets it
sh runs ping and then id; cmd.exe treats & or " in name as syntax on an unpatched toolchain; Git treats --output=... as an option.
The attacker runs code on the server
The process runs as the service account: it can read environment variables and mounted secrets, reach internal services and write files.
Source Code: Flaw vs. Secure Implementation
// tools.rs: each function lets request data change what runs
use std::io;
use std::process::{Command, Output};
// sh -c parses the formatted string, ; and all
pub fn ping(host: &str) -> io::Result<Output> {
Command::new("sh").arg("-c").arg(format!("ping -c 1 {host}")).output()
}
// Windows batch file: cmd.exe re-parses name (CVE-2024-24576 before 1.77.2)
pub fn run_report(name: &str) -> io::Result<Output> {
Command::new("scripts\\report.bat").arg(name).output()
}
// no shell, but a ref starting with - is read as a Git option
pub fn commit_log(git_ref: &str) -> io::Result<Output> {
Command::new("git").args(["log", git_ref]).output()
}
// tools.rs: separate arguments, allow-lists and a 5-second deadline
use std::io::{self, Read};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
// no shell; stdout read on a thread; kill the child at the deadline
fn run(program: &str, args: &[&str]) -> io::Result<String> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let mut stdout = child.stdout.take().expect("stdout is piped");
let reader = thread::spawn(move || {
let mut out = String::new();
stdout.read_to_string(&mut out).map(|_| out)
});
let deadline = Instant::now() + Duration::from_secs(5);
while child.try_wait()?.is_none() {
if Instant::now() >= deadline {
child.kill()?;
child.wait()?;
return Err(io::Error::new(io::ErrorKind::TimedOut, "command timed out"));
}
thread::sleep(Duration::from_millis(50));
}
reader.join().expect("reader thread panicked")
}
fn allowed(value: &str, max_len: usize, extra: &str) -> bool {
(1..=max_len).contains(&value.len())
&& value.starts_with(|c: char| c.is_ascii_alphanumeric())
&& value.chars().all(|c| c.is_ascii_alphanumeric() || extra.contains(c))
}
fn invalid(what: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, format!("invalid {what}"))
}
// ping runs directly; host is one validated argument
pub fn ping(host: &str) -> io::Result<String> {
if !allowed(host, 253, ".-") {
return Err(invalid("host name"));
}
run("ping", &["-c", "1", host])
}
// letters, digits and _ only; Rust 1.77.2+ also rejects unsafe arguments
pub fn run_report(name: &str) -> io::Result<String> {
if !allowed(name, 64, "_") {
return Err(invalid("report name"));
}
run("scripts\\report.bat", &[name])
}
// --end-of-options makes Git treat git_ref as a revision
pub fn commit_log(git_ref: &str) -> io::Result<String> {
if !allowed(git_ref, 100, "._/-") {
return Err(invalid("ref"));
}
run("git", &["log", "--end-of-options", git_ref, "--"])
}
Engineering & System Hardening Checklist
- ✓Add a Semgrep or CI grep rule that flags
Command::newwith"sh","bash","cmd"or"powershell"and any.arg(format!(...))built from request data. - ✓Set
rust-version = "1.77.2"or later inCargo.tomland build with a patched toolchain so batch-file arguments are escaped or rejected. - ✓Reject option-like values by default. For an untrusted Git revision, call
git rev-parse --verify --end-of-options "$revision"; use--only with the operand or path meaning documented by that exact command. - ✓Give each child a deadline: poll
try_wait, callkillandwaiton timeout, and read its output on a separate thread. - ✓Run the service as an unprivileged user in a container image without a shell where possible.
Sources
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Rust std — std::process::Command
- Rust Security Response WG — CVE-2024-24576
- Cargo Book — the rust-version field
- Git — git rev-parse --end-of-options
- OWASP OS Command Injection Defense Cheat Sheet
- POSIX Utility Syntax Guidelines
- Git — Command-Line Interface Conventions
- Git — git log