Description
When user input becomes part of a shell command, characters such as ;, && and | can be interpreted as command syntax. Go's os/exec does not invoke a shell by default, but calling sh -c or writing strings to a shell's standard input introduces this risk.
Potential impact
- Unintended commands may run with the application process's permissions.
- Files, environment variables and credentials may be read, or data changed.
- Resource-intensive commands may disrupt service. Gaining administrator privileges depends on existing process permissions or additional vulnerabilities.
Remediation
- Prefer standard-library operations. If an external program is needed, pass its executable and arguments separately to
exec.Commandwithout a shell. - Fix executables, subcommands and options in code, and restrict user input to allowed values. Separate arguments do not themselves prevent dangerous options in the target program.
- If a shell is unavoidable, quote each value for that shell and insertion context. Escaping does not validate authorization or the safety of the target operation.
- Check the caller's permissions and apply least privilege, time limits and resource limits.
Examples
Confirm executable paths for the deployment environment. Authentication, authorization, TLS and separate resource isolation are omitted.
Before
go
package main
import (
"io"
"log"
"net/http"
"os/exec"
)
func unsafeHandler(w http.ResponseWriter, r *http.Request) {
// User-supplied command string (for example, name; rm -rf /)
userCmd := r.URL.Query().Get("cmd")
// Before: launch a shell and pass user text to standard input
// Metacharacters such as ;, && and | can introduce extra commands
c := exec.Command("bash")
stdin, err := c.StdinPipe()
if err != nil {
http.Error(w, "pipe error", http.StatusInternalServerError)
return
}
if err := c.Start(); err != nil {
http.Error(w, "start error", http.StatusInternalServerError)
return
}
io.WriteString(stdin, userCmd+"\n")
stdin.Close()
_ = c.Wait()
w.Write([]byte("done"))
}
func main() {
http.HandleFunc("/run", unsafeHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
After
go
package main
import (
"log"
"net/http"
"os/exec"
"regexp"
"time"
"context"
)
// Fixed allowed commands
var allowed = map[string][]string{
"whoami": {"/usr/bin/whoami"},
"date": {"/bin/date", "+%F %T"},
}
// Restrict characters for this user-supplied message argument
var safeText = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,64}$`)
func safeHandler(w http.ResponseWriter, r *http.Request) {
action := r.URL.Query().Get("action")
// 1) Choose the executable and fixed arguments from the allow-list
cmdDef, ok := allowed[action]
if !ok {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
// 2) Validate an optional user-supplied argument against the allow-list
msg := r.URL.Query().Get("msg")
args := []string{}
if msg != "" {
if !safeText.MatchString(msg) {
http.Error(w, "invalid msg", http.StatusBadRequest)
return
}
// Use the predefined printf %s\n <msg> form
cmdDef = []string{"/usr/bin/printf", "%s\n", msg}
}
// 3) Pass separate arguments without a shell
prog := cmdDef[0]
if len(cmdDef) > 1 {
args = cmdDef[1:]
}
// 4) Limit execution time
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, prog, args...).CombinedOutput()
if err != nil {
http.Error(w, "exec error", http.StatusInternalServerError)
return
}
w.Write(out)
}
func main() {
http.HandleFunc("/run", safeHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Explanation:
- Before: User text is written to
bashstandard input, where shell syntax is executed. - After: Fixed executables and arguments are passed without a shell. A valid
actionwith a nonemptymsgswitches toprintfwith a fixed format. Character validation is specific to this limited message use; the two-second timeout does not replace separate memory, output and concurrency limits.