Why command injection still matters in Rust

Command injection happens when untrusted input changes the meaning of a command. A classic example is building a shell string like:

sh -c "grep <user_input> /var/log/app.log"

If <user_input> contains shell metacharacters, the shell may interpret them as additional commands. Rust does not prevent this because the vulnerability is not about memory corruption; it is about unsafe process construction.

This issue appears in:

  • admin tools that wrap system utilities
  • web backends that call git, ffmpeg, convert, or tar
  • automation scripts that process user-supplied filenames or filters
  • desktop apps that launch helper binaries

The safest approach is to avoid the shell entirely and pass arguments directly to a program.


The core rule: do not invoke a shell unless you must

Rust’s std::process::Command is designed to launch programs without shell parsing. That means each argument is passed as a separate value, not concatenated into a command line string.

Unsafe pattern: shell string construction

The following pattern is risky because it delegates parsing to a shell:

use std::process::Command;

fn search_logs(user_query: &str) {
    let cmd = format!("grep {} /var/log/app.log", user_query);
    let _ = Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .status();
}

If user_query is error; rm -rf /, the shell may execute the second command.

Safe pattern: direct argument passing

use std::process::Command;

fn search_logs(user_query: &str) -> std::io::Result<String> {
    let output = Command::new("grep")
        .arg(user_query)
        .arg("/var/log/app.log")
        .output()?;

    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

Here, grep receives the query as a literal argument. Characters like ;, &&, |, and $() lose their special meaning because no shell is involved.


Prefer structured arguments over formatted strings

A common mistake is to build a single string and then split it later. This is fragile and can still create injection opportunities.

Bad: string concatenation

let pattern = format!("--pattern={}", user_input);
Command::new("tool").arg(pattern);

This may be acceptable if tool expects a single argument, but it is often better to separate the option name and value:

Command::new("tool")
    .arg("--pattern")
    .arg(user_input);

This makes the intent explicit and avoids ambiguity if the value begins with - or contains whitespace.

Better: use typed configuration

If your application has multiple process options, model them as structured data rather than raw strings:

struct SearchJob<'a> {
    query: &'a str,
    path: &'a str,
    case_sensitive: bool,
}

Then map each field to a specific command argument. This reduces the chance of accidentally feeding untrusted input into a shell-like interface.


Validate inputs before launching a process

Even when you avoid the shell, you should still validate user input. Safe argument passing prevents command injection, but it does not guarantee that the target program will behave safely with arbitrary values.

Validate by allowlist

The best validation strategy is usually an allowlist. For example, if your tool accepts a log level, only permit known values:

fn parse_level(input: &str) -> Option<&'static str> {
    match input {
        "debug" => Some("debug"),
        "info" => Some("info"),
        "warn" => Some("warn"),
        "error" => Some("error"),
        _ => None,
    }
}

Validate paths carefully

If a user provides a file path, ensure it is within an expected directory and does not escape via .. segments or symlinks. For example, if your app processes files under /srv/uploads, normalize and verify the resolved path before using it.

Validate length and character set

Some programs behave poorly with extremely long inputs or unusual Unicode forms. Consider rejecting:

  • empty values
  • excessively long strings
  • control characters
  • embedded NUL bytes
  • unexpected whitespace

Rust strings cannot contain NUL bytes in the middle of a CString, but many OS APIs and external tools still have edge cases worth handling explicitly.


Use Command correctly

Rust’s Command API provides several methods that help you launch processes safely.

MethodPurposeSecurity note
Command::newSelect executablePrefer an absolute path when possible
.arg()Add one argumentAvoid building shell strings
.args()Add multiple argumentsGood for fixed argument lists
.current_dir()Set working directoryEnsure the directory is trusted
.env() / .env_clear()Control environmentClear or minimize inherited variables
.output()Capture stdout and stderrCheck exit status before trusting output
.status()Wait for completionUse when output is not needed

Example: safe wrapper around git

Suppose you want to inspect a repository status from a web service. Do not pass user input into a shell. Instead, use a fixed executable and explicit arguments:

use std::process::Command;

fn git_status(repo_dir: &str) -> Result<String, Box<dyn std::error::Error>> {
    let output = Command::new("/usr/bin/git")
        .arg("-C")
        .arg(repo_dir)
        .arg("status")
        .arg("--short")
        .env_clear()
        .output()?;

    if !output.status.success() {
        return Err(format!(
            "git failed: {}",
            String::from_utf8_lossy(&output.stderr)
        )
        .into());
    }

    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

This example still needs path validation for repo_dir, but it avoids shell injection and reduces environmental surprises by clearing inherited variables.


Control the execution environment

The environment can influence how a program behaves. Variables such as PATH, HOME, LANG, IFS, LD_PRELOAD, and tool-specific settings may change execution in ways that affect security.

Use absolute executable paths

If you call Command::new("git"), the OS may search PATH. That can be acceptable in trusted environments, but in security-sensitive code, prefer an absolute path:

Command::new("/usr/bin/git")

This avoids accidentally executing a malicious binary earlier in PATH.

Clear or set environment variables explicitly

If the child process does not need the parent’s environment, remove it:

use std::process::Command;

let output = Command::new("/usr/bin/ffmpeg")
    .env_clear()
    .env("LANG", "C")
    .arg("-version")
    .output()?;

This is especially useful for tools that may behave differently based on locale or inherited configuration files.

Set a trusted working directory

Some programs resolve relative paths based on the current directory. Use .current_dir() to control this behavior:

Command::new("/usr/bin/tar")
    .current_dir("/srv/backups")
    .arg("-czf")
    .arg("archive.tgz")
    .arg("data")
    .status()?;

If the directory is user-controlled, validate it first. A malicious working directory can still cause problems through relative path resolution.


Handle output safely

Capturing a child process’s output is common, but the output itself should be treated as untrusted data.

Check exit status

Never assume success just because a process produced output:

let output = Command::new("grep")
    .arg("error")
    .arg("/var/log/app.log")
    .output()?;

if !output.status.success() {
    return Err("search failed".into());
}

Avoid reusing output as code

Do not feed command output into another shell command. If you must chain tools, pass data through files, pipes, or structured formats instead of command strings.

Limit output size

A malicious or buggy process can emit huge amounts of data. If you need to process untrusted output, consider streaming it rather than loading everything into memory. For long-running tools, also set timeouts at a higher level using threads, async runtimes, or platform-specific process control.


Common anti-patterns and safer alternatives

Anti-patternRiskSafer alternative
sh -c format!(...)Shell injectionCommand::new(...).arg(...).arg(...)
Concatenating user input into one stringArgument confusionPass each argument separately
Using PATH for sensitive executablesBinary hijackingUse absolute paths
Inheriting all environment variablesUnexpected behaviorenv_clear() and explicit env values
Trusting process output blindlyLogic bugs, data abuseCheck exit code and validate output
Accepting arbitrary pathsTraversal and file abuseCanonicalize and allowlist directories

A practical pattern for safe process execution

A good design separates three responsibilities:

  1. Input validation
  2. Confirm that the user input matches the expected format.

  1. Command construction
  2. Build the process invocation with Command, one argument at a time.

  1. Result handling
  2. Check exit status, parse output carefully, and surface errors clearly.

Here is a compact example that combines these steps:

use std::process::Command;

fn run_thumbnailer(input: &str, size: u32) -> Result<(), String> {
    if input.is_empty() || input.len() > 256 {
        return Err("invalid input path length".into());
    }

    if size == 0 || size > 4096 {
        return Err("invalid size".into());
    }

    let status = Command::new("/usr/bin/convert")
        .env_clear()
        .arg(input)
        .arg("-resize")
        .arg(format!("{}x{}", size, size))
        .arg("thumbnail.png")
        .status()
        .map_err(|e| e.to_string())?;

    if !status.success() {
        return Err(format!("convert exited with {}", status));
    }

    Ok(())
}

This example is not complete production code, but it demonstrates the right shape: validate first, avoid the shell, and check the result.


When shell usage is unavoidable

Sometimes you genuinely need shell features such as globbing, pipelines, redirection, or compound expressions. Even then, treat shell invocation as a high-risk boundary.

If you must use a shell:

  • keep the shell script minimal
  • avoid interpolating untrusted input directly
  • pass data through environment variables or temporary files when appropriate
  • quote carefully according to the target shell’s rules
  • prefer a dedicated library or native Rust implementation instead

In many cases, the shell can be replaced entirely with Rust code or a safer library. For example, file globbing, text processing, and archive handling often have native crates that remove the need for shell syntax.


Production checklist

Before shipping code that launches external processes, verify the following:

  • no sh -c, cmd /C, or equivalent shell wrapper unless absolutely necessary
  • every user-controlled value is passed as a separate argument
  • executable paths are explicit and trusted
  • environment variables are minimized
  • paths are validated and constrained
  • exit status is checked
  • output is treated as untrusted
  • resource usage is bounded where possible

If you apply these rules consistently, Rust’s process API becomes a strong foundation for secure integration with system tools.

Learn more with useful resources