FlawOpen

flawopen.com/command-injection/Java

● CWE-78 · Critical
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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. Use List.of(...) with a fixed program name or absolute path.
Runtime.exec(String)
Splits the string on whitespace with StringTokenizer and 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 (or cmd.exe /c on 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; in git 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 false if the process is still running, so the caller can call destroyForcibly(). Redirect output to a file or read it on another thread, or a full pipe buffer can stall the child.

Framework mappings

Step-by-Step Attack Flow

Step 1

Request value reaches the helper

A Spring controller passes @RequestParam String host, file or ref straight into a static helper that starts a process.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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();
    }
}
HARDENED SECURE PATCH
// 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

Sources