Shell Injection in Rust Without Ever Calling a Shell
"No shell in my process" and "no shell anywhere in the chain" are different claims, and the gap between them is where the bugs live. The usual advice about running external programs is "never build a command string from user input". Rust's std::process::Command makes that advice mostly automatic, and that is exactly why people relax around it. The type is good. The chain is the problem.
First, the good news, so we know what we are working with.
use std::process::Command;
fn main() -> std::io::Result<()> {
let name = "notes.txt; rm -rf ~";
let out = Command::new("ls").arg("-l").arg(name).output()?;
eprintln!("{}", String::from_utf8_lossy(&out.stderr));
Ok(())
}
Command skips the shell entirely
On Unix, Command forks and execs the program directly with an argument vector. There is no sh in between, so the semicolon is just a character in a filename. ls will complain that it cannot access a file with a silly name, and your home directory survives.
Each .arg() is one argv entry, whatever it contains. The only thing it refuses is an interior NUL byte, which makes spawn return an InvalidInput error, because C strings cannot carry one.
So the shell injection you write is the one where you go and fetch a shell yourself.
The one you wrote on purpose
// Don't.
let cmd = format!("ls -l {}", name);
Command::new("sh").arg("-c").arg(cmd).status()?;
Now the string is parsed by sh, and the semicolon is a command separator again. You have rebuilt the classic bug in a memory-safe language, which is a special sort of achievement.
If you genuinely need a shell (a pipeline, say), pass the data as positional parameters rather than splicing it into the script:
Command::new("sh")
.arg("-c")
.arg(r#"ls -l -- "$1" | wc -l"#)
.arg("sh") // becomes $0
.arg(name) // becomes $1, never parsed as shell syntax
.status()?;
I checked that behaviour with a hostile value of $(id); x, and $1 came through as those literal characters. The script text is a constant, so there is nothing for an attacker to influence.
Often you can skip the shell altogether and connect two Commands with Stdio::piped(), but the positional form is fine when the pipeline is short.
The one the program writes for you: option injection
Here is the more common problem, and it does not need a shell at all. Your argument arrives intact as one argv entry, and then the program you launched decides it is an option.
use std::io;
use std::process::{Command, Output};
fn diff_against(rev: &str) -> io::Result<Output> {
Command::new("git").args(["diff", rev]).output()
}
If rev is --output=/some/path, git obligingly writes the diff to that path, truncating whatever was there. I ran exactly this on git 2.43 and got an empty file where I pointed it. No metacharacters, no shell, and the file write is entirely legitimate as far as Command is concerned.
Other tools have far worse options. Anything with an "execute this" flag (tar, find, rsync, many others) turns argument injection into code execution.
The fixes, roughly in order of preference:
- Use the tool's end-of-options marker. Most Unix tools honour
--. Git's revision parsing wants--end-of-optionsinstead, which is in reasonably recent versions; with it my hostile value was rejected with a fatal error rather than obeyed. - Reject values that start with
-before you get nearCommand. Crude, but cheap and it works on tools that lack a marker. - Better still, validate against what the value should be: a revision matching a known pattern, a path you have canonicalised and checked, an integer you have parsed.
- Prefix relative paths with
./so a file called-rfcannot masquerade as a flag.
fn diff_against(rev: &str) -> io::Result<Output> {
if rev.starts_with('-') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "bad revision"));
}
Command::new("git")
.args(["diff", "--end-of-options", rev])
.output()
}
Belt and braces, yes, but neither half is doing much work on its own.
ssh: the shell you forgot was there
Actually, this bit is the one I find most annoying, because the Rust code looks perfectly innocent.
Command::new("ssh").arg("backup-host").arg("ls").arg(dir).status()?;
You passed three separate arguments, so surely they stay separate? They do not. The ssh client joins everything after the hostname into one string with spaces and sends it to the remote side, where the user's login shell parses it.
A dir of x; touch /tmp/hello runs touch on the remote machine. Command did its job perfectly; the boundary you thought you had was flattened one process later.
The fix is to quote for the remote shell yourself, which is the one place where hand-rolled quoting is defensible:
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
let remote = format!("ls -- {}", sh_quote(dir));
Command::new("ssh").arg("backup-host").arg(remote).status()?;
Single quotes make everything literal in a POSIX shell except the single quote itself, so that gets closed, escaped and reopened. This assumes the remote login shell is POSIX-ish; fish and csh have different rules, and I would not want to promise anything for them.
Where you can, avoid the problem: send the data over stdin, or use a forced command on the remote end that takes no free-form arguments.
The same flattening happens with su -c, docker exec ... sh -c, xargs and anything else whose job is to run another command line. If a tool's documentation says "command" and not "argv", assume a shell is involved.
Windows does it differently, and worse
On Windows there is no argv at the OS level. A process receives a single command-line string, and each program splits it however it likes. Command builds that string for you using the usual MSVCRT quoting rules, which is fine for programs that split it that way.
Batch files are the exception
Running a .bat or .cmd file goes through cmd.exe, which has its own, much stranger parsing rules. In 2024 this was disclosed as CVE-2024-24576 (often called "BatBadBut"). The standard library did not escape arguments correctly for batch files, so a crafted argument could inject commands. It was fixed in Rust 1.77.2.
As I understand the fix, std now escapes for cmd.exe and returns an InvalidInput error when it cannot do so safely, but I would not lean on that for untrusted input. Read the Rust security announcement for the exact wording rather than trusting my summary.
Two practical points:
- Do not pass untrusted arguments to batch files, full stop; call the real executable.
- Treat
CommandExt::raw_argas an escape hatch that turns off all of the quoting protection, because that is what it is for.
Small things worth knowing
Commandinherits the parent's environment. If you run as something privileged, or you launch git,env_clear()and set only what the child needs (an explicitPATHincluded), since variables likeLD_PRELOADandGIT_*change what the child does.- Prefer an absolute path to the program when it is not user-influenced.
Command::new("git")searchesPATH, and that is only as trustworthy as the environment you started in. - Prefer a library over a subprocess when one exists. Every subprocess is a second parser of your data.
Ask what happens to your argument after exec
The habit that helps is to ask that question for each external program. Three answers cover most cases:
- It is data: fine.
- It is an option: add a terminator or validate.
- It is going to be parsed as a command line by something else: quote it, or stop.