Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix OS command and argument injection in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Run the program itself with each argument as its own string: exec.CommandContext(ctx, tool, "--", name). os/exec never invokes a shell unless you call sh -c, so metacharacters stay characters, and "--" keeps a name that starts with a dash from posing as an option. Never build a string for sh -c. Choose the format from an allowlist map.

Stack
  • Language Go
  • Runtime Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
  • Framework Go standard library 1.27.2 (net/http, os/exec)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. Find every exec.Command and exec.CommandContext whose program is sh, bash or another shell with -c, and every command line built with +, fmt.Sprintf or strings.Join from request data: r.URL.Query(), r.PathValue(), form values and decoded JSON.
  2. Call the program directly and pass each argument as its own string: exec.CommandContext(r.Context(), tool, "--format", flag, "--", name). The context stops the tool when the client goes away.
  3. Put a literal "--" before the first user-controlled operand, so a value that begins with - is an operand and not an option.
  4. For any argument that selects the command, a subcommand, a flag or an enumerated option value, look the caller's choice up in a map and return a sentinel error for anything else; the handler checks it with errors.Is and answers 400.
  5. Pass the tool's absolute path. Since Go 1.19, exec.Command and exec.LookPath refuse to resolve a program through a PATH entry relative to the current directory and return an error that satisfies errors.Is(err, exec.ErrDot).
  6. Log the tool's stderr on the server (Output puts it in the *exec.ExitError when cmd.Stderr is nil) and answer a generic 500; its text helps an attacker tune a payload.
  7. Add regression tests with httptest.NewServer that send this card's payloads (separators, substitution, newline and a leading-dash option) and assert that a marker file in t.TempDir() was never created, next to functional tests for names with spaces, apostrophes and quotes.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.goGo
func (Vulnerable) Greet(ctx context.Context, tool, name string) (string, error) {
	// sh -c parses the whole string: every metacharacter in name is live, and
	// ; | && $( ) backticks and newlines all start new work.
	out, err := exec.CommandContext(ctx, "sh", "-c", tool+" "+name).Output()
	return string(out), err
}

With IMPL=vulnerable, 16 of 16 tests fail (10 exploit tests).

The fix: no shell, and -- before the operand

Fixedimpl/fixed.goGo
func (Fixed) Greet(ctx context.Context, tool, name string) (string, error) {
	// exec.Command starts the program itself; no shell parses the arguments, so
	// metacharacters are just characters. "--" makes everything after it an operand,
	// so a name that starts with a dash cannot pose as an option.
	out, err := exec.CommandContext(ctx, tool, "--", name).Output()
	return string(out), err
}

With IMPL=fixed, 16 of 16 tests pass.

The fix: format allowlist

Fixedimpl/fixed.goGo
// The format picks part of the command itself, which a value can never do
// safely, so map the caller's choice onto a fixed flag value from an allowlist.
var formatFlags = map[string]string{"text": "text", "json": "json"}

func (Fixed) Report(ctx context.Context, tool, name, format string) (string, error) {
	flag, ok := formatFlags[format]
	if !ok {
		return "", fmt.Errorf("%w: unsupported format", ErrRejected)
	}
	out, err := exec.CommandContext(ctx, tool, "--format", flag, "--", name).Output()
	return string(out), err
}

With IMPL=fixed, 16 of 16 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.goGo
// The "obvious" shell separators. Note what is missing: $ ( ) and backticks
// (command substitution), and the leading dash of an injected option.
var shellSeparators = regexp.MustCompile(`[;|&<>\n]`)

func shellQuote(value string) (string, error) {
	if shellSeparators.MatchString(value) {
		return "", fmt.Errorf("%w: illegal character in input", ErrRejected)
	}
	return `"` + value + `"`, nil
}

func (Wrong) Greet(ctx context.Context, tool, name string) (string, error) {
	quoted, err := shellQuote(name)
	if err != nil {
		return "", err
	}
	out, err := exec.CommandContext(ctx, "sh", "-c", tool+" "+quoted).Output()
	return string(out), err
}

With IMPL=wrong, 7 of 16 tests fail (5 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testapp_test.goGo
// Exploit: each payload must stay data; the marker file must never be created.
// Each payload names a fresh marker path; a clean run leaves it absent.

func expectNoMarker(t *testing.T, path string, params func(marker string) url.Values) {
	t.Helper()
	srv := newServer(t)
	marker := filepath.Join(t.TempDir(), "pwned")
	get(t, srv, path, params(marker)) // the tool runs before the response is written
	if _, err := os.Stat(marker); err == nil {
		t.Errorf("marker was created: the payload executed (%v)", params(marker))
	}
}

func TestExploitSemicolonDoesNotStartASecondCommand(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"Alice; touch " + m}} })
}

func TestExploitPipeDoesNotStartASecondCommand(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"Alice | touch " + m}} })
}

func TestExploitAndChainDoesNotStartASecondCommand(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"Alice && touch " + m}} })
}

func TestExploitCommandSubstitutionDoesNotRun(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"Alice$(touch " + m + ")"}} })
}

func TestExploitBackticksDoNotRun(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"Alice`touch " + m + "`"}} })
}

func TestExploitNewlineDoesNotStartASecondCommand(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"Alice\ntouch " + m}} })
}

func TestExploitLeadingOutputOptionCannotWriteAFile(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"--output=" + m}} })
}

func TestExploitLeadingShortOptionCannotWriteAFile(t *testing.T) {
	expectNoMarker(t, "/greeting", func(m string) url.Values { return url.Values{"name": {"-o" + m}} })
}

func TestExploitOptionInjectedThroughFormatIsRejected(t *testing.T) {
	expectNoMarker(t, "/report", func(m string) url.Values {
		return url.Values{"name": {"Alice Martin"}, "format": {"text --output=" + m}}
	})
}

func TestExploitLeadingDashNameIsAnOperandNotAnOption(t *testing.T) {
	expectNoMarker(t, "/report", func(m string) url.Values {
		return url.Values{"name": {"--output=" + m}, "format": {"text"}}
	})
}

Pitfalls

  • sh -c is the whole bug. The os/exec documentation says the package intentionally does not invoke the system shell, so the shell comes back only when the code asks for it.
  • exec.Command still runs one program, but without "--" an operand that starts with - is parsed as an option. Argument injection (CWE-88) needs no shell.
  • Splitting a command line with strings.Fields, or a shell-words package, and passing the pieces to exec.Command lets the caller choose extra arguments: Alice --output=/path becomes two arguments.
  • Wrapping a value in double quotes inside an sh -c string still runs $( ) and backticks, breaks on a value that contains a quote, and does nothing about a leading dash. Go has no shell-quoting function in the standard library; use the argument list instead.
  • Allowlist by value, not with a regular expression over the raw input: a blocklist of shell metacharacters still lets --output= and -o through, because a dash is not a metacharacter.
  • Output returns standard output only. Writing err.Error() or the ExitError's Stderr into the response leaks the tool's messages.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs command-injection/go-nethttp does this for you, in Docker if you do not have Go).
  2. Search for sh -c and bash -c: grep -rn '"-c"' --include=*.go . should find nothing that carries request data. gosec G204's message reads "potential tainted input or cmd arguments" only for the shell calls here.
  3. Against your own endpoint, send name=Alice;touch /tmp/pwned and name=--output=/tmp/pwned and confirm /tmp/pwned is never created.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix OS command and argument injection in Go and net/http
ToolRuleWhat it found here
Semgrepgo.lang.security.audit.dangerous-exec-command.dangerous-exec-commandFlagged all six calls: two each in impl/vulnerable.go, impl/wrong.go and impl/fixed.go. It is an audit rule that matches any exec.CommandContext whose command is not a string literal, or sh -c with a non-literal command; the fixed code runs the tool from a variable path, so it matches too. Treat its fixed.go hits as a prompt to confirm there is no shell.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> .
Semgrepgo.lang.security.audit.dangerous-exec-cmd.dangerous-exec-cmdNo findings: it matches exec.Cmd struct literals, and the card uses exec.CommandContext. The p/golang pack reported nothing on the card either.Checked: Semgrep 1.180.0 (CE)
CodeQLgo/command-injectionTwo results, both sh -c calls in impl/vulnerable.go, traced from r.URL in app.go. It missed impl/wrong.go: CommandInjectionCustomizations.qll treats any regexp match on the value as a barrier guard, so the separator blocklist silences it. Nothing in impl/fixed.go; for a program that is not a shell, sudo or an interpreter, SystemCommandExecutors.qll makes only the command name a sink, so the query does not model argument injection into an arbitrary tool.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165)
gosecG204Audit use of command executionFlagged all six calls (MEDIUM severity, HIGH confidence). Its message separates them: "Subprocess launched with a potential tainted input or cmd arguments" for the sh -c calls in impl/vulnerable.go and impl/wrong.go, and "Subprocess launched with variable" for impl/fixed.go, whose only variable is the tool's path.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec -include=G204,G702 ./...
gosecG702Command injection via taint analysisNo findings: the taint analysis does not follow the request value through the Greeter interface into impl/. On a probe file with the handler and exec.Command("sh", "-c", "./greet.sh "+r.URL.Query().Get("name")) in one function it reported G702 (HIGH severity, HIGH confidence).Checked: gosec 2.29.0

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http, os/exec)
gotest.tools/gotestsum (JUnit report, a go.mod tool)1.13.0
golang.org/x/text (raised for gotestsum)0.42.0
greet.sh (the bundled tool, run by /bin/sh: dash in the Debian trixie image)the same file in every stack of this card

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the command-injection/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go mod download && go build ./... tool'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=fixed \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=vulnerable \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=wrong \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs command-injection/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 22 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
Toolchain
  • go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
  • gotestsum 1.13.0
  • shell /bin/sh is dash in the Debian trixie image
  • semgrep 1.180.0
  • gosec 2.29.0
  • codeql CLI 2.27.1, codeql/go-queries 1.6.12
Matrix
fixed: 16 of 16 tests pass; vulnerable: 16 of 16 tests fail (10 exploit tests); wrong: 7 of 16 tests fail (5 exploit tests)
Test results
fixed 16/16 pass; vulnerable 16/16 fail (10 exploit tests); wrong 7/16 fail (5 exploit tests: $( ), backticks, --output=, -o and the leading-dash name), with --network=none after install; the same on Go 1.26.9 and the same counts as the other stacks of this card.
Dependency freshness
The card needs only the standard library. gotestsum 1.13.0 was published on 2025-09-11; it asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds (x/text/unicode/norm), so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. Go standard library: os/exec (no shell, and executables in the current directory)
  2. Go standard library: exec.Command
  3. Go standard library: exec.ExitError (stderr from Output)
  4. POSIX.1-2024 Utility Syntax Guidelines (Guideline 10: the -- delimiter)
  5. OWASP OS Command Injection Defense Cheat Sheet
  6. CodeQL Go: SystemCommandExecutors.qll (which arguments are command sinks)
  7. CodeQL Go: CommandInjectionCustomizations.qll (regexp checks as barrier guards)
  8. gosec 2.29.0 rules
  9. CWE-88: Argument Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.