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
•Technically reviewed by Anupam Mediratta•Published August 31, 2026•Reviewed 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

critical

ffmpeg.Probe() Protocol Abuse in the GetSnapshot Thumbnailer

The local storage driver's thumbnail helpers — `GetSnapshot()` and `resizeImageToBufferWithFFmpegGo()` — handed user-controlled paths to `ffmpeg.Input()` and `ffmpeg.Probe()` with no restriction on which FFmpeg protocols those processes could open. A file that passed the existing `sanitizeFilePath` check (absolute, cleaned, regular file) could still be an HLS playlist or concat script pointing at `http://`, `subfile:` or `file:` targets, turning thumbnail generation into SSRF and arbitrary file

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

high

THiNX Device Platform: Command Injection via source.owner in Git Fetch

The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.