CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') - Java
Overview
OS Command Injection occurs when an application incorporates untrusted data into an operating system command without proper validation or sanitization. Attackers can execute arbitrary commands on the host operating system.
Primary Defence: Use Java native APIs (Files, HttpClient, etc.) instead of system commands, or if unavoidable, use ProcessBuilder with separate command arguments and do not invoke a shell such as /bin/sh -c or cmd.exe /c.
Common Vulnerable Patterns
String Concatenation with Runtime.exec()
// VULNERABLE - Command injection via string concatenation
String filename = request.getParameter("file");
Runtime.getRuntime().exec("ls -la " + filename);
// Attack example:
// Input: "-R /"
// Result: User input becomes additional ls options/paths and changes command behavior
Why this is vulnerable: Runtime.exec(String) has been deprecated since Java 18 because it tokenizes the command string with a StringTokenizer before launching the process - so the split between program and arguments is decided by whitespace in a string the caller assembled. The String[] overloads are not deprecated, because they leave that split to the caller. Neither form runs through a shell by default, so shell operators such as ; and | are not interpreted unless the command explicitly invokes a shell. The risk is still real: user input can break argument boundaries, alter the invoked program's options, or become command injection if the string is later changed to run through /bin/sh -c or cmd.exe /c. Prefer ProcessBuilder with separate arguments.
Using Shell with User Input
// VULNERABLE - Shell command injection
String userInput = request.getParameter("path");
String[] cmd = {"/bin/sh", "-c", "cat " + userInput};
Runtime.getRuntime().exec(cmd);
// Attack example:
// Input: "file.txt; cat /etc/passwd > /tmp/pwned.txt"
// Result: Exports password file
Why this is vulnerable: Invoking /bin/sh with -c flag processes user input through the shell, enabling command injection via semicolons, pipes, and other shell metacharacters.
ProcessBuilder with Shell Invocation
// VULNERABLE - Invoking shell allows command injection
String ip = request.getParameter("ip");
ProcessBuilder pb = new ProcessBuilder("/bin/bash", "-c", "ping -c 4 " + ip);
pb.start();
// Attack example:
// Input: "8.8.8.8 && cat /etc/shadow"
// Result: Executes additional commands
Why this is vulnerable: ProcessBuilder with shell commands (/bin/bash, -c) processes user input through the shell, enabling injection attacks via command chaining (&&, ||) and command substitution.
Unvalidated Input in Process Arguments
// VULNERABLE - No input validation
String userFile = request.getParameter("filepath");
Runtime.getRuntime().exec("cat " + userFile);
// Attack example:
// Input: "/etc/passwd"
// Result: Reads an unintended absolute path if path validation is missing
Why this is vulnerable: Runtime.exec(String) splits the command on whitespace, so a filename containing spaces or option-like values can alter the argument list passed to the program. Shell operators such as |, <, and > are only interpreted if a shell is explicitly invoked, but the pattern is still unsafe because it mixes trusted command structure with untrusted data. Use ProcessBuilder or Runtime.exec(String[]) with separate arguments and validate the input for the specific command.
Caller-Chosen Executable (No Shell Involved)
// VULNERABLE - the request decides which program runs
String tool = request.getParameter("tool"); // expected: "ping" or "traceroute"
String target = request.getParameter("target");
Process process = new ProcessBuilder(tool, target).start();
// Attack example:
// Input: tool=rm, target=/srv/app/data/app.db
// Result: rm runs with the application's privileges and deletes the database
Why this is vulnerable: There is no shell here, and ProcessBuilder is doing exactly what it promises, which is why this passes a review that is looking for /bin/sh -c. The problem is the first element. tool=rm deletes whatever target names, and tool=sh or tool=python3 with the path of a file the attacker uploaded runs their code. Without a shell the attacker cannot chain a second command on the same line, but they do not need to. The executable does not have to come from the request, either: a program path read from a settings table, a plugin descriptor or an environment variable is the same finding if anyone outside the deployment can change it.
Secure Patterns
Use Java NIO File APIs (PREFERRED - Eliminates Command Injection)
// SECURE - Use Java NIO APIs instead of OS commands
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.io.IOException;
import java.util.stream.Stream;
// List files instead of "ls"
try (Stream<Path> files = Files.list(Paths.get(directory))) {
files.forEach(file -> {
try {
BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
System.out.printf("%s %d %s%n",
file.getFileName(),
attrs.size(),
attrs.lastModifiedTime());
} catch (IOException e) {
e.printStackTrace();
}
});
}
// Read file instead of "cat"
String content = Files.readString(Paths.get(filepath));
// Copy file instead of "cp"
Files.copy(Paths.get(source), Paths.get(dest), StandardCopyOption.REPLACE_EXISTING);
// Delete file instead of "rm"
Files.delete(Paths.get(filepath));
// Create directory instead of "mkdir"
Files.createDirectories(Paths.get(directory));
Why this works: Java NIO's Files API operates directly on the filesystem through the JVM. No OS process is started, so there is no shell to interpret metacharacters such as ;, |, or &&.
Use HttpClient for Network Operations (Java 11+)
// SECURE - Use HttpClient instead of curl/wget
import java.net.http.*;
import java.net.URI;
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
String content = response.body();
// For downloads
HttpResponse<Path> fileResponse = client.send(request,
HttpResponse.BodyHandlers.ofFile(Paths.get(localPath)));
Why this works: HttpClient performs network operations in pure Java, without executing curl, wget, or a similar command-line utility. No process is started, so a malicious URL or parameter has no shell to escape into.
Use java.util.zip for Archives
// SECURE - Use built-in zip instead of unzip/tar commands
import java.util.zip.*;
import java.io.*;
// Extract zip file
try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipPath))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path extractRoot = Paths.get(extractPath).toAbsolutePath().normalize();
Path destPath = extractRoot.resolve(entry.getName()).normalize();
// Prevent zip slip attack
if (!destPath.startsWith(extractRoot)) {
throw new IOException("Invalid zip entry");
}
if (entry.isDirectory()) {
Files.createDirectories(destPath);
} else {
Files.copy(zis, destPath, StandardCopyOption.REPLACE_EXISTING);
}
}
}
// Create zip file
Path sourceRoot = Paths.get(sourceDir);
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipPath))) {
Files.walk(sourceRoot)
.filter(path -> !Files.isDirectory(path))
.forEach(path -> {
ZipEntry entry = new ZipEntry(sourceRoot.relativize(path).toString());
try {
zos.putNextEntry(entry);
Files.copy(path, zos);
zos.closeEntry();
} catch (IOException e) {
e.printStackTrace();
}
});
}
Why this works: Java's built-in zip libraries handle archive operations in-memory without calling external tar, unzip, or 7z commands. Even if an attacker controls filenames within the archive, they cannot inject shell commands because no shell is invoked. The path normalization check also prevents zip slip attacks.
Use Pattern/Matcher for Text Processing
// SECURE - Use Java regex instead of grep/awk commands
import java.util.regex.*;
import java.nio.file.Files;
import java.util.stream.Stream;
String content = Files.readString(Paths.get(filepath));
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(content);
while (matcher.find()) {
System.out.println(matcher.group());
}
// Line-by-line processing
try (Stream<String> lines = Files.lines(Paths.get(filepath))) {
lines.filter(line -> line.contains(searchTerm))
.forEach(System.out::println);
}
Why this works: Java's regex and stream APIs process text in the JVM, without executing grep, sed, awk, or other shell utilities. There is no command line for a search term or a filename to break out of.
ProcessBuilder with Argument Array (If Process Execution Required)
WARNING: Exhaust the native alternatives first - Java covers most of this ground already (HttpClient, java.nio, java.util.zip). This pattern is ONLY for cases where no Java library exists, such as calling a legacy third-party binary.
// USE WITH CAUTION - When process execution is unavoidable, use argument array
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
String ipAddress = request.getParameter("ip");
// Validate input first - isValidIP() also bounds each octet, which the
// regex alone does not (it accepts 999.999.999.999)
if (!isValidIP(ipAddress)) {
throw new IllegalArgumentException("Invalid IP address");
}
// Use ProcessBuilder with separate arguments - NO SHELL
ProcessBuilder pb = new ProcessBuilder(
"ping",
"-c",
"4",
ipAddress // Arguments are NOT concatenated
);
pb.redirectErrorStream(true);
// Send output to a file, not the default pipe. Nothing here drains a pipe,
// and reading one inline would outlive the timeout below - see the note after
// this example.
Path out = Files.createTempFile("ping", ".txt");
pb.redirectOutput(out.toFile());
Process process = pb.start();
if (!process.waitFor(10, TimeUnit.SECONDS)) {
// waitFor() after destroyForcibly(), or the child still holds the file
// open and the delete below fails on Windows.
process.destroyForcibly().waitFor();
Files.deleteIfExists(out);
throw new IllegalStateException("ping did not finish within 10 seconds");
}
try {
Files.readAllLines(out, StandardCharsets.UTF_8).forEach(System.out::println);
} finally {
Files.deleteIfExists(out);
}
Why this works: ProcessBuilder with separate arguments (not a single concatenated string) passes each argument directly to the executable without shell interpretation. Even if ipAddress contains shell metacharacters like ; or &&, they're treated as literal argument data rather than command separators. Input validation provides defense-in-depth by rejecting malformed inputs before they reach ProcessBuilder. On Windows the argument-array guarantee is narrower than it looks - see Considerations.
A child process has two ways of hanging, and the fix for one reintroduces the other. Calling waitFor while nothing reads the output pipe deadlocks a chatty child: it blocks in write() once the OS buffer fills, and the timeout fires on work that was legitimate. Draining the pipe first - a readLine() loop or readAllBytes() before waitFor - fixes that and creates the opposite failure, because the read blocks until EOF and a child that prints one line and then hangs never sends one. Measured on JDK 26 against ping -t: the read loop was still blocked after 15 seconds with waitFor(10, TimeUnit.SECONDS) on the line below it, never evaluated. Redirecting to a file, as above, has neither problem; so does a reader thread whose Future is bounded by the same deadline. Whenever a timeout follows a blocking read of the same resource, ask whether the read can finish without it - and test the quiet-hang case, which is a different input from the chatty one.
Map the Requested Tool to a Fixed Invocation
// SECURE - the request picks a key; the application picks the program
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
private static final Map<String, List<String>> TOOLS = Map.of(
"ping", List.of("/usr/bin/ping", "-c", "4"),
"traceroute", List.of("/usr/bin/traceroute", "-n")
);
public ProcessBuilder diagnostic(String tool, String target) {
// Map.of() throws NullPointerException on get(null), so check first
List<String> invocation = tool == null ? null : TOOLS.get(tool);
if (invocation == null) {
throw new IllegalArgumentException("Unknown tool");
}
if (target == null || !isValidIP(target)) {
throw new IllegalArgumentException("Invalid IP address");
}
List<String> command = new ArrayList<>(invocation);
command.add(target);
return new ProcessBuilder(command);
}
Why this works: The caller's string is only ever a map key. The executable and its fixed options come from TOOLS, so rm, sh or /bin/sh finds no entry and is rejected before a process is built. The table holds absolute paths, so PATH does not get to decide which binary runs. The whole invocation is fixed except for one operand, and isValidIP() (below) only accepts a dotted IPv4 address, which cannot begin with -. Start the returned ProcessBuilder with the file redirect and waitFor deadline from the previous example.
Runtime.exec() with String Array (Legacy Java)
WARNING: Use this only for legacy code that already depends on Runtime.exec(), and avoid process execution entirely where you can. ProcessBuilder has been available since Java 5 and is clearer for new code.
// LEGACY PATTERN - Use String array with Runtime.exec
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
String filename = request.getParameter("file");
// Validate filename. No leading dash: it is harmless here because the name is
// concatenated onto a fixed directory prefix below, but the same helper copied
// to a call that passes the name as its own argument would hand "cat" an option.
if (filename == null || !filename.matches("[a-zA-Z0-9._][a-zA-Z0-9._-]*")) {
throw new IllegalArgumentException("Invalid filename");
}
// Use String array - no shell invocation
String[] command = {"cat", "/var/data/" + filename};
Process process = Runtime.getRuntime().exec(command);
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
// Runtime.exec() cannot redirect to a file, so drain both streams on their own
// threads. Reading inline blocks until EOF, and the deadline below would never
// be evaluated for a child that stops writing without exiting.
CompletableFuture<byte[]> stdout = drain(process.getInputStream());
CompletableFuture<byte[]> stderr = drain(process.getErrorStream());
// One deadline for the wait *and* the output: a descendant that inherited the
// pipes can hold them open after the child itself has exited.
byte[] out;
byte[] err;
try {
if (!process.waitFor(remaining(deadline), TimeUnit.NANOSECONDS)) {
throw new TimeoutException();
}
out = stdout.get(remaining(deadline), TimeUnit.NANOSECONDS);
err = stderr.get(remaining(deadline), TimeUnit.NANOSECONDS);
} catch (TimeoutException e) {
stop(process);
throw new IllegalStateException("cat did not finish within 10 seconds");
} catch (ExecutionException e) {
stop(process);
throw new IllegalStateException("could not read cat's output", e.getCause());
}
if (process.exitValue() != 0) {
throw new IllegalStateException(new String(err, StandardCharsets.UTF_8));
}
System.out.print(new String(out, StandardCharsets.UTF_8));
static long remaining(long deadline) {
return Math.max(0, deadline - System.nanoTime());
}
static void stop(Process process) throws IOException {
// Descendants first, while they can still be found through the child
process.descendants().forEach(ProcessHandle::destroyForcibly);
process.destroyForcibly();
// Closing our ends unblocks the reader threads even if a descendant
// escaped - it will get a broken pipe the next time it writes
process.getInputStream().close();
process.getErrorStream().close();
}
static CompletableFuture<byte[]> drain(InputStream in) {
CompletableFuture<byte[]> result = new CompletableFuture<>();
Thread reader = new Thread(() -> {
try {
result.complete(in.readAllBytes());
} catch (IOException e) {
result.completeExceptionally(e);
}
});
reader.setDaemon(true); // a stuck reader must not keep the JVM alive
reader.start();
return result;
}
Why this works: Using a String array with Runtime.exec() passes each array element as a separate process argument rather than tokenizing one command string. Input validation with allowlisting blocks malformed filenames and command-specific option injection attempts. The output handling is the part that differs from the ProcessBuilder example: Runtime.exec() has no redirectOutput(), so the two streams are drained on threads of their own while waitFor keeps the deadline - the reader-thread option from the note above. Both streams need a reader, because a child that fills the stderr pipe blocks just as surely as one that fills stdout.
The deadline covers collecting the output as well as the exit. A child can exit while a process it started still holds the inherited pipes, and then EOF never comes: measured on JDK 26 / Windows 11, a child that started a 15-second grandchild and exited took 15.1 seconds with the deadline on waitFor alone, and 10.0 seconds with it on the reads too. stop() kills descendants first because that only works while they can still be traced through the child - Windows keeps that link after the child exits, and there the grandchild was killed; on Linux an orphan is re-parented and descendants() no longer finds it. Closing the pipe ends is what bounds the call either way. A tool that starts processes of its own is better run somewhere that can kill the whole tree, such as a container or a systemd unit. ProcessBuilder is still preferred for new code, and the file redirect it allows is simpler than this.
Input Validation (Defense in Depth)
Allowlist Characters
public boolean isValidFilename(String filename) {
// Alphanumeric, underscore, dash, dot - but never a leading dash, which
// any command receiving the name would read as an option (CWE-88).
// matches() requires the whole input, so no anchors are needed and a
// trailing newline cannot slip past - unlike find(), or Python's re.match.
return filename.matches("[a-zA-Z0-9._][a-zA-Z0-9._-]*");
}
public boolean isValidIP(String ip) {
// Validate IPv4 format
String ipPattern = "^([0-9]{1,3}\\.){3}[0-9]{1,3}$";
if (!ip.matches(ipPattern)) return false;
// Check each octet is 0-255, with no leading zero: ping reads "010" as
// octal, so 010.1.1.1 would pass here and reach 8.1.1.1
String[] parts = ip.split("\\.");
for (String part : parts) {
if (part.length() > 1 && part.startsWith("0")) return false;
int num = Integer.parseInt(part);
if (num < 0 || num > 255) return false;
}
return true;
}
Why the leading-zero check: the regex and the range check both pass 010.1.1.1, and Windows ping sends it to 8.1.1.1 - the leading zero is read as octal. The value the validator approved and the address the program used would differ. Java has no built-in that parses an address literal without also resolving hostnames - InetAddress.getByName() sends anything that is not a literal to DNS - so the check is written out here rather than delegated.
Spring Framework Integration
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class SystemController {
@PostMapping("/ping")
public String ping(@Valid @RequestBody PingRequest request)
throws IOException, InterruptedException {
// A body that fails validation is answered with 400 before this runs
ProcessBuilder pb = new ProcessBuilder("ping", "-c", "4", request.getIpAddress());
pb.redirectErrorStream(true);
Path out = Files.createTempFile("ping", ".txt");
pb.redirectOutput(out.toFile());
Process process = pb.start();
try {
if (!process.waitFor(10, TimeUnit.SECONDS)) {
process.destroyForcibly().waitFor();
throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "ping timed out");
}
return Files.readString(out);
} finally {
Files.deleteIfExists(out);
}
}
}
public class PingRequest {
// Each octet 0-255 with no leading zero - the rule isValidIP() enforces.
// @Pattern must match the whole value, so no anchors are needed.
private static final String OCTET = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
@NotNull // @Pattern treats null as valid
@Pattern(regexp = "(" + OCTET + "\\.){3}" + OCTET, message = "Invalid IP address")
private String ipAddress;
public String getIpAddress() {
return ipAddress;
}
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
}
Why this works: @Valid on the request body makes Spring run the constraints before the method is called, and a failure becomes a 400 response. The pattern carries the octet range and the leading-zero rule itself, because a constraint annotation cannot call isValidIP(): a shape-only pattern such as ([0-9]{1,3}\.){3}[0-9]{1,3} accepts 999.999.999.999 and 010.1.1.1. @NotNull is needed beside it, because @Pattern, like most constraints, treats a missing value as valid. The execution is the file-redirect and deadline pattern from the ProcessBuilder example.
Considerations
Removing the shell does not finish the finding. An argument array stops the shell from parsing the value. It does not stop the program you launched from parsing it. tar given a filename of --to-command=curl attacker.example reads that as an option, and no shell was involved. Before closing a CWE-78 finding, ask what the target program does with a value that starts with -, and either reject those values or place -- ahead of the user-controlled arguments where the program supports it. What is left is CWE-88, and the scanner usually stops reporting either way once the shell is gone.
The program itself is part of the judgement. new ProcessBuilder("ping", "-c",
"4", host) and new ProcessBuilder("python3", script) have the same shape and
very different exposure: the second hands its argument to an interpreter, so any
value is code. Watch for targets that accept a command inside an option
(find -exec, ssh -o ProxyCommand, git -c core.sshCommand) and for wrapper
shell scripts, which re-enter a shell one layer below the Java code.
The examples on this page write ping and similar tools without a path, for
readability. ProcessBuilder resolves a bare name against PATH, and on Windows
against the working directory as well - measured on JDK 26 / Windows 11, a bare
name ran from the working directory. Pin the absolute path in production, as the
mapping-table example does. What a bare name risks is
CWE-426, and the
generic page covers when it matters.
Windows narrows what an argument array guarantees. Windows has no argv array at the system-call level. CreateProcess takes a single command-line string and the child re-parses it; ProcessBuilder builds that string for you. By default the JDK builds it with legacy quoting that is not the C runtime's, so an embedded double quote is dropped and the argument boundary can move. Measured on JDK 26 / Windows 11 against a program that prints its own argv:
Argument passed to ProcessBuilder |
argv the child received (default) | With allowAmbiguousCommands=false |
|---|---|---|
a b |
["a b"] |
["a b"] |
a"b c |
["ab", "c"] |
["a\"b c"] |
with"quote |
["withquote"] |
["with\"quote"] |
Running with -Djdk.lang.Process.allowAmbiguousCommands=false restores strict
quoting. The property is unset by default, which selects the legacy behaviour.
The same property decides whether a .bat or .cmd target is safe at all. On
the same JDK, new ProcessBuilder("show.bat", "x\"&echo INJECTED&") executes
echo INJECTED: cmd.exe interprets the batch file's command line, so the shell
is back. With allowAmbiguousCommands=false the JDK refuses, throwing
IOException: Argument has embedded quote, use the explicit CMD.EXE call. This
is the same defect class as CVE-2024-27980 in Node.js and CVE-2024-1874 in PHP,
where the runtimes shipped fixes; on the JDK it is a configuration choice. If a
Windows deployment shells out through a batch wrapper, point the call at the real
executable instead, or set the property - do not rely on the argument array
alone.
Where to put least privilege. Least privilege belongs to whatever starts the JVM, not to the Java code. The SecurityManager that older guidance recommended here is deprecated for removal (JEP 411) and never constrained a child process anyway: a spawned binary runs under the OS user, outside the JVM's control. Use a container security context, a systemd unit with NoNewPrivileges and a restricted user, or an OS sandbox. This bounds the damage; it does not close the finding.
Testing
- Normal input:
isValidIP("8.8.8.8")is accepted and theProcessBuildercall returns ping output rather than throwing. A pattern that rejects everything passes every assertion below, so the accept is what tells a working validator from a broken one. - Boundary input:
isValidFilename("")returnsfalse."..","."and a 300-character name all returntrue- the character class allows dots and has no length limit, so it is not a path check. Because it also rejects/and\, one validated name cannot reach a file outside the directory it is joined to:..names that directory's parent itself, andcatin the legacyRuntime.exec()example answersIs a directoryand exits 1. Assert that..and.are accepted anyway, so nobody reads the validator as more than it is, and add a length limit if the tool needs one. Where a name can carry separators, test containment separately - see CWE-22 - Java. - Octet bounds:
"999.999.999.999"is rejected. The regex alone accepts it, so this assertion is what covers the numeric check beside it. - Leading zeros:
"010.1.1.1"is rejected, and"10.1.1.1"and"0.0.0.0"are accepted. The regex and the range check both pass the first, so this assertion is what covers the leading-zero check; the two accepts show it has not taken a legitimate0or10with it. - Anchoring:
"8.8.8.8\n"is rejected.matches()gives this for free because it requires the whole input; the same pattern throughfind()accepts it, and so doesre.matchif the pattern is ported to Python. - Argument injection:
"-debug"is rejected by bothisValidFilenameandisValidIPbefore it reaches the program. It contains no shell metacharacter, so none of the payloads below exercises it. - Program choice:
diagnostic("rm", "8.8.8.8"), and the same call with"sh","/bin/sh"ornull, throwsIllegalArgumentExceptionbefore any process is built.diagnostic("ping", "8.8.8.8").command()equals[/usr/bin/ping, -c, 4, 8.8.8.8]. Assert oncommand()rather than on output, because a call that ran the wrong binary can still exit 0. None of the shell payloads below exercises this either. - Output volume: a command producing more than a pipe buffer's worth of output completes rather than hitting the timeout. Test with a chatty command, not a one-line one - the deadlock only appears above the OS buffer.
- Quiet hang: a child that prints one line and then blocks is killed at the deadline rather than waited on forever. This is a different input from the chatty case and fails against the drain-then-
waitForshape. - Malicious input:
8.8.8.8; cat /etc/passwd,`whoami`,$(cat /etc/shadow), and&& rm -rf /tmp/*are all rejected or treated as a single literal argument, never executed.
Common Pitfalls
- Switching from
Runtime.exec(String)toRuntime.exec(String[])orProcessBuilder, but keeping/bin/sh,-c, and a concatenated string as the array elements (new String[]{"/bin/sh", "-c", "cat " + userInput}) - the array form only removes shell parsing once each shell argument is its own element; here the whole command is still a single string handed to the shell. - Validating input with
matches()against a regex on a decoded or normalized copy of the string, while the original (still-encoded, differently-normalized) value is what's actually passed toProcessBuilder- the value that was checked and the value that gets executed diverge. - Carrying over a legacy
/bin/sh -cinvocation when migrating fromRuntime.exec()toProcessBuilder, on the assumption thatProcessBuilderis inherently safe -ProcessBuilderavoids the shell only when the command array itself doesn't request one; naming a shell interpreter as the program keeps the exact same injection point.
Dependencies and Installation
Java Version Requirements
The examples on this page target Java 17 or later, where java.net.http.HttpClient
and Files.readString() are part of the platform and no dependency is needed to
replace curl or cat. On Java 8, substitute Apache HttpComponents or OkHttp for
HttpClient and Files.readAllLines() for Files.readString() - neither
substitution changes the command injection fix, which is the same on every
version.
Maven Dependencies
<!-- For Apache Commons Compress (zip/tar operations) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
<!-- For HTTP operations on Java 8 only; Java 11+ has java.net.http.HttpClient -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<!-- For testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Gradle Dependencies
// For Apache Commons Compress
implementation 'org.apache.commons:commons-compress'
// For HTTP operations on Java 8 only; Java 11+ has java.net.http.HttpClient
implementation 'org.apache.httpcomponents.client5:httpclient5'
// For testing
testImplementation 'org.junit.jupiter:junit-jupiter'
Security Configuration (Optional)
# application.properties (Spring Boot)
# Restrict file access
app.upload.directory=/var/app/uploads
app.upload.max-size=10485760
app.allowed.file-extensions=.txt,.pdf,.jpg,.png
# Process execution settings
app.process.timeout-seconds=10
app.process.max-concurrent=5