flawopen.com/command-injection/Go
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.
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/execnever invokes a shell:nameis resolved to one program and each element ofargsis 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 programtoolnames. 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; ingit 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.LookPathandexec.Commandrefuse to run a program found through a relativePATHentry such as., returningexec.ErrDot. Do not work around it by adding the current directory back toPATH.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-78
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
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.
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.
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.
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.
// 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()
}
// 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
- ✓Enable gosec in golangci-lint and review every G204 finding (command execution with variable input); ban
sh -candbash -cwith non-constant strings. - ✓Pass fixed program names or absolute paths to
exec.Command, and map any user choice through a lookup table defined in code. - ✓Reject option-like values by default. For an untrusted Git revision, call
git rev-parse --verify --end-of-options "$revision"; use--only with the operand or path meaning documented by that exact command. - ✓Use
exec.CommandContextwith a deadline so a hung process cannot tie up the handler. - ✓Run the service as an unprivileged user in a minimal container image without a shell where possible.
- ✓Validate uploaded filenames as basenames against a strict allowlist, then resolve them beneath a fixed directory; do not accept request-supplied paths.
- ✓Keep the upload directory private from attacker-created symlinks; a basename allowlist blocks traversal text but does not by itself constrain symlink resolution.
Sources
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Go — os/exec package
- Go blog — Command PATH security in Go (exec.ErrDot)
- gosec — G204: Audit use of command execution
- Git — git rev-parse --end-of-options
- OWASP OS Command Injection Defense Cheat Sheet
- Go docs — filepath.Join
- POSIX Utility Syntax Guidelines
- Git — Command-Line Interface Conventions
- Git — git log