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.
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.
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
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.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);
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
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. However, ProcessBuilder is preferred for better control and clearer intent.
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
String[] parts = ip.split("\\.");
for (String part : parts) {
int num = Integer.parseInt(part);
if (num < 0 || num > 255) return false;
}
return true;
}
Spring Framework Integration
@RestController
public class SystemController {
@PostMapping("/ping")
public String ping(@Validated @RequestBody PingRequest request) {
// Input validated by @Valid annotation
ProcessBuilder pb = new ProcessBuilder("ping", "-c", "4", request.getIpAddress());
// ... execute safely
}
}
public class PingRequest {
@Pattern(regexp = "^([0-9]{1,3}\\.){3}[0-9]{1,3}$",
message = "Invalid IP address")
private String ipAddress;
// getters/setters
}
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.
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.
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