FlawOpen

flawopen.com/command-injection/Rust

● CWE-78 · Critical
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 (or cmd /C on Windows), which parses separators and substitutions. format! with request data then lets the data add commands.
CVE-2024-24576 (BatBadBut)
On Windows, running a .bat or .cmd file goes through cmd.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, spawn returns an InvalidInput error 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; in git 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_wait against a deadline and call kill when it passes, and read stdout on a separate thread so a full pipe cannot stall the child.

Framework mappings

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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()
}
HARDENED SECURE PATCH
// 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

Sources