Web Security's own domain covered SQL injection in depth — untrusted input smuggling structure into a query it was never meant to control. The exact same mechanism shows up anywhere code builds a SHELL command from untrusted input, and it's at least as dangerous, because the shell it reaches has far more power than a database connection does.
3 min read
import os
def ping_host(hostname):
os.system(f"ping -c 1 {hostname}") # hostname comes straight from a user-submitted formIf a user submits example.com; rm -rf /important-data, the resulting shell command becomes ping -c 1 example.com; rm -rf /important-data — the shell interprets ; as a command separator and dutifully runs both commands, exactly as if a developer had typed them by hand. This is structurally identical to SQL injection's core mechanism (untrusted data treated as code instead of as data), just pointed at an operating system shell instead of a database engine.
A database connection's privileges are usually scoped to data operations; an OS shell inherits the full privileges of the process running it — reading any file the application's user account can read, making outbound network connections, installing software, or deleting data entirely unrelated to the application. A successful SQL injection compromises a database; a successful command injection can compromise the entire machine the application runs on.
os.system() callCommand injection risk exists anywhere user input flows into a function that invokes a shell: subprocess.run(cmd, shell=True) in Python, exec()/child_process.exec() in Node.js, backtick execution in Ruby or PHP, or even an innocuous-looking image-processing library that shells out to ImageMagick or ffmpeg internally with a filename the application didn't sanitize. The risk isn't tied to one specific function name — it's tied to any code path where untrusted data reaches a shell interpreter.
import subprocess
def ping_host(hostname):
# Pass arguments as a LIST — no shell is invoked, no string parsing, no injection surface
subprocess.run(["ping", "-c", "1", hostname], shell=False)Passing arguments as a list directly to the target program, with shell=False (the actual default in most languages' subprocess APIs — shell=True has to be opted into explicitly), means the hostname is passed as one literal argument to ping, never interpreted by a shell at all. There's no ;, no |, no backtick for an attacker to exploit, because no shell ever parses the string in the first place. This is the exact same principle as SQL injection's fix — parameterization, not string concatenation — applied to a different interpreter.
Some real use cases need actual shell features (pipes, globbing, environment-variable expansion) that a plain argument list can't express. In that narrow case, the input must be validated against a strict allowlist of acceptable characters or values before it ever reaches the shell call — the same allowlist-over-blocklist principle Web Security's own lessons established repeatedly, applied here to the much higher-stakes context of shell metacharacters rather than URL patterns.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What makes os.system(f"ping -c 1 {hostname}") vulnerable to command injection?
2. Why is command injection often considered worse than SQL injection?
3. What is the actual fix for command injection, parallel to SQL injection's parameterized queries?
Application Security & Secure Coding