Back to Blog
critical SEVERITY7 min read

How command injection happens in Kotlin/Android and how to fix it

V2rayNG's RootShell.kt built root shell commands by concatenating an unescaped file path directly into a string passed to `su -c`, creating a critical command injection risk (CWE-78). The fix restricts the `exec()` API to internal use only and single-quote-escapes the file path before it ever reaches the root shell.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Kotlin/Android where `RootShell.exec()` passed a raw command string into `ProcessBuilder("su", "-c", command)`, letting shell metacharacters be interpreted with root privileges. The fix makes `exec()` private so it can't be called with arbitrary external input, and it single-quote-escapes the file path in `runScript()` before building the command string, preventing shell metacharacter interpretation.

Vulnerability at a Glance

cweCWE-78
fixMade exec() private to restrict callers, and single-quote-escaped the file path before embedding it in the shell command string
riskArbitrary command execution with root privileges via `su -c`
languageKotlin (Android)
root causeUnescaped/unvalidated string concatenated into a shell command executed via ProcessBuilder("su", "-c", command)
vulnerabilityCommand Injection

In V2rayNG, a root shell helper trusted string concatenation a little too much

In V2rayNG's Android codebase, we found a critical command injection vulnerability in RootShell.kt — the utility object responsible for running privileged commands via Android's su binary. The exec() method took a command: String and handed it straight to ProcessBuilder("su", "-c", command). Because su -c interprets its argument through a shell, any shell metacharacters inside that string get executed with root privileges. Worse, exec() was public, meaning any code — now or in the future — could call it with attacker-influenced data and trigger arbitrary command execution as root.

This matters a lot for VPN apps like V2rayNG that legitimately need root access for certain networking features. When root-level tooling is exposed through a loosely-typed string API, a single unescaped path, config value, or IPC message can turn a helper utility into a full device compromise vector.

The Vulnerability Explained

Here's the vulnerable code path before the fix:

fun runScript(script: String): Result {
    val file = File(...).apply {
        writeText(script)
        setExecutable(true, false)
    }
    return exec("sh ${file.absolutePath}")
}

fun exec(command: String, timeoutSeconds: Long = 30): Result {
    return try {
        val process = ProcessBuilder("su", "-c", command)
            .redirectErrorStream(true)
        ...

Two problems stack up here:

  1. runScript() builds a command via raw string interpolation. file.absolutePath is dropped directly into "sh ${file.absolutePath}" with no quoting or escaping. If that path ever contained a space, a single quote, a semicolon, or backticks — whether from a customized data directory, a symlink, or a compromised storage location — the resulting string could be reinterpreted by the shell in unexpected ways.
  2. exec() was public and accepted any string. Because ProcessBuilder("su", "-c", command) passes command to a shell, exec() was effectively a general-purpose "run anything as root" function. Any future caller — a settings screen, a config importer, an IPC handler — that passed user-influenced text into exec() would immediately create a full command injection primitive.

Example attack scenario: Imagine a future feature (or a supply-chain-compromised dependency) calls RootShell.exec(userSuppliedProfileName) to tag a log file. If userSuppliedProfileName were something like:

myprofile; rm -rf /data & 

su -c would happily execute myprofile, then rm -rf /data as root on the device. Even without a directly malicious caller, the runScript() path itself was building sh <path> without quoting — meaning a maliciously named or symlinked file path could inject shell syntax into a root-executed command.

The real-world impact: since this code runs with su, exploitation isn't limited to app-sandbox data — it's a path to root-level code execution, arbitrary file access, and full device compromise on rooted Android devices running V2rayNG.

The Fix

The PR makes two complementary changes to close both the exposure surface and the injection point:

1. Restrict the API surface — exec() is now private:

- fun exec(command: String, timeoutSeconds: Long = 30): Result {
+ private fun exec(command: String, timeoutSeconds: Long = 30): Result {

This is a critical containment step. By making exec() private, the module guarantees that raw shell commands built from arbitrary strings can never be invoked from outside RootShell.kt. Any future contributor who wants to run a root command must go through the safer, controlled entry points (runScript()), rather than being tempted to hand-build a command string elsewhere in the codebase and call exec() directly.

2. Escape the file path before it reaches the shell:

- return exec("sh ${file.absolutePath}")
+ val safePath = file.absolutePath.replace("'", "'\\''")
+ return exec("sh '$safePath'")

This applies the standard POSIX shell single-quote escaping technique: wrap the value in single quotes, and for any embedded single quote, replace it with '\'' (close the quote, insert an escaped literal quote, reopen the quote). Wrapping safePath in single quotes means the shell treats the entire path as one literal argument — spaces, semicolons, backticks, and $() inside the path are no longer interpreted as shell syntax, they're just characters in a string.

Together, these changes mean:
- The only string ever passed to su -c from this codebase is one that has been explicitly quoted and escaped.
- No external caller can bypass that protection by calling exec() directly with untrusted input, because exec() is no longer accessible outside the file.

Key Takeaways

  • RootShell.exec() executed strings through su -c, meaning any unescaped shell metacharacter in the input became a root-privileged command — this is a textbook CWE-78 command injection.
  • runScript() was vulnerable even without a malicious caller: it interpolated file.absolutePath directly into "sh ${file.absolutePath}" without quoting.
  • Making exec() private closes off the injection surface for any future or external caller in the V2rayNG codebase.
  • The fix uses standard POSIX single-quote escaping (''\'', wrapped in '...') to neutralize shell metacharacters in the file path before it reaches su -c.
  • Root-level utilities like RootShell.kt deserve extra scrutiny — because the blast radius of a command injection here is full root access, not just app-sandbox compromise.

How Orbis AppSec Detected This

  • Source: The command parameter of the public RootShell.exec() method, and the interpolated file.absolutePath value inside runScript().
  • Sink: ProcessBuilder("su", "-c", command) in RootShell.kt:34, which executes the string through a root shell.
  • Missing control: No shell escaping/quoting of dynamic values before command-string construction, and no access restriction preventing external callers from invoking exec() with arbitrary input.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command.
  • Fix: exec() was made private, and runScript() now single-quote-escapes the file path before embedding it in the sh '<path>' command string.

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 vulnerability is a strong reminder that "internal" helper functions running with elevated privileges — like RootShell.exec() calling su -c — need to be treated as high-risk attack surface, not convenience utilities. A single unescaped string concatenation, "sh ${file.absolutePath}", was enough to turn a legitimate root-access feature into a potential root command injection vector. The fix combines two proven mitigations: restricting API visibility so untrusted callers can't reach the dangerous sink, and properly escaping dynamic values before they're embedded in a shell command. Any codebase that shells out with elevated privileges should apply both principles — least-privilege API design and rigorous input escaping — as standard practice.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #5951

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How Command Injection happens in Node.js and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.