Web Application Security · intermediate · ~12 min
**What you will learn** - Explain OS command injection and path traversal as two members of the same injection family: untrusted input crossing into an interpreter as *structure* instead of staying as *data*. - Recognise the vulnerable patterns in server code (a shell command built from a request parameter; a filename taken straight from a query string). - Apply the correct, robust fixes: argument-array (argv) APIs that never invoke a shell, and canonicalized, base-bounded file paths or filename allowlists. - Verify a fix works by proving it *rejects* malicious input and *accepts* legitimate input. - Design detection and logging so these attacks leave a trail, and describe what you must never log. - Run every test safely inside an authorized, isolated lab.
Security objective. The asset you are protecting is the web server itself and the files on it. The threats are (1) an attacker running arbitrary operating-system commands on your server (remote code execution, RCE), and (2) an attacker reading or writing files outside the folder you intended to expose (arbitrary file access). By the end of this lesson you will be able to detect both flaws in code and traffic, and prevent them with fixes you can test.
This lesson builds directly on your prerequisite, "Testing the web: intercepting and shaping requests." There you learned to capture an HTTP request and modify a parameter before it reaches the server. That is exactly the skill an attacker uses here: they take a normal parameter like ?host=8.8.8.8 or ?file=report.pdf and reshape it into ?host=8.8.8.8; id or ?file=../../etc/passwd. This lesson is about what happens on the server side when that reshaped input is trusted.
Two attacks, one root cause:
;, |, &, backticks, $( )) and runs whatever the attacker appended. Impact: remote code execution as the web server's user account.../ climb out of the intended directory, letting the attacker read credentials, source code, and configuration, or in write scenarios overwrite files.Both share the SQL-injection root cause you may have already met: untrusted input becomes part of an interpreter's instructions. The cures rhyme too: for commands, keep input out of the shell entirely by using an argument array; for files, resolve the real (canonical) path and confirm it stays inside an allowed base directory. This lesson is defensive-first: every insecure example is immediately followed by a secure fix, a verification step, and detection guidance. All testing happens only in a local, isolated lab.
In authorized professional work, these two flaw classes are among the most consequential you can find.
/etc/passwd, application source, .env files with database passwords and API keys, private keys, cloud metadata. Even read-only traversal frequently leaks the credentials that lead to a deeper compromise.These bugs recur across languages and frameworks because the underlying mistake — assembling a command or path from strings — is easy to write and hard to spot in review. For a defender, knowing the correct fix matters as much as finding the bug. Weak mitigations (blacklisting ;, stripping ../ once) create a false sense of safety and are routinely bypassed with encoding or alternative syntax. Being able to recommend a fix that is structurally safe, and then demonstrate that the fix rejects attacks while still accepting good input, is what separates a useful security report from a noisy one.
A note on honesty in reporting: passing an automated scanner does not prove these bugs are absent, and no system is ever "completely secure." Your job is to reduce exploitability with defenses you can verify, and to log enough that abuse is detectable.
Each concept below is taught on its own: a definition, a plain explanation, how it works, when it applies (and when not), and a pitfall.
Definition. A vulnerability where attacker-controlled input is placed into a string that is then executed by a system shell, allowing the attacker to run additional operating-system commands.
Plain explanation. Your code wants to run one command (say, ping). It builds the command by pasting user input into a string and hands the whole string to a shell. The shell is a full language interpreter: it treats ;, |, &&, backticks, and $( ) as instructions, not text. So the attacker's input isn't just a hostname anymore — it's more program.
How it works. Given ping -c1 <host> and host = "8.8.8.8; id", the shell sees two commands separated by ;: it pings, then runs id. Metacharacters that enable this include ; (sequence), | (pipe), &/&& (background/AND), ` and $( ) (command substitution), and newlines.
When it applies / when not. It applies any time you invoke a shell (system, popen, os.system, subprocess with shell=True, Runtime.exec on a shell string, backticks in Perl/Ruby) with a string containing input. It does not apply if you never call a shell — passing an argv array to execve/subprocess.run([...]) means there is no shell to interpret metacharacters.
Pitfall. Trying to "escape" or blacklist metacharacters. There are many of them across shells and quoting contexts, and encodings differ; hand-rolled escaping is fragile. The robust fix is to remove the shell from the equation.
Definition. A vulnerability where attacker-controlled input is used to build a filesystem path, and the input contains sequences (../, absolute paths, encoded variants) that move outside the directory the application intended to serve.
Plain explanation. Your code means to serve files from /var/www/files. It builds the path by joining that base with a user-supplied filename. If the filename is ../../etc/passwd, the .. segments walk up the tree and out of the sandbox.
How it works. join("/var/www/files", "../../etc/passwd") resolves to /etc/passwd. Variants used to defeat naive filters include URL-encoding (%2e%2e%2f), double-encoding (%252e%252e%252f), backslashes on Windows (..\), absolute paths (/etc/passwd), and NUL or trailing tricks.
When it applies / when not. It applies wherever a request value influences a file path used for read, write, include, or template loading. It does not apply when you never let input choose the path — e.g., you map an opaque ID (?doc=42) to a server-side path via a lookup table.
Pitfall. Stripping ../ once with a simple replace. Input like ....// becomes ../ after a single removal pass, re-introducing the traversal. Decode-then-check ordering bugs cause similar bypasses. The robust fix is to canonicalize first, then verify containment.
Definition. Injection of any kind is untrusted input crossing a trust boundary and being interpreted as structure (commands, path segments, SQL syntax) rather than remaining inert data.
Plain explanation. The same idea unites SQL injection, command injection, and path traversal. The fix is always "keep input as data": parameterized queries for SQL, argument arrays for commands, canonical bounded paths (or ID lookups) for files. Allowlisting — accepting only known-good values — is the strongest layer wherever the set of valid inputs is small.
Pitfall. Treating these as unrelated bugs with unrelated fixes. If you internalise the data/structure boundary, you will spot new variants (LDAP injection, XML/XPath injection, template injection) using the same lens.
TRUST BOUNDARY (HTTP request)
|
Attacker (browser / proxy) | Web server process
--------------------------- | --------------------------------
Entry points: | Sinks (where input becomes structure):
- ?host= ?ip= ?target= ------+----> shell: system("ping " + host)
- ?file= ?path= ?doc= ------+----> fs: open(base + "/" + file)
|
Assets behind the boundary: | Controls at the boundary:
- OS command execution | - argv arrays (no shell)
- files: /etc/passwd, .env, | - canonical path + base check
source code, private keys | - filename / ID allowlist
- the server's identity/creds | - input validation + logging
The request line is the trust boundary. Everything the client sends is untrusted. The dangerous sinks are the shell and the filesystem. Controls sit at the boundary and at the sink.
Knowledge check.
?host= path, what asset is protected by switching from system("ping " + host) to an argv array, and where is the trust boundary??file=report.pdf, what insecure assumption makes ../../etc/passwd work, and which log field would let you detect the attempt after the fact?; id?The key structural choice in each attack is which API touches the input. Prefer the safe API on the right.
| Danger (shell / raw path) | Safe alternative (argv / bounded path) |
|---|---|
system("ping " + host) |
execvp("ping", (char*[]){"ping","-c1",host,NULL}) — no shell |
subprocess.run(cmd, shell=True) |
subprocess.run(["ping","-c1",host]) — argv list |
open(base + "/" + name) |
resolve real path, then verify it starts with the real base |
Annotated shape of the secure command call (C, lab-safe):
/* No shell is involved: exec* takes an argument vector. */
/* 'host' is ONE argument, so "8.8.8.8; id" is a single, literal */
/* string passed to ping, which simply fails to resolve it. */
char *argv[] = { "ping", "-c", "1", "--", host, NULL };
execvp("ping", argv); /* replaces the child image; run in fork() */
Annotated shape of the secure path check (POSIX):
/* realpath() collapses '..', symlinks, and '.' into a canonical */
/* absolute path. Then a prefix check confirms containment. */
char resolved[PATH_MAX];
realpath(candidate, resolved); /* candidate = base + "/" + name */
int inside = strncmp(resolved, base_real, strlen(base_real)) == 0
&& resolved[strlen(base_real)] == '/';
The -- in the ping argv marks the end of options, so a hostname that begins with - cannot be misread as a flag. The prefix check must also confirm the next character is a path separator, so /var/www/files-secret cannot masquerade as being inside /var/www/files.
These are two file- and OS-level injection attacks. Both can hand the server to an attacker.
The problem appears when an application builds a shell command from user input.
ping -c1 <user_host>
Suppose the attacker sets:
user_host = "8.8.8.8; cat /etc/passwd"
The shell runs the appended cat command as well. The impact is immediate remote code execution as the web server's user account.
Fix: do not pass user data to a shell at all.
Escaping shell metacharacters by hand is fragile and easy to get wrong.
The problem appears when a filename comes from user input and is not constrained.
GET /download?file=report.pdf -> intended
GET /download?file=../../etc/passwd -> reads arbitrary files
The ../ sequences climb out of the intended directory. Encoded forms such as %2e%2e%2f, or double-encoding, can bypass naive filters.
Fix:
../. That approach is bypassable.Both attacks share the same root cause as SQL and NoSQL injection: untrusted input crosses into an interpreter (the shell or the filesystem) as structure.
The cure is always the same idea: keep input as data.
The example is a tiny "ping a host" and "download a file" service in C11. It shows the insecure version, the secure fix, and a verification harness. Compile and run only in a local, isolated lab.
/* WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Command injection: user 'host' is concatenated into a shell string. */
void ping_insecure(const char *host) {
char cmd[256];
snprintf(cmd, sizeof cmd, "ping -c 1 %s", host);
system(cmd); /* shell interprets ; | ` $( ) in 'host' */
}
/* Path traversal: user 'name' is joined to a base with no containment check. */
void read_insecure(const char *name) {
char path[512];
snprintf(path, sizeof path, "/var/lab/files/%s", name);
FILE *f = fopen(path, "r"); /* name = "../../etc/passwd" escapes the base */
if (f) { fclose(f); printf("opened %s\n", path); }
}
ping_insecure("8.8.8.8; id") runs id. read_insecure("../../etc/passwd") opens a file outside the base. Both trust input as structure.
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <unistd.h>
#include <sys/wait.h>
#include <ctype.h>
#define BASE "/var/lab/files"
/* Validate a hostname against a strict allowlist of characters. */
static int valid_host(const char *h) {
if (!h || !*h || strlen(h) > 253) return 0;
for (const char *p = h; *p; ++p)
if (!(isalnum((unsigned char)*p) || *p == '.' || *p == '-'))
return 0; /* rejects ; | ` $ ( ) space etc. */
return 1;
}
/* Run ping with NO shell: exec takes an argument vector. Returns 0 on success. */
int ping_secure(const char *host) {
if (!valid_host(host)) { fprintf(stderr, "reject host\n"); return -1; }
pid_t pid = fork();
if (pid < 0) { perror("fork"); return -1; }
if (pid == 0) {
char *argv[] = { "ping", "-c", "1", "--", (char *)host, NULL };
execvp("ping", argv); /* metacharacters are inert here */
_exit(127); /* only reached if exec fails */
}
int st; if (waitpid(pid, &st, 0) < 0) { perror("waitpid"); return -1; }
return (WIFEXITED(st) && WEXITSTATUS(st) == 0) ? 0 : 1;
}
/* Open a file only if it canonicalizes to inside BASE. Returns FILE* or NULL. */
FILE *open_secure(const char *name) {
if (!name || strchr(name, '/') || !strcmp(name, "..")) return NULL; /* no separators */
char candidate[PATH_MAX];
if (snprintf(candidate, sizeof candidate, "%s/%s", BASE, name) >= (int)sizeof candidate)
return NULL;
char base_real[PATH_MAX], real[PATH_MAX];
if (!realpath(BASE, base_real)) return NULL;
if (!realpath(candidate, real)) return NULL; /* fails if file missing */
size_t bl = strlen(base_real);
if (strncmp(real, base_real, bl) != 0 || real[bl] != '/') return NULL; /* containment */
return fopen(real, "r");
}
int main(void) {
/* (3) VERIFY: fix must REJECT bad input and ACCEPT good input. */
printf("host good (expect 0/1 exec): %d\n", ping_secure("8.8.8.8"));
printf("host evil (expect -1) : %d\n", ping_secure("8.8.8.8; id"));
FILE *good = open_secure("report.pdf"); /* create this file in lab first */
FILE *evil = open_secure("../../etc/passwd");
printf("file good (expect non-NULL): %s\n", good ? "opened" : "NULL");
printf("file evil (expect NULL) : %s\n", evil ? "OPENED-BUG" : "NULL");
if (good) fclose(good);
if (evil) fclose(evil);
return 0;
}
Build and expected behaviour. Compile with cc -std=c11 -Wall -Wextra -o svc svc.c. In a lab where /var/lab/files/report.pdf exists, the verification prints: the good host runs ping (exit 0 or 1 depending on network), the evil host returns -1 (rejected before any exec), the good file prints opened, and the evil file prints NULL. The evil path never yields OPENED-BUG. This is the proof that the fix rejects attacks and still accepts legitimate input. Because execvp receives the hostname as one argument, ; id is passed to ping literally and simply fails DNS resolution — the shell never sees it.
Walkthrough of the secure example.
valid_host — Defence in depth for the command path. Even though execvp already neutralises metacharacters, we reject anything that is not a letter, digit, dot, or hyphen. This keeps input to the shape of a real hostname/IP and blocks surprising values early. "8.8.8.8; id" contains a space and ;, so the loop hits a disallowed character and returns 0.
ping_secure — The heart of the command fix.
| Step | What happens | Why it is safe |
|---|---|---|
valid_host gate |
rejects non-hostname input | fails closed before any process spawn |
fork() |
creates a child process | isolates the exec so the parent survives |
argv[] = {"ping","-c","1","--",host,NULL} |
builds an argument vector | host is exactly one argument, never parsed |
execvp("ping", argv) |
replaces child with ping | no shell exists, so ; ` |
_exit(127) |
runs only if exec fails | signals a launch error distinctly |
waitpid + WEXITSTATUS |
reaps child, reads result | parent learns success/failure cleanly |
Trace for host = "8.8.8.8; id": valid_host returns 0 at the space, ping_secure prints reject host and returns -1. No fork, no exec, no id. Trace for host = "8.8.8.8": passes validation, forks, execs ping -c 1 -- 8.8.8.8, parent waits and returns 0 or 1.
open_secure — The heart of the path fix.
| Step | What happens | Why it is safe |
|---|---|---|
strchr(name, '/') reject |
forbids any separator in the name | stops ../, absolute paths, subdir escapes |
snprintf join |
builds BASE/name, checks truncation |
avoids silently cut paths |
realpath(BASE, base_real) |
canonical base | stable reference for comparison |
realpath(candidate, real) |
canonical target | collapses .., ., symlinks to the real file |
strncmp(...)==0 && real[bl]=='/' |
prefix + separator check | confirms containment, blocks sibling files-secret |
fopen(real, "r") |
opens only verified path | attacker cannot escape BASE |
Trace for name = "../../etc/passwd": strchr finds the first / and returns NULL immediately — this input never even reaches realpath. Suppose an attacker avoided separators with a symlink trick inside the folder; realpath would still resolve to the real target and the prefix check would reject anything outside base_real. Trace for name = "report.pdf": no /, joins to /var/lab/files/report.pdf, realpath confirms it, prefix matches, fopen succeeds.
The two fixes mirror the two attacks: keep the command's input as one inert argument, and keep the file's input inside a verified boundary.
Real mistakes, why they fail, and the corrected approach.
Mistake 1 — Blacklisting shell characters.
; and |, then still call system().`cmd`, $(cmd), &&, newlines, or ${IFS} to bypass the blacklist; different shells differ.execvp, subprocess.run([...])) so no shell parses the input at all. Add an allowlist as defence in depth.system, popen, shell=True, backticks. Any of these with variable input is a red flag.Mistake 2 — Escaping instead of separating.
"; id; "), and nested quoting rules are subtle.Mistake 3 — Stripping ../ once.
name = name.replace("../", "").....// collapses to ../ after one pass; encoded forms survive; the check runs before decoding.realpath (or the language equivalent), then verify the result is inside the base with a prefix + separator check.Mistake 4 — Prefix check without the separator.
startsWith(real, base) only./var/lab/files-secret/x starts with /var/lab/files but is a different directory./ (as the sample does).Mistake 5 — Trusting a scanner's green result.
When a fix does not behave as expected in the lab:
system, popen, sh -c, shell=True. A single leftover shell invocation re-opens the hole. Verify by passing "8.8.8.8; touch /tmp/lab_marker" in the lab and checking the marker was not created.open_secure returns NULL for a legitimate file. realpath fails if the file does not exist. Create the test file first, and check permissions. Print errno/perror("realpath") to see ENOENT vs EACCES.:). Widen deliberately and re-test, but never widen to include shell metacharacters.realpath(BASE) too, then compare canonical-to-canonical, and check the trailing separator.snprintf returning >= size means the buffer was too small; treat truncation as an error and reject, never proceed with a cut string.Questions to ask when it fails: Does input reach a shell anywhere on this path? Am I canonicalizing before or after checking (must be before)? Am I decoding input before validating (validate the final decoded form)? Does my test actually assert rejection, or just "no crash"? Am I comparing canonical base to canonical target with a separator check?
Security & safety: detection and logging.
A verified fix is half the job; the other half is making abuse visible. Log a structured record at each sensitive sink.
What to log (per request that hits the command or file path):
host or file), clearly marked as untrusted, plus the endpoint.accepted vs rejected, and why ("host failed allowlist", "path outside base").What to NEVER log: passwords, session tokens or cookies, API keys, private keys, full payment card numbers (PANs), or unnecessary PII. If an input might contain a secret, log a hash or a redacted marker, not the value. Never echo a full file's contents into logs during a traversal investigation.
Events that signal abuse:
;, |, `, $(, or newlines on a command endpoint.file/path values containing .., %2e, %2f, backslashes, or absolute paths.rejected decisions from one source in a short window (probing).How false positives arise: a legitimate filename may legitimately contain a dot-heavy name, or a hostname field may receive an IPv6 literal with colons; a security tool in the org may scan your own app and trip the rules. Tune by distinguishing rejected-and-blocked (informational) from accepted-but-suspicious (investigate), and by whitelisting known internal scanners by source. Alert on patterns and rates, not single events, to keep the signal usable.
Because these are C-style examples, note the ordinary memory-safety duties too: bound every snprintf, treat truncation as failure, size buffers to PATH_MAX, and fclose/waitpid to avoid leaks — a memory bug on a security path can itself become the vulnerability.
Authorized real-world use case. A company hires you to test an internal "network tools" web app that lets staff ping and traceroute hosts and download saved reports. Working against a staging copy in an isolated lab, you find the ping endpoint builds a shell string and the report endpoint joins the filename directly. You demonstrate both safely (a benign ; id marker and reading a lab-only sentinel file), then hand the team the two structural fixes and a retest plan. This is the everyday shape of injection work: find, prove safely, fix, verify.
Professional best-practice habits.
| Habit | Beginner focus | Advanced focus |
|---|---|---|
| Input validation | allowlist characters for hostnames/filenames | schema/grammar validation, canonical decoding before checks |
| Least privilege | run the web process as a non-root user | seccomp/AppArmor to deny execve, read-only mounts, per-request chroot/jails |
| Secure defaults | argv APIs, ID-to-path lookups | libraries that forbid shell=True, CI lint rules that fail on dangerous sinks |
| Logging | record accept/reject + reason | correlation ids, rate-based alerting, SIEM detections for .. and metacharacters |
| Error handling | fail closed on any doubt | generic error messages to clients, detailed structured logs server-side |
Across beginner and advanced levels the constant is: keep input as data, prove the boundary holds, and never claim "completely secure" — claim "this specific class is mitigated and verified."
All tasks are lab-only: run on localhost, a container, or an intentionally-vulnerable VM you own or are explicitly authorized to test. Never point payloads at systems you do not control.
Authorization checklist (before any task): (1) I own or have written authorization for this target. (2) It runs on localhost/container/isolated VM. (3) Payloads are benign markers, not real exploits. (4) I have a cleanup/reset plan. Cleanup/reset: remove any lab marker files (rm -f /tmp/lab_marker), delete test files you created under the base folder, and restore the app to its baseline (redeploy or git checkout).
Beginner 1 — Spot the sink.
system, popen, shell=True, backticks) or into a file path (open, fopen).Beginner 2 — Prove the command fix rejects and accepts.
system("ping " + host) call to an argv API in the lab.execvp/subprocess.run([...]); add a hostname allowlist.8.8.8.8 → runs; 8.8.8.8; touch /tmp/lab_marker → rejected, and /tmp/lab_marker does not exist afterwards.-- before the host. Concepts: argv separation, verification. Defensive conclusion: remediate then verify the marker is absent; delete any stray marker.Intermediate 1 — Canonical bounded path.
realpath (or equivalent), verify base prefix + trailing separator.report.pdf → served; ../../etc/passwd and ....//....//etc/passwd → refused.../. Hints: canonicalize base too. Concepts: canonicalization, containment. Defensive conclusion: confirm both evil inputs return refused, then reset the folder.Intermediate 2 — Detection rules.
.. attempt produces an alert and no secret appears in the log.Challenge — ID-mapped downloads with least privilege.
?file=name with ?doc=<id> mapped server-side to a path via a lookup table; run the service as a non-root user; make the base directory read-only to the process.doc=../../etc/passwd yields 404 (not a file), document the fix, and record a retest result.Main concepts. Command injection and path traversal are the OS and filesystem members of the injection family. Both come from the same root cause: untrusted request input crossing the trust boundary and being treated as structure (shell commands, path segments) instead of inert data.
Key techniques and commands.
execvp("ping", (char*[]){"ping","-c","1","--",host,NULL}) or subprocess.run(["ping","-c1",host]) — plus a character allowlist.realpath() the candidate, and verify it stays inside a canonicalized base with a trailing-separator prefix check; better still, map an opaque ID to a server-side path.Common mistakes to avoid. Blacklisting or escaping shell characters instead of removing the shell; stripping ../ once; prefix checks without the separator; and trusting a clean scanner run as proof of safety.
What to remember. Keep input as data. Prove the boundary holds with a rejection test. Log accept/reject decisions (never secrets) so abuse is detectable. Test only in an authorized, isolated lab, and never claim a system is "completely secure" — claim the class is mitigated and verified.