skip to content

Why is ProcessBuilder with an argument array safer than Runtime.exec with a shell string for running external programs?

level: middleimportance: should knowfreq 62%

answer

  1. Shell string = metacharacters (; | && $()) become code
  2. ProcessBuilder/exec(String[]) = no shell, args stay literal
  3. One list element per argument
  4. Absolute exe path -> no PATH hijack
  5. Secrets in args are world-visible (ps)

basics

~20 s

Passing a single command string to a shell lets attacker input add extra commands (via ; | && etc.). ProcessBuilder with a list of separate arguments runs the program directly with no shell, so each argument stays one literal value.

solid answer

~50 s

OS command injection happens when untrusted input is placed into a string that a shell interprets, because shell metacharacters (;, |, &&, $(), backticks, redirects) let the attacker append or rewrite commands. The root cause is invoking a shell at all. ProcessBuilder (or Runtime.exec with a String[]) takes an explicit argument vector: program, then each argument as its own list element. There is no shell to parse metacharacters, so a value like "foo; rm -rf /" is passed verbatim as a single argument to the program and is harmless. The safe pattern is new ProcessBuilder(List.of("/usr/bin/convert", input, output)) with absolute paths, never concatenation. If you genuinely need shell features you've reintroduced the risk and must allow-list. Also prefer a fixed absolute executable path to avoid PATH hijacking, and don't pass secrets as arguments (they show in the process list).

code

java · 12 lines
java
// DANGEROUS: user input inside a shell string
String f = req.getParameter("f");
Runtime.getRuntime().exec("sh -c \"convert " + f + " out.png\""); // ; | $() all live

// SAFE: no shell, each argument is a separate, literal element
ProcessBuilder pb = new ProcessBuilder(
        "/usr/bin/convert",  // absolute path -> no PATH hijack
        f,                   // "a.jpg; rm -rf /" is just a (missing) filename
        "out.png");
pb.redirectErrorStream(true);
Process p = pb.start();
int code = p.waitFor();

go deeper

for a junior

Knows to use ProcessBuilder with separate arguments instead of building a shell command string from user input.

for a middle

Explains that the shell interprets metacharacters and that an argument vector bypasses the shell so input stays a literal argument.

for a senior

Adds hardening: absolute exe path (PATH hijacking), no secrets in argv, validate path arguments, allow-list if a shell is truly needed.

for a principal

Generalizes injection across interpreters, mandates least-privilege child processes, and bans shell-string exec APIs via static analysis/review policy.

## The vulnerability: OS command injection **OS command injection** is when an attacker gets your program to run operating-system commands of their choosing, by injecting text into a command your program executes. It is the shell-equivalent of SQL injection, and it often gives full control of the host. **The role of the shell.** A *shell* (bash, sh, cmd) is a program that parses a command *line* and interprets special characters called **metacharacters**: - `;` separates commands; - `|` pipes one command's output into another; - `&&` / `||` run a second command conditionally; - `$(...)` and backticks run a command and substitute its output; - `>` `<` redirect files; - `*` `?` glob filenames. If you hand the shell a *single string* that contains untrusted input, the shell will happily interpret any metacharacters in that input as **syntax**, not data — the same data-as-code confusion as SQLi. ## The broken pattern ```java // DANGEROUS: a single string handed to a shell String filename = request.getParameter("f"); Runtime.getRuntime().exec("sh -c \"convert " + filename + " out.png\""); ``` If `filename` is `a.jpg; rm -rf /`, the shell sees two commands and runs both. Even `Runtime.exec(String)` *without* an explicit shell is dangerous: its single-string overload splits the command naively on whitespace (it does **not** honor quoting), so it both breaks on legitimate spaces and can be abused. ## The fix: an argument vector, no shell The reliable defense is to **not invoke a shell** and to pass the program plus each argument as **separate elements of a list/array**. Then the OS executes the named program directly (`execvp`-style), handing it that exact argument vector. Nothing parses metacharacters. ```java ProcessBuilder pb = new ProcessBuilder( "/usr/bin/convert", // absolute path to the executable filename, // one element = one argument, verbatim "out.png"); pb.redirectErrorStream(true); Process p = pb.start(); ``` Now `filename = "a.jpg; rm -rf /"` is passed to `convert` as one literal argument named `a.jpg; rm -rf /` — `convert` just fails to find that file. The `;` is never special because no shell is in the picture. (`Runtime.exec(String[])` gives the same property; `ProcessBuilder` is the modern, more flexible API.) ## Additional hardening - **Use an absolute executable path** (`/usr/bin/convert`, not `convert`). Otherwise the program is resolved via the `PATH` environment variable, and an attacker who can influence `PATH` or drop a malicious binary earlier in it can hijack the call (**PATH hijacking**). - **Don't pass secrets as arguments.** Process arguments are visible system-wide (e.g. `ps`, `/proc`). Pass secrets via stdin or environment instead — and even environment has caveats. - **Validate file paths separately** for path traversal if an argument is a path (see canonical-path checks). - **If you truly need shell features** (pipes, globbing), you've reintroduced the danger; restrict inputs to a strict **allow-list** of known-safe values and/or escape rigorously — but prefer redesigning to avoid the shell. - **Least privilege:** run the child process as an unprivileged user so a successful injection is contained. ## Why this is the same idea as SQLi Both are **injection** flaws: untrusted data crosses a trust boundary into an interpreter (a shell / a SQL engine) that mixes code and data. The general cure is the same — **keep data out of the code channel**: bind parameters for SQL, pass an argument vector (no shell) for commands.

  • A teammate keeps `sh -c` because they need a pipe between two tools. How do you make it safe?
    Prefer running each tool with its own ProcessBuilder and wiring streams in Java (or redirectOutput/redirectInput). If the shell is unavoidable, allow-list the inputs to a fixed safe set and pass no raw user text into the command line.
  • Why pass the absolute path to the executable instead of just its name?
    A bare name is resolved through PATH; an attacker who controls PATH or plants a binary earlier in it can substitute their own executable. An absolute path removes that ambiguity.

saying these in an interview costs you the question

  • Believing Runtime.exec(String) (single string) is safe — it splits naively and is still abusable
  • Thinking you can safely keep the shell if you just strip a few characters
  • Passing the executable by bare name and ignoring PATH hijacking
  • Passing API keys/passwords as command-line arguments

context