How Command Injection Happens in Java Runtime.exec() and How to Fix It
Summary
A critical command injection vulnerability was discovered in page-object/src/main/java/com/iluwatar/pageobject/App.java where Runtime.getRuntime().exec() was used to launch a file using cmd.exe with a directly concatenated file path. An attacker who could control the applicationFile variable could inject shell metacharacters to execute arbitrary system commands with the privileges of the running Java process. The fix replaces the unsafe exec() call with a properly tokenized ProcessBuilder invocation, eliminating the injection surface entirely.
Introduction
The App.java file in the page-object module handles application launch logic, including a fallback path for Windows systems where java.awt.Desktop is not supported. That fallback, buried in an else branch at line 80, contained a single line that created a critical security hole:
Runtime.getRuntime().exec("cmd.exe start " + applicationFile);
The variable applicationFile is concatenated directly into a command string and handed to the Windows shell for execution. If an attacker can influence the value of applicationFile — through a crafted file path, a configuration value, or any upstream input — they can inject shell metacharacters and execute arbitrary commands on the host system with the same privileges as the Java process.
This is a textbook example of CWE-78: OS Command Injection, and it is exactly the kind of subtle, single-line vulnerability that is easy to overlook during code review but trivial to exploit in practice.
The Vulnerability Explained
What went wrong
The core problem is the way Runtime.getRuntime().exec() handles its input when given a single String argument.
Many Java developers assume that passing a string to exec() is equivalent to running it in a terminal. That assumption is partially correct — and that partial correctness is what makes it dangerous. When you pass a single string, the JVM splits it on whitespace and passes the first token as the executable and the rest as arguments. However, the shell (cmd.exe in this case) is still invoked as the executable, and cmd.exe does interpret metacharacters in its arguments.
Here is the vulnerable line:
// VULNERABLE: applicationFile is concatenated directly into the command string
Runtime.getRuntime().exec("cmd.exe start " + applicationFile);
If applicationFile is something like:
C:\myapp\app.html
The command becomes:
cmd.exe start C:\myapp\app.html
That is the intended behavior. But if applicationFile is:
C:\myapp\app.html & net user hacker P@ssw0rd /add
The command becomes:
cmd.exe start C:\myapp\app.html & net user hacker P@ssw0rd /add
cmd.exe interprets & as a command separator. The second command — creating a new Windows user account — executes silently alongside the first.
Attack scenario specific to this code
In the page-object design pattern context, applicationFile represents the path to an application file that the page object framework needs to open. If this path is derived from a test configuration file, a database record, a URL parameter, or any other externally influenced source, an attacker who can write to that source can inject commands.
Consider a scenario where the application file path is read from a properties file that a low-privileged user can edit. The attacker sets the path to:
index.html & powershell -EncodedCommand <base64-encoded-reverse-shell>
When App.java runs on a build server or CI/CD pipeline (a common use case for page object test frameworks), the injected PowerShell command executes with the privileges of the CI agent — potentially giving the attacker a foothold on the build infrastructure.
Why this is rated Critical
- No validation or sanitization of
applicationFilebefore it is used in the command - Shell metacharacters (
&,|,;,&&,||,`) are not stripped or escaped - cmd.exe is explicitly invoked, meaning Windows shell interpretation is guaranteed
- The JVM process may run with elevated privileges in server or CI environments
- The impact is complete: arbitrary command execution, data exfiltration, persistence, lateral movement
The Fix
What changed
The fix replaces the single-string Runtime.exec() call with a ProcessBuilder that passes each argument as a discrete token:
Before (vulnerable):
Runtime.getRuntime().exec("cmd.exe start " + applicationFile);
After (safe):
new ProcessBuilder("cmd.exe", "/c", "start", "", applicationFile.getAbsolutePath()).start();
Why this fix works
ProcessBuilder accepts a list of strings where the first element is the executable and each subsequent element is a separate argument. Critically, no shell is invoked to parse this argument list. The arguments are passed directly to the operating system's process creation API (CreateProcess on Windows), which does not interpret shell metacharacters.
This means that even if applicationFile contains & net user hacker /add, the entire string is treated as a single, literal file path argument. The OS will fail to find a file with that name, but it will not execute the injected command. The injection surface is structurally eliminated.
Let's break down the specific arguments in the fix:
| Argument | Purpose |
|---|---|
"cmd.exe" |
The executable to run |
"/c" |
Tells cmd.exe to execute the following command and then terminate |
"start" |
The Windows start command, which opens a file with its associated program |
"" |
Window title argument required by start when a path follows (prevents path parsing issues) |
applicationFile.getAbsolutePath() |
The fully resolved, absolute path to the file — passed as a discrete token |
The use of .getAbsolutePath() is an additional improvement. It resolves the file object to its canonical absolute path before passing it, reducing ambiguity and preventing relative path traversal tricks.
Additional context: the two forms of Runtime.exec()
It is worth noting that Runtime.exec() itself has an overload that accepts a String[] array, which also avoids shell interpretation:
// Also safe — array form bypasses shell parsing
Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", "start", "", applicationFile.getAbsolutePath()});
However, ProcessBuilder is the modern, preferred API because it provides better control over the process environment, working directory, and I/O streams, and its intent is more explicit and readable.
Prevention & Best Practices
1. Never concatenate input into shell command strings
The moment you write "some command " + userInput, you have created a potential injection point. Treat any string concatenation into a command as a red flag that demands immediate review.
2. Always use ProcessBuilder with tokenized arguments
// UNSAFE — string concatenation
Runtime.getRuntime().exec("cmd.exe /c " + userInput);
// SAFE — tokenized arguments, no shell interpretation
new ProcessBuilder("cmd.exe", "/c", userInput).start();
3. Validate file paths with an allowlist
Even with ProcessBuilder, it is good practice to validate that applicationFile resolves to an expected directory:
Path resolved = applicationFile.toPath().toRealPath();
Path allowed = Paths.get("C:\\expected\\app\\directory");
if (!resolved.startsWith(allowed)) {
throw new SecurityException("File path outside allowed directory: " + resolved);
}
4. Apply the principle of least privilege
Run Java applications with the minimum OS privileges required. If the process does not need to create users or write to system directories, ensure the OS user account it runs under cannot do so either. This limits the blast radius of any successful injection.
5. Use static analysis in your CI pipeline
Tools that can detect this pattern include:
- Semgrep with the
java.lang.security.audit.command-injection-formatted-runtime-execrule - SpotBugs with the FindSecBugs plugin (detects
COMMAND_INJECTION) - SonarQube (Java security rules for OS command injection)
- Orbis AppSec (detected and fixed this exact vulnerability automatically)
6. Reference standards
- OWASP Command Injection
- OWASP OS Command Injection Defense Cheat Sheet
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
Key Takeaways
Runtime.getRuntime().exec(String)with concatenated input is never safe — even when you think the input is controlled, useProcessBuilderwith tokenized arguments instead.cmd.exeinterprets metacharacters (&,|,;,&&,||) in its arguments, so any code path that invokescmd.exewith user-influenced data is a command injection risk.- The fix in
App.javais structurally sound becauseProcessBuilderbypasses shell interpretation entirely — it is not a sanitization fix, it is an architectural fix. - The empty string
""as the fourth argument tostartis a necessary Windows quirk:starttreats the first quoted argument as a window title, so an empty title must be passed explicitly to prevent the file path from being misinterpreted. - Page object and test framework code runs in CI/CD pipelines where process privileges are often elevated — command injection in test infrastructure can compromise the entire build supply chain.
How Orbis AppSec Detected This
- Source: The
applicationFilevariable inApp.java, which represents a file path that can be influenced by external configuration or test inputs - Sink:
Runtime.getRuntime().exec("cmd.exe start " + applicationFile)atpage-object/src/main/java/com/iluwatar/pageobject/App.java:80— a direct OS command execution call with concatenated, unsanitized input - Missing control: No allowlist validation, no metacharacter escaping, and no use of a shell-bypass API before the value was passed to
exec() - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Replaced
Runtime.getRuntime().exec()withnew ProcessBuilder("cmd.exe", "/c", "start", "", applicationFile.getAbsolutePath()).start(), passing arguments as discrete tokens to eliminate shell interpretation
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Command injection through Runtime.exec() is one of the most dangerous and most preventable vulnerabilities in Java. The pattern is seductive — it looks like you are just running a shell command, and for benign inputs it works exactly as expected. But the moment an attacker controls any part of that concatenated string, the consequences can be catastrophic: full system compromise, data exfiltration, and in CI/CD environments, supply chain attacks.
The fix demonstrated here — replacing Runtime.exec() with a properly tokenized ProcessBuilder call — is not a workaround. It is the architecturally correct approach. By passing arguments as discrete tokens rather than as a shell string, the injection surface is eliminated at the structural level, not patched over with fragile sanitization logic.
If your codebase uses Runtime.exec() with any form of string concatenation, treat it as a critical finding and migrate to ProcessBuilder immediately.