flawopen.com/command-injection/Swift
OS Command Injection in Swift (Foundation Process)
Setting Process.executableURL to /bin/sh with an interpolated -c script, launching /usr/bin/env with a program name from the request, or passing a value that starts with a dash lets a request run code on your Swift server or Mac app (CWE-78). Why Process with an arguments array is safe, where that stops, and how to add the timeout Process lacks.
A rail dispatcher receives a destination and a list of stops. If a passenger’s free-form note is sent to the signal box as a script, punctuation can become another instruction. If the passenger can choose which control program runs, separating the note into fields does not help. Swift’s Process makes both choices visible: executableURL selects the program and arguments supplies its separate arguments. Fix both with a fixed executable, validated argument values, and a code-defined tool allow-list.
Core Concepts & Subsystem Terms
Process.executableURL and argumentsProcessruns the file atexecutableURLdirectly and passes each element ofargumentsas one argument, with no shell in between, so;,|and$( )in an element are plain characters./bin/sh -c with string interpolation- Pointing
executableURLat/bin/shand building the-cscript with\(host)hands the whole string to the shell, which parses any separators and substitutions the user supplied. /usr/bin/env as the executable/usr/bin/envsearchesPATHfor its first argument and runs it, soarguments = [tool, file]lets the request choose the program. Use an absolute path picked from a table in code.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. terminationHandler with a timeoutProcesshas no built-in timeout andwaitUntilExit()can block forever. Signal aDispatchSemaphorefromterminationHandler, wait with a deadline, and callterminate()when it passes.
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 Vapor route or app URLSchemeHandler reads host, tool, or ref and passes the value to a helper that configures Process.executableURL and Process.arguments.
The value becomes part of a command
The ping helper runs /bin/sh -c with interpolated host; the optimizer sends caller-selected tool through /usr/bin/env; the Git helper passes ref where Git can parse an option.
The shell or the program interprets it
The shell treats separators as commands, /usr/bin/env launches the named binary, or Git treats a leading option as a flag. Swift’s argument array avoids shell-style string splitting only when no shell is invoked; it does not authorize the selected executable or neutralize target-program options.
The attacker runs code on the host
The child can use that account’s files, environment, reachable services, and on macOS potentially accessible Keychain-backed data. Use absolute executable paths, an allow-list, validated values, separate arguments, and a bounded timeout.
Source Code: Flaw vs. Secure Implementation
// Tools.swift: each function lets request data change what runs
import Foundation
enum Tools {
// the interpolated -c script is parsed by sh, ; and all
static func ping(_ host: String) throws -> Process {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "ping -c 1 \(host)"]
try process.run()
return process
}
// env runs whatever program the request names
static func optimize(tool: String, file: String) throws -> Process {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = [tool, file]
try process.run()
return process
}
// no shell, but a ref starting with - is read as a Git option
static func commitLog(_ ref: String) throws -> Process {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["log", ref]
try process.run()
return process
}
}
// Tools.swift: absolute paths, argument arrays, validation and a timeout
import Foundation
enum ToolError: Error {
case invalidInput(String)
case timedOut
}
enum Tools {
private static let optimizers: [String: String] = [
"png": "/usr/bin/optipng",
"jpg": "/usr/bin/jpegoptim",
]
// no shell, output to a file, terminate after 5 seconds
private static func run(_ executable: String, _ arguments: [String]) throws -> String {
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
defer { try? FileManager.default.removeItem(at: outputURL) }
let output = try FileHandle(forWritingTo: outputURL)
defer { try? output.close() }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardOutput = output
process.standardError = FileHandle.nullDevice
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
try process.run()
if finished.wait(timeout: .now() + 5) == .timedOut {
process.terminate()
throw ToolError.timedOut
}
return try String(contentsOf: outputURL, encoding: .utf8)
}
private static func matches(_ value: String, _ pattern: String) -> Bool {
value.range(of: pattern, options: .regularExpression) != nil
}
// ping runs directly; host is one validated argument
static func ping(_ host: String) throws -> String {
guard matches(host, #"\A[A-Za-z0-9][A-Za-z0-9.-]{0,252}\z"#) else {
throw ToolError.invalidInput("host name")
}
return try run("/sbin/ping", ["-c", "1", host])
}
// the format selects a fixed absolute path
static func optimize(format: String, file: String) throws -> String {
guard let tool = optimizers[format], !file.hasPrefix("-") else {
throw ToolError.invalidInput("format or file name")
}
return try run(tool, [file])
}
// --end-of-options makes Git treat ref as a revision
static func commitLog(_ ref: String) throws -> String {
guard matches(ref, #"\A[A-Za-z0-9][A-Za-z0-9._/-]{0,99}\z"#) else {
throw ToolError.invalidInput("ref")
}
return try run("/usr/bin/git", ["log", "--end-of-options", ref, "--"])
}
}
Engineering & System Hardening Checklist
- ✓Add a SwiftLint
custom_rulesregex that flagsexecutableURLvalues of/bin/sh,/bin/bashor/usr/bin/env, andargumentsarrays that contain"-c". - ✓Launch programs by absolute path with a separate
argumentsarray, and map any user choice of tool through a dictionary defined in code. - ✓Reject option-like values by default. For an untrusted Git revision, call
git rev-parse --verify --end-of-options "$revision"; use--only with the operand or path meaning documented by that exact command. - ✓Give every
Processa deadline throughterminationHandlerand aDispatchSemaphore, callterminate()on timeout, and write output to a file so the child cannot block on a full pipe. - ✓Run the server as an unprivileged user, or enable the App Sandbox for Mac apps, so a launched process cannot reach more than the task needs.
Sources
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Apple Developer — Process
- Apple Developer — Process.terminationHandler
- SwiftLint — custom_rules
- Git — git rev-parse --end-of-options
- OWASP OS Command Injection Defense Cheat Sheet
- POSIX Utility Syntax Guidelines
- Git — Command-Line Interface Conventions
- Git — git log