flawopen.com/command-injection/Java
OS Command Injection in Java (ProcessBuilder)
Wrapping input in new ProcessBuilder("sh", "-c", ...), concatenating it into Runtime.exec(String), or passing a value that starts with a dash lets a request run code or add options on your Java server (CWE-78). How each mistake works, why ProcessBuilder with a List is the fix, and how to bound the process with a timeout.
Think of a package label with separate boxes for the program and each argument. ProcessBuilder(List<String>) keeps those boxes separate and starts the program directly. Runtime.exec(String) is a trap of a different kind: it splits one string on whitespace, so a value with spaces can become extra arguments even without a shell. ProcessBuilder("sh", "-c", text) explicitly asks /bin/sh to interpret the text. A value beginning with - may also become an option to the chosen program. Fix the executable, build a validated argument list, and bound the child with waitFor(timeout, unit).
Core Concepts & Subsystem Terms
ProcessBuilder(List<String>)- Each list element becomes exactly one argument of the new process, and no shell is involved, so
;,|and$( )in an element are plain characters. UseList.of(...)with a fixed program name or absolute path. Runtime.exec(String)- Splits the string on whitespace with
StringTokenizerand runs the first token. There is no shell, but a value containing spaces becomes several arguments, including new options. Deprecated since Java 18 for this reason. new ProcessBuilder("sh", "-c", ...)- Hands the whole concatenated string to
/bin/sh(orcmd.exe /con Windows), which parses separators and substitutions. Any user text in that string can start extra commands. 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. Process.waitFor(timeout, unit)- Waits at most the given time and returns
falseif the process is still running, so the caller can calldestroyForcibly(). Redirect output to a file or read it on another thread, or a full pipe buffer can 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
A Spring controller passes @RequestParam String host, file or ref straight into a static helper that starts a process.
The value becomes part of a command line
"ping -c 1 " + host is handed to sh -c, Runtime.exec splits file on spaces into extra arguments, 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; ImageMagick receives options it was never meant to get; Git treats --output=... as an option.
The attacker runs code on the server
The process runs as the JVM's service account: it can read environment variables and mounted secrets, reach internal services and write files.
Source Code: Flaw vs. Secure Implementation
// Tools.java: each method lets request data change what runs
package com.example.tools;
import java.io.IOException;
public final class Tools {
// sh -c parses the concatenated string, ; and all
public static Process ping(String host) throws IOException {
return new ProcessBuilder("sh", "-c", "ping -c 1 " + host).start();
}
// exec(String) splits on spaces: file can add options
public static Process thumbnail(String file) throws IOException {
return Runtime.getRuntime().exec("convert " + file + " -resize 200x200 thumb.png");
}
// no shell, but a ref starting with - is read as a Git option
public static Process commitLog(String ref) throws IOException {
return new ProcessBuilder("git", "log", ref).start();
}
}
// Tools.java: argument lists, validation and a timeout
package com.example.tools;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
public final class Tools {
private static final Pattern HOST = Pattern.compile("[A-Za-z0-9][A-Za-z0-9.-]{0,252}");
private static final Pattern REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]{0,99}");
// one argument per element, output to a file, 5-second limit
private static String run(List<String> command) throws IOException, InterruptedException {
Path out = Files.createTempFile("tool", ".out");
try {
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.redirectOutput(out.toFile())
.start();
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new IOException("command timed out");
}
return Files.readString(out);
} finally {
Files.deleteIfExists(out);
}
}
// ping runs directly; host is one validated argument
public static String ping(String host) throws IOException, InterruptedException {
if (!HOST.matcher(host).matches()) {
throw new IllegalArgumentException("invalid host name");
}
return run(List.of("ping", "-c", "1", host));
}
// the file must stay inside uploadDir and not start with -
public static String thumbnail(Path uploadDir, String name) throws IOException, InterruptedException {
Path file = uploadDir.resolve(name).normalize();
if (name.startsWith("-") || !file.startsWith(uploadDir)) {
throw new IllegalArgumentException("invalid file name");
}
return run(List.of("/usr/bin/convert", file.toString(), "-resize", "200x200", "thumb.png"));
}
// --end-of-options makes Git treat ref as a revision
public static String commitLog(String ref) throws IOException, InterruptedException {
if (!REF.matcher(ref).matches()) {
throw new IllegalArgumentException("invalid ref");
}
return run(List.of("git", "log", "--end-of-options", ref, "--"));
}
}
Engineering & System Hardening Checklist
- ✓Flag every
Runtime.getRuntime().exec(call and everyProcessBuilderwhose first elements aresh,bashorcmd.exefollowed by-cor/c(Semgrepjava.lang.security.audit.command-injection-process-builder). - ✓Build commands with
new ProcessBuilder(List.of(...))and a fixed program name or absolute path; never concatenate request data into a single command string. - ✓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. - ✓Wait with
waitFor(timeout, unit), calldestroyForcibly()on timeout, and redirect output so the child cannot block on a full pipe. - ✓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')
- Java SE 21 API — ProcessBuilder
- Java SE 21 API — Runtime.exec(String) (deprecated since 18)
- SEI CERT Oracle Coding Standard — IDS07-J. Sanitize untrusted data passed to the Runtime.exec() method
- 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