Web Application Security · intermediate · ~12 min

Command injection and path traversal

**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.

Overview

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:

  • OS command injection happens when an application builds a shell command string out of user input. The shell interprets metacharacters (;, |, &, backticks, $( )) and runs whatever the attacker appended. Impact: remote code execution as the web server's user account.
  • Path traversal (also called directory traversal) happens when a filename comes from user input and is not constrained to a safe folder. Sequences like ../ 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.

Why it matters

In authorized professional work, these two flaw classes are among the most consequential you can find.

  • Command injection is often the single most severe finding in an engagement. It typically means full remote code execution: the attacker can read secrets, pivot to internal systems, install a foothold, or exfiltrate data. On a bug-bounty program or a paid penetration test, a confirmed, safely-demonstrated RCE is usually rated Critical.
  • Path traversal exposes exactly the files defenders most want to protect/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.

Core concepts

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.

1. OS command injection

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.

2. Path traversal (directory traversal)

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.

3. The shared root cause: data vs. structure

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.

Threat model

                          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.

  1. In the ?host= path, what asset is protected by switching from system("ping " + host) to an argv array, and where is the trust boundary?
  2. For ?file=report.pdf, what insecure assumption makes ../../etc/passwd work, and which log field would let you detect the attempt after the fact?
  3. Why must you only fire these payloads at a lab you own or are explicitly authorized to test — even the "harmless" ; id?

Syntax notes

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.

Lesson

These are two file- and OS-level injection attacks. Both can hand the server to an attacker.

OS command injection

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.

  • Use an API that takes an argument array (and runs no shell).
  • Or strictly allowlist and validate the input.

Escaping shell metacharacters by hand is fragile and easy to get wrong.

Path traversal

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:

  • Resolve the canonical (fully-resolved, real) path and verify it stays within an allowed base directory.
  • Prefer an allowlist of filenames, or map an opaque ID to a path.
  • Do not simply strip ../. That approach is bypassable.

The common thread

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.

  • Use argument arrays for commands.
  • Use canonicalized, bounded paths for files.
  • Use allowlists wherever you can.

Code examples

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.

(1) INSECURE — do not deploy

/* 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.

(2) SECURE — argv command + canonical bounded path

#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.

Line by line

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.

Common mistakes

Real mistakes, why they fail, and the corrected approach.

Mistake 1 — Blacklisting shell characters.

  • Wrong: strip or reject ; and |, then still call system().
  • Why wrong: attackers use `cmd`, $(cmd), &&, newlines, or ${IFS} to bypass the blacklist; different shells differ.
  • Corrected: use an argv API (execvp, subprocess.run([...])) so no shell parses the input at all. Add an allowlist as defence in depth.
  • Recognise/prevent: grep the codebase for system, popen, shell=True, backticks. Any of these with variable input is a red flag.

Mistake 2 — Escaping instead of separating.

  • Wrong: wrap input in quotes inside the command string.
  • Why wrong: quoting context is easy to break out of ("; id; "), and nested quoting rules are subtle.
  • Corrected: separation (argv) beats escaping. Let the OS pass arguments as discrete items.

Mistake 3 — Stripping ../ once.

  • Wrong: name = name.replace("../", "").
  • Why wrong: ....// collapses to ../ after one pass; encoded forms survive; the check runs before decoding.
  • Corrected: canonicalize with 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.

  • Wrong: startsWith(real, base) only.
  • Why wrong: /var/lab/files-secret/x starts with /var/lab/files but is a different directory.
  • Corrected: require the character after the base to be / (as the sample does).

Mistake 5 — Trusting a scanner's green result.

  • Wrong: "the scanner found nothing, so we are safe."
  • Why wrong: scanners miss context-specific sinks and encoding tricks; a pass is not a proof.
  • Corrected: combine code review for dangerous sinks, targeted tests that assert rejection, and logging so real attempts are visible.

Debugging tips

When a fix does not behave as expected in the lab:

  • Evil input still executes a command. Confirm you removed all shell calls on that path. Search for 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.
  • Legitimate host rejected. Your allowlist may be too strict (e.g., IPv6 uses :). Widen deliberately and re-test, but never widen to include shell metacharacters.
  • Containment check passes for an outside path. You probably compared against the non-canonical base. Always realpath(BASE) too, then compare canonical-to-canonical, and check the trailing separator.
  • Truncated command or path. 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?

Memory safety

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):

  • Timestamp (UTC, ISO 8601).
  • Source identifier: client IP and, if authenticated, the user/session id (a stable id, not the raw cookie).
  • Requested resource: the parameter value as received (the raw host or file), clearly marked as untrusted, plus the endpoint.
  • Security decision and result: accepted vs rejected, and why ("host failed allowlist", "path outside base").
  • Correlation id so the request can be traced across services.

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:

  • Rejected inputs containing ;, |, `, $(, or newlines on a command endpoint.
  • file/path values containing .., %2e, %2f, backslashes, or absolute paths.
  • A burst of rejected decisions from one source in a short window (probing).
  • Requests for canonical paths that resolve outside the base — even if blocked, they are intent.

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.

Real-world uses

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."

Practice tasks

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.

  • Objective: identify vulnerable code by pattern.
  • Requirements: given a small sample app, list every line that passes request input into a shell (system, popen, shell=True, backticks) or into a file path (open, fopen).
  • Output: a table of file, line, sink type, and the tainted parameter.
  • Hints: grep for the sink names first, then trace the parameter back to the request. Concepts: data vs structure, entry point → sink.

Beginner 2 — Prove the command fix rejects and accepts.

  • Objective: convert one system("ping " + host) call to an argv API in the lab.
  • Requirements: rewrite to execvp/subprocess.run([...]); add a hostname allowlist.
  • Input/Output: 8.8.8.8 → runs; 8.8.8.8; touch /tmp/lab_marker → rejected, and /tmp/lab_marker does not exist afterwards.
  • Constraints: no shell anywhere on the path. Hints: pass -- 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.

  • Objective: make a file-download endpoint traversal-safe.
  • Requirements: reject separators in the name, canonicalize with realpath (or equivalent), verify base prefix + trailing separator.
  • Input/Output: report.pdf → served; ../../etc/passwd and ....//....//etc/passwd → refused.
  • Constraints: do not merely strip ../. Hints: canonicalize base too. Concepts: canonicalization, containment. Defensive conclusion: confirm both evil inputs return refused, then reset the folder.

Intermediate 2 — Detection rules.

  • Objective: add logging that flags abuse without leaking secrets.
  • Requirements: log timestamp, source, endpoint, raw parameter (marked untrusted), decision + reason, correlation id; suppress tokens/passwords.
  • Output: sample log lines for one accepted and one rejected request, plus a rule that alerts on a burst of rejects from one source.
  • Constraints: never log secrets or full file contents. Concepts: detection, false positives, rate-based alerting. Defensive conclusion: verify a rejected .. attempt produces an alert and no secret appears in the log.

Challenge — ID-mapped downloads with least privilege.

  • Objective: remove user-controlled paths entirely and harden the runtime.
  • Requirements: replace ?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.
  • Input/Output: valid id → served; unknown id → 404; any traversal string → impossible because input never reaches a path.
  • Constraints: no filename ever comes from the client. Hints: the lookup table is your allowlist. Concepts: allowlist by design, least privilege, secure defaults. Defensive conclusion: demonstrate that even a crafted doc=../../etc/passwd yields 404 (not a file), document the fix, and record a retest result.

Summary

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.

  • Commands: use an argv API that never invokes a shell — execvp("ping", (char*[]){"ping","-c","1","--",host,NULL}) or subprocess.run(["ping","-c1",host]) — plus a character allowlist.
  • Files: reject separators, 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.
  • Verify every fix by asserting it rejects bad input and accepts good input.

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.

Practice with these exercises