Command injection: how it works, how testers prove it, how to fix it
OS command injection is a flaw where an application builds an operating system command from user input and an attacker changes what runs. Fix it by not calling a shell: use a library function, or pass the program and each argument separately with no shell. Testers prove it with a harmless command, a matched time delay or a callback, and controls.
Command injection, also called OS command injection, is a flaw where an application builds an operating system command from input that a user controls, so an attacker can change which command runs. MITRE describes the weakness as CWE-78: the product "constructs all or part of an OS command using externally-influenced input from an upstream component" and does not neutralize special characters that could change the command. The attacker's commands run with the privileges of the application.
Testing for command injection is part of web application penetration testing and API penetration testing. Testing needs written authorization from the system owner.
What is OS command injection?#
OS command injection happens when an application passes user input to a shell or to a program and the input changes the command's meaning. The usual cause is string concatenation: the developer builds one command line from fixed text plus a value, and the shell parses the whole string.
Typical features that run commands are network diagnostics such as ping and traceroute, file and image converters, PDF generators, archive tools, backup and export jobs, and wrappers around git or DNS tools. CISA and the FBI call this class of bug preventable, because it comes from failing to separate user input from the contents of a command. Their July 2024 Secure by Design alert names three exploited edge-device flaws: CVE-2024-20399, CVE-2024-3400 and CVE-2024-21887.
OWASP lists injection as A05 in the Top 10:2025. Code injection, covered below, is a neighbouring bug with a different fix.
How does command injection work?#
Command injection works because a shell treats some characters as operators. If user input reaches the shell unquoted, the characters ;, |, & and a newline end one command and start another. The application then runs both.
Vulnerable ping tool (fictional app)
This is a demonstration. NetDesk is a fictional admin app on app.example.com, and no real customer system was tested.
The app has a "check host" page. The handler builds a shell command from the host name.
# Vulnerable: one string, parsed by a shell
@app.get("/api/ping")
def ping():
host = request.args["host"]
out = subprocess.check_output("ping -c 1 " + host, shell=True, text=True)
return {"result": out}
Normal request:
GET /api/ping?host=db1.example.com HTTP/1.1
Host: app.example.com
A tester with authorization sends a harmless second command, id, which prints the account the app runs as:
GET /api/ping?host=db1.example.com;id HTTP/1.1
Host: app.example.com
If the response contains the ping output followed by a line such as uid=1001(netdesk), the shell ran the tester's command. That response, saved with its timestamp, is the evidence.
The same flaw exists without a visible shell in your own code. If the application starts a program directly and puts user input in the argument list, the attacker cannot chain commands but can still supply an option. That is argument injection, covered below.
Which characters and injection points matter?#
The injection characters depend on the shell. PortSwigger's academy documents command separators for Unix and Windows. This table follows that structure.
| Purpose | Linux and macOS shells | Windows cmd.exe |
|---|---|---|
| Run a second command after the first | ; and newline |
& and newline |
| Run the second only if the first succeeds | && |
&& |
| Pipe output of one command into another | | |
| |
| Run the second only if the first fails | || |
|| |
| Insert the output of a command | backticks and $(...) |
no equivalent |
Testers look first at parameters that look like host names, file names, paths, formats and options, in features that wrap an external tool. Less obvious places include the file name of an upload that a background job later processes, HTTP headers copied into log scripts, and values stored in the database and used later by a scheduled job. A value can be injected in one request and run in another.
What is blind command injection?#
Blind command injection is a case where the command runs but its output is not in the HTTP response. Many real cases are blind, because the application only reports "done" or an error.
PortSwigger describes three ways to detect it. Each gives evidence that a reader can check.
| Technique | What the tester sends | Evidence |
|---|---|---|
| Time delay | A command such as sleep 10, with a matched baseline request |
Response time rises by about the delay on repeats, and does not rise on the control |
| Output redirection | A command that writes harmless output to a file in a web-readable folder | The tester fetches the file and reads the content back |
| Out-of-band (OAST) | A command that makes a DNS lookup to a host the tester owns | The listener logs a lookup from the target network, with a timestamp |
Time delays need controls. The same request with sleep 0, or with a slow but harmless value, shows whether the application is simply slow. Repeat the test three times. If the delay does not follow the number you send, drop the finding.
What is argument injection, and why does escaping fail?#
Argument injection is a case where the attacker cannot add a new command but can add an option to the one the application starts. OWASP's cheat sheet states: "Every OS Command Injection is also an Argument Injection."
Escaping &, | and ; before system("curl " . escape($url)) stops chained commands. Input such as --help is still read by curl as an option. Depending on the program, an unwanted option can disclose information or lead to code execution. OWASP's PHP example shows the difference. escapeshellcmd() locks the command name but allows unlimited parameters, and an attacker can inject --directory-prefix=. into a wget call. escapeshellarg() quotes the whole value as one argument, so that attack fails. The single argument can still start with a dash, so validate it too.
Two defenses help. Put -- before user-controlled values where the program supports it, and validate each value against an allow-list. OWASP warns that for curl, -- does not stop extra URL operands, so pass exactly one validated URL, add --globoff, and apply SSRF protections.
Which real incidents involved command injection?#
Command injection has caused some of the most serious edge-device and server compromises. Two cases have public records.
| CVE | Product | Published (NVD) | What the record says |
|---|---|---|---|
| CVE-2014-6271 (Shellshock) | GNU Bash through 4.3 | 24 September 2014 | Bash processed trailing strings after function definitions in environment variables, so remote attackers could execute code through vectors such as Apache mod_cgi and OpenSSH ForceCommand. NVD maps it to CWE-78 and gives a CVSS v3.1 score of 9.8 |
| CVE-2024-3400 | Palo Alto Networks PAN-OS, GlobalProtect | 12 April 2024 | NVD describes "a command injection as a result of arbitrary file creation" that may let an unauthenticated attacker execute arbitrary code with root privileges on the firewall. CVSS v3.1 score 10.0. NVD lists a CISA Known Exploited Vulnerabilities entry dated 12 April 2024 |
Shellshock shows that the shell does not have to be called by your own code: an environment variable crossed a trust boundary into Bash. CISA's alert names CVE-2024-3400 among the flaws used against network edge devices.
How do penetration testers detect and prove command injection?#
Testers prove command injection with a harmless command, a control and a saved response, never with a destructive command. Excession Labs holds to one evidence rule: a finding needs evidence of a real effect, and a bare "success" response is not proof.
The steps for one parameter:
- Send a baseline request and record the status, length and time.
- Send controls: a nonexistent value, and a value with a harmless special character. Both should return an error or a normal result, with no change in time.
- Send the proof command (
id, or a time delay) and compare with the baseline. - For blind cases, use the out-of-band callback or a read-back of a file the test created.
- Save the request and response with timestamps. Stop at the proof command. Do not read secrets, list home directories or change data.
Controls that held belong in the report. If the application rejects shell characters in one parameter and handles another correctly, say so, so a later fix does not break the working protection. When a control shows the first result was misleading, we downgrade or drop the finding.
Write-ups label evidence: "Observed" for a reproduced result, and a separate label for inferred paths. Destructive proofs use only test-owned accounts and records.
How do you prevent command injection?#
Prevent command injection by not starting a shell, and by using a library function instead of an external program where one exists. If you must run a program, pass the program path and each argument separately, validate each argument, and run the process with the lowest privileges possible. This is the order in OWASP's cheat sheet.
The CISA and FBI alert gives the same advice and shows os.mkdir() in place of shelling out to mkdir. It suggests pull-request rules that block os.system, string-form subprocess.run and shell=True.
Python
import re, subprocess
HOST = re.compile(r"[A-Za-z0-9.-]{1,253}")
def ping(host):
if not HOST.fullmatch(host) or host.startswith("-"):
raise ValueError("bad host")
return subprocess.run(["/bin/ping", "-c", "1", host],
capture_output=True, text=True, timeout=5, check=False)
The Python documentation states that the library does not choose a shell on its own, so metacharacters "can safely be passed to child processes". It says a sequence of arguments is preferred.
Node.js
const { execFile } = require("node:child_process");
const HOST = /^[A-Za-z0-9.-]{1,253}$/;
function ping(host, cb) {
if (!HOST.test(host) || host.startsWith("-")) return cb(new Error("bad host"));
execFile("/bin/ping", ["-c", "1", host], { timeout: 5000 }, cb);
}
The Node.js documentation says exec() runs the command in a shell and warns never to pass unsanitized user input to it. execFile() spawns the file directly, without a shell by default. Do not set shell: true.
Java
if (!host.matches("[A-Za-z0-9.-]{1,253}") || host.startsWith("-")) throw new IllegalArgumentException();
ProcessBuilder pb = new ProcessBuilder("/bin/ping", "-c", "1", host);
pb.redirectErrorStream(true);
Process p = pb.start();
OWASP notes that Runtime.exec(String) splits only on whitespace and does not invoke a shell, and that it has been deprecated since Java 18. Use ProcessBuilder with separate elements.
PHP
$host = $_GET['host'] ?? '';
if (!preg_match('/^[A-Za-z0-9.-]{1,253}$/D', $host) || $host[0] === '-') { http_response_code(400); exit; }
$out = shell_exec('/bin/ping -c 1 ' . escapeshellarg($host));
PHP's shell_exec has no argument-array form. Prefer escapeshellarg() over escapeshellcmd(), hardcode the command and options, and validate strictly. Where a PHP function does the job, such as gethostbyname, use it instead of a command.
Go
var hostRe = regexp.MustCompile(`^[A-Za-z0-9.-]{1,253}$`)
func ping(host string) ([]byte, error) {
if !hostRe.MatchString(host) || strings.HasPrefix(host, "-") {
return nil, errors.New("bad host")
}
return exec.Command("/bin/ping", "-c", "1", host).Output()
}
The os/exec documentation says the package "intentionally does not invoke the system shell". Injection returns if you write exec.Command("sh", "-c", ...) with user input.
C#
var psi = new ProcessStartInfo {
FileName = "/bin/ping",
UseShellExecute = false,
RedirectStandardOutput = true
};
psi.ArgumentList.Add("-c"); psi.ArgumentList.Add("1"); psi.ArgumentList.Add(host);
using var p = Process.Start(psi);
OWASP advises a fixed, fully qualified file name, UseShellExecute = false, ArgumentList for each argument, validation of each value, and no untrusted input to cmd.exe, powershell.exe or sh -c. .NET Framework has no ArgumentList.
These examples show the pattern. Adapt the allow-list to your inputs and test the code in your own framework. Add a low-privilege service account, a read-only file system where possible, and no network access for the process if it needs none.
How is command injection different from code injection?#
Command injection runs operating system commands, and code injection runs code in the application's own language. In code injection the input reaches eval() or a similar function in PHP, Python or JavaScript, and the attacker's code runs inside the application process. In command injection the input reaches a shell or an external program.
The impact can look the same, since code injection in many languages can also start commands. The fix differs. For code injection, never evaluate input as code. For command injection, do not build a command string. Server-side template injection is a third neighbour, where input becomes template code. See server-side template injection.
What is the command injection testing checklist?#
Use this list on each feature that wraps an external tool, and only inside the agreed scope.
- List every feature that may call a program: ping, traceroute, DNS lookup, converters, thumbnails, PDF and archive tools, git wrappers, import and export jobs.
- For each parameter, record a baseline response, then send controls with harmless special characters and a nonexistent value.
- Try the separators from the table above and compare against the baseline.
- If nothing changes in the response, test blind: a time delay with a matched control, then an out-of-band callback or a read-back of a harmless test file.
- Test argument injection: values that start with a dash, and values with spaces.
- Test stored paths: file names, headers and stored values that a later job processes.
- Test each platform in scope, because Linux and Windows use different separators.
- Stop at the proof command. Do not read secrets or change data.
- Save requests, responses and timestamps. List the controls that held.
- Search the code for
os.system,shell=True,exec(,Runtime.exec,shell_exec,system(andProcess.Startwith string concatenation.
File upload flaws often lead to command injection through file names and converters, so test them together.
When should you get a professional test?#
Get a professional test when your application wraps system tools, accepts files that background jobs process, or runs on appliances and agents where one shell flaw gives root access. Scanners find the obvious cases. Blind and stored cases need controls, read-back and careful judgment about scope.
Excession Labs runs authorized web application penetration tests and API penetration tests. We agree the scope with you in writing, check the evidence for each finding and write the report. You can also apply for the free pentest pilot.
Questions
What is the difference between command injection and code injection?
Command injection runs operating system commands through a shell or a program the application starts. Code injection runs code in the application's own language, such as PHP or Python, inside the application's process. Both give the attacker control, but the fixes differ: command injection is fixed by avoiding the shell, code injection by never evaluating input as code.
Does escaping shell characters prevent command injection?
Escaping blocks chained commands but not argument injection. OWASP states that every OS command injection is also an argument injection. Input such as a leading dash can still be read as an option by the program. Prefer a library function, or pass a fixed program and separate arguments with validation.
What is blind command injection?
In blind command injection the command runs but its output never appears in the response. Testers detect it with a time delay compared against a baseline, with output written to a file the tester can read, or with a callback to a host the tester controls. A response that looks normal does not mean the application is safe.
How do I prevent command injection in Python or Node.js?
In Python, call subprocess.run with a list of arguments and without shell=True. In Node.js, use execFile or spawn with an argument array instead of exec. Both pass each argument to the program directly, so shell metacharacters lose their meaning. Validate each value against an allow-list as a second layer.
Is command injection in the OWASP Top 10?
Yes. In the OWASP Top 10:2025 it falls under A05 Injection. Its weakness class is CWE-78, which MITRE names Improper Neutralization of Special Elements used in an OS Command.
Sources
- OWASP OS Command Injection Defense Cheat Sheet (checked 9 November 2026)
- OWASP Community: Command Injection (checked 9 November 2026)
- OWASP Top 10:2025 (checked 9 November 2026)
- PortSwigger Web Security Academy: OS command injection (checked 9 November 2026)
- CWE-78: Improper Neutralization of Special Elements used in an OS Command (checked 9 November 2026)
- CISA and FBI Secure by Design Alert: Eliminating OS Command Injection Vulnerabilities (checked 9 November 2026)
- NVD: CVE-2014-6271 (Shellshock) (checked 9 November 2026)
- NVD: CVE-2024-3400 (PAN-OS GlobalProtect) (checked 9 November 2026)
- Python documentation: subprocess, security considerations (checked 9 November 2026)
- Node.js documentation: child_process (checked 9 November 2026)
- Go documentation: package os/exec (checked 9 November 2026)