FlawOpen

flawopen.com/command-injection/Go

● CWE-78 · Critical
FlawOpen Security Research

OS Command Injection in Go (os/exec)

Wrapping a command in exec.Command("sh", "-c", ...), letting the request pick the program to run, or passing a value that starts with a dash lets a request run code on your Go server (CWE-78). Why os/exec is safe by default, the three ways Go code gives that up, and the gosec rule that flags them.

💡 Plain English Explainer (ELI5)

Think of a lab instrument with guarded controls: “ping,” “optimize image,” and “show commit.” Go os/exec sends one chosen program separate argument slots, so ;, | and && inside a value stay characters; it does not start a shell. Add sh -c and the value becomes a script for /bin/sh to interpret. Even without a shell, letting a request choose the executable or pass a leading-dash Git option can change what runs. Map choices to fixed program paths, put --end-of-options before an untrusted Git ref, and use exec.CommandContext with a deadline. For a Git revision, git rev-parse --verify --end-of-options "$revision" ends option parsing; -- has separate, command-specific operand or path semantics.

Core Concepts & Subsystem Terms

exec.Command(name, args...)
os/exec never invokes a shell: name is resolved to one program and each element of args is passed to it as a single argument. ;, | and $( ) inside an argument are plain characters.
exec.Command("sh", "-c", ...)
Starting a shell yourself hands the concatenated string back to /bin/sh, which parses separators and substitutions in it. Any user text in that string can add commands.
Program name chosen by the request
Even without a shell, exec.Command(tool, file) runs whatever program tool names. Map a small set of request keys to absolute paths in code instead of accepting a program name from the request.
Argument injection and Git option boundaries
Separate argv entries prevent shell parsing, but the target program still parses its own options. For an untrusted Git revision, use git rev-parse --verify --end-of-options "$revision": this ends option parsing. -- has command-specific operand or path semantics; in git log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.
exec.ErrDot (Go 1.19+)
Since Go 1.19, exec.LookPath and exec.Command refuse to run a program found through a relative PATH entry such as ., returning exec.ErrDot. Do not work around it by adding the current directory back to PATH.

Framework mappings

Step-by-Step Attack Flow

Step 1

Request value reaches the helper

A handler reads host, tool or ref with r.URL.Query().Get(...) and passes the string to a function that starts a process.

Step 2

The value becomes part of a command

"ping -c 1 " + host is handed to sh -c, the request chooses the executable itself, or a value that looks like an option is passed to git. The benign test value 127.0.0.1; id illustrates the shell case.

Step 3

The shell or the program interprets it

sh runs ping and then id; in the other cases the chosen binary runs with the request's argument, or Git treats --output=... as an option.

Step 4

The attacker runs code on the server

The process runs with the service account's permissions: it can read environment variables and mounted secrets, reach internal services and write files.

Source Code: Flaw vs. Secure Implementation

Each process path creates its own bounded context; CommandContext terminates the child when that deadline expires. Optimize accepts only an allowlisted basename and joins it under a fixed upload directory, blocking explicit traversal segments. This assumes untrusted users cannot place symlinks in that directory; path handling is separate from shell injection.

UNPATCHED FLAW
// tools.go: each function below lets request data decide what runs
package tools

import (
	"context"
	"os/exec"
)

// sh -c parses the concatenated string, ; and all
func Ping(ctx context.Context, host string) ([]byte, error) {
	return exec.CommandContext(ctx, "sh", "-c", "ping -c 1 "+host).Output()
}

// the request chooses which program is executed
func Optimize(ctx context.Context, tool, file string) ([]byte, error) {
	return exec.CommandContext(ctx, tool, file).Output()
}

// no shell, but a ref starting with - is read as a Git option
func CommitLog(ctx context.Context, ref string) ([]byte, error) {
	return exec.CommandContext(ctx, "git", "log", ref).Output()
}
HARDENED SECURE PATCH
// tools.go: fixed programs, argument lists, validation and deadlines
package tools

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

var hostRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$`)
var imageNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}\.(png|jpe?g)$`)

var optimizers = map[string]string{
	"png": "/usr/bin/optipng",
	"jpg": "/usr/bin/jpegoptim",
}

// ping runs directly; host is one validated argument
func Ping(ctx context.Context, host string) ([]byte, error) {
	if !hostRe.MatchString(host) {
		return nil, errors.New("invalid host name")
	}
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	return exec.CommandContext(ctx, "ping", "-c", "1", host).Output()
}

// a lookup table maps the format to an absolute path; file is a basename only
func Optimize(ctx context.Context, format, file string) ([]byte, error) {
	tool, ok := optimizers[format]
	if !ok {
		return nil, errors.New("unsupported format")
	}
	if !imageNameRe.MatchString(file) {
		return nil, errors.New("invalid image name")
	}
	input := filepath.Join("/srv/uploads", file)
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	return exec.CommandContext(ctx, tool, input).Output()
}

// --end-of-options makes Git treat ref as a revision
func CommitLog(ctx context.Context, ref string) ([]byte, error) {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	return exec.CommandContext(ctx, "git", "log", "--end-of-options", ref, "--").Output()
}

Engineering & System Hardening Checklist

Sources