flawopen.com/command-injection/Kotlin
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.
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 tosh -cmakes the shell parse whateverhostcontains. 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_COMMANDreplacesssh,LD_PRELOADloads a shared library into every dynamically linked program, andBASH_ENVmakes non-interactivebashrun a file first. Clear the map and add only allow-listed names. Git protocol.allow- Git configuration that controls which URL transports
cloneandfetchmay use.ext::runs a command to reach the remote andfile://reads local repositories;-c protocol.allow=never -c protocol.https.allow=alwayspermits 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; ingit log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-78
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
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.
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.
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.
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
// 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()
}
// 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
- ✓Add a Semgrep or Detekt rule that flags
ProcessBuilderandRuntime.execcalls containing"sh","bash"or"-c"together with a string template. - ✓Build commands with
ProcessBuilder(listOf(...))and a fixed program; never interpolate request data into a single command string. - ✓Call
environment().clear(), set a fixedPATH, and accept user-defined variables only from an allow-list of names with validated values. - ✓For Git, require an
https://URL parsed withjava.net.URI, pass-c protocol.allow=never -c protocol.https.allow=always, and put--before the URL. This is command-specific: use a delimiter only when the exact executable documents it. Otherwise reject option-like values or use a safer API. - ✓Wait with
waitFor(timeout, unit), calldestroyForcibly()on timeout, and redirect output so the child cannot block on a full pipe.
Sources
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Java SE 21 API — ProcessBuilder.environment()
- Git — git config protocol.allow
- Git — git clone --upload-pack
- Git — environment variables (GIT_SSH_COMMAND)
- OWASP OS Command Injection Defense Cheat Sheet
- POSIX Utility Syntax Guidelines
- Git rev-parse — --end-of-options
- Git — Command-Line Interface Conventions
- Git — git log