FlawOpen

flawopen.com/command-injection/Kotlin

● CWE-78 · Critical
FlawOpen Security Research

OS Command Injection in Kotlin (ProcessBuilder)

A Kotlin string template inside sh -c, request-supplied environment variables such as GIT_SSH_COMMAND, or a git clone URL that is really an option or an unsafe transport lets a request run code on your Kotlin server (CWE-78). How each one works and how ProcessBuilder with a list, a cleared environment and Git's protocol.allow stop it.

💡 Plain English Explainer (ELI5)

A workshop accepts a job card to build a shelf. The card’s text is passed to a command reader, but it can also rewrite the workshop’s startup labels and choose the repository transport. A note that was meant to be data can then change which helper runs or what the next tool loads. Kotlin’s ProcessBuilder needs more than separate arguments: keep its environment clean, allow only named build variables, and restrict Git to HTTPS.

Core Concepts & Subsystem Terms

ProcessBuilder(listOf(...))
Each list element becomes exactly one argument and no shell is involved, so ;, | and $( ) inside an element are plain characters. Pass a fixed program name or absolute path as the first element.
String templates inside sh -c
"ping -c 1 $host" looks harmless in Kotlin, but handing it to sh -c makes the shell parse whatever host contains. Kotlin templates never escape anything.
ProcessBuilder.environment()
The child inherits the server's entire environment, and some variables tell programs to run other code: GIT_SSH_COMMAND replaces ssh, LD_PRELOAD loads a shared library into every dynamically linked program, and BASH_ENV makes non-interactive bash run a file first. Clear the map and add only allow-listed names.
Git protocol.allow
Git configuration that controls which URL transports clone and fetch may use. ext:: runs a command to reach the remote and file:// reads local repositories; -c protocol.allow=never -c protocol.https.allow=always permits HTTPS only.
Argument injection and Git option boundaries
Separate argv entries prevent shell parsing, but the target program still parses its own options. For an untrusted Git revision, use git rev-parse --verify --end-of-options "$revision": this ends option parsing. -- has command-specific operand or path semantics; in git log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.

Framework mappings

Step-by-Step Attack Flow

Step 1

Request value reaches the helper

A Ktor or Spring route passes host, a map of build variables, or repoUrl into a helper that starts a JVM process with ProcessBuilder.

Step 2

The value shapes the command or its environment

host may be inserted into sh -c; caller-provided variables may be copied into environment(); and repoUrl may be parsed by Git as an option or a transport URL.

Step 3

The shell or the program acts on it

The shell can treat separators as commands. make or Git may honor environment variables such as GIT_SSH_COMMAND or LD_PRELOAD; Git may also accept an ext:: transport. Separate arguments alone do not clear the inherited environment or constrain Git’s protocols.

Step 4

The attacker runs code on the server

A child process can access files, credentials, and network services available to the app. Use fixed executables, a cleared environment with a small allow-list, validated values, -- before positional input, HTTPS-only Git protocols, and a timeout.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// Tools.kt: each function lets request data change what runs
package com.example.tools

import java.io.File

object Tools {
    // the template builds a script that sh -c parses, ; and all
    fun ping(host: String): Process =
        ProcessBuilder("sh", "-c", "ping -c 1 $host").start()

    // GIT_SSH_COMMAND or LD_PRELOAD from the request run other code
    fun build(workDir: File, env: Map<String, String>): Process =
        ProcessBuilder("make", "build")
            .directory(workDir)
            .apply { environment().putAll(env) }
            .start()

    // repoUrl can be an option such as --upload-pack=... or an ext:: URL
    fun clone(repoUrl: String, target: File): Process =
        ProcessBuilder("git", "clone", repoUrl, target.path).start()
}
HARDENED SECURE PATCH
// Tools.kt: argument lists, a cleared environment and HTTPS-only Git
package com.example.tools

import java.io.File
import java.net.URI
import java.util.concurrent.TimeUnit

object Tools {
    private val HOST = Regex("[A-Za-z0-9][A-Za-z0-9.-]{0,252}")
    private val ALLOWED_ENV = setOf("BUILD_PROFILE", "LOG_LEVEL")
    private val ENV_VALUE = Regex("[A-Za-z0-9._-]{0,64}")

    // fixed PATH, only allow-listed variables, output to a file, time limit
    private fun run(command: List<String>, workDir: File? = null, env: Map<String, String> = emptyMap()): String {
        val output = File.createTempFile("tool", ".out")
        try {
            val process = ProcessBuilder(command)
                .directory(workDir)
                .redirectErrorStream(true)
                .redirectOutput(output)
                .apply {
                    environment().clear()
                    environment()["PATH"] = "/usr/local/bin:/usr/bin:/bin"
                    environment().putAll(env)
                }
                .start()
            if (!process.waitFor(60, TimeUnit.SECONDS)) {
                process.destroyForcibly()
                throw IllegalStateException("command timed out")
            }
            return output.readText()
        } finally {
            output.delete()
        }
    }

    // ping runs directly; host is one validated argument
    fun ping(host: String): String {
        require(HOST.matches(host)) { "invalid host name" }
        return run(listOf("ping", "-c", "1", host))
    }

    // only named variables with simple values reach make
    fun build(workDir: File, env: Map<String, String>): String {
        require(env.all { (name, value) -> name in ALLOWED_ENV && ENV_VALUE.matches(value) }) {
            "environment variable not allowed"
        }
        return run(listOf("make", "build"), workDir, env)
    }

    // https only, other transports disabled, -- before the URL
    fun clone(repoUrl: String, target: File): String {
        val uri = URI(repoUrl)
        require(uri.scheme == "https" && !uri.host.isNullOrEmpty()) { "only https:// repositories are allowed" }
        return run(
            listOf(
                "git", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always",
                "clone", "--", uri.toString(), target.path,
            ),
        )
    }
}

Engineering & System Hardening Checklist

Sources