FlawOpen

flawopen.com/command-injection/Swift

● CWE-78 · Critical
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 arguments
Process runs the file at executableURL directly and passes each element of arguments as one argument, with no shell in between, so ;, | and $( ) in an element are plain characters.
/bin/sh -c with string interpolation
Pointing executableURL at /bin/sh and building the -c script 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/env searches PATH for its first argument and runs it, so arguments = [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; in git log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.
terminationHandler with a timeout
Process has no built-in timeout and waitUntilExit() can block forever. Signal a DispatchSemaphore from terminationHandler, wait with a deadline, and call terminate() when it passes.

Framework mappings

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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
    }
}
HARDENED SECURE PATCH
// 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

Sources