Getting the Server to Read What You Want#

Path traversal (directory traversal) is a file-access flaw: attacker-controlled input in a file path escapes the directory the application intended and reaches files it should never expose. Targets include /etc/passwd, application config holding database credentials and API keys, source code, and private keys. It is quiet, trivial to automate, and frequently the on-ramp to a bigger compromise once source or secrets leak.

Where the Bug Lives#

The vulnerability is naive path construction: joining a base directory with user input without checking the result stays inside that directory.

# VULNERABLE: user_filename is trusted to stay inside uploads/
def read_upload(user_filename: str) -> bytes:
    with open(f"/var/www/uploads/{user_filename}", "rb") as f:
        return f.read()

If user_filename is ../../../etc/passwd, the operating system resolves the combined path by walking up out of uploads and returns /etc/passwd. The .. sequence means "parent directory," and repeated enough times it reaches the filesystem root. You can see the resolution happen without touching any sensitive file:

from pathlib import Path

root = Path("/var/www/uploads")
print((root / "avatar.png").resolve())
# /var/www/uploads/avatar.png
print((root / "../../../etc/passwd").resolve())
# /etc/passwd   <- escaped the intended directory

The resolve() output shows exactly what open() would receive: a path well outside uploads.

Why Blacklists Fail#

The instinctive fix, "strip out ../," fails in two directions. It misses encoded and alternate forms, and it breaks legitimate filenames. Attackers iterate through variants until one slips past a naive filter:

  • URL-encoded: %2e%2e%2f decodes to ../ after the web server decodes it.
  • Double-encoded: %252e%252e%252f survives one decode and becomes %2e%2e%2f, then ../.
  • Overlong or Unicode forms on some platforms.
  • ....// which, after a filter removes one ../, collapses to ../.
  • Windows backslashes (..\\) and mixed separators.
  • An absolute path (/etc/passwd) supplied directly, so there is no .. to strip at all.

A blacklist that removes ../ does nothing against an absolute path or a double-encoded sequence, and it may reject a legitimate file whose name happens to contain a dot-dot. Because attackers can fuzz these variants in seconds, traversal must be fixed structurally, by validating the final resolved path, not by pattern-matching the input.

The Fix: Canonicalize and Confine#

The robust pattern is: resolve the full path (collapsing every .. and symlink), then verify the result is still inside the intended root. Refuse otherwise.

from pathlib import Path

ROOT = Path("/var/www/uploads").resolve()

def read_upload_safe(user_filename: str) -> bytes:
    candidate = (ROOT / user_filename).resolve()
    if not candidate.is_relative_to(ROOT):        # Python 3.9+
        raise PermissionError("path escapes the allowed directory")
    return candidate.read_bytes()

resolve() turns the request into its real absolute path, following symlinks and collapsing ... is_relative_to(ROOT) then makes a single, decisive check: is the real target inside the folder we allow? An ../../etc/passwd resolves to /etc/passwd, which is not under ROOT, so it is rejected. This defeats every encoding variant at once, because they all resolve to the same escaping path, and it is done after the web framework has URL-decoded the input.

On older Python, compare with os.path.commonpath or check that the resolved string starts with the root path plus a separator. Whatever the language, the shape is identical: canonicalize, then confine.

One caution: do the confine check on the resolved path, not the raw input, and be careful with os.path.join, which silently discards the base when handed an absolute second argument. os.path.join("/var/www/uploads", "/etc/passwd") returns /etc/passwd, not a path inside uploads, which is exactly how the absolute-path variant of the attack succeeds against code that "looked safe." Resolving and confining catches this case too, because the resolved result is checked regardless of how it was built.

What an Attacker Gains#

It helps to understand why traversal is rated so highly. Reading /etc/passwd lists local usernames, which seeds further attacks. But the real prizes are application-specific: a framework's settings file with the database password and secret key, .env files, cloud credential files under a home directory, SSH private keys, and the application's own source code. Source disclosure in particular converts a black-box target into a white-box one, revealing other vulnerabilities and hardcoded secrets. On some stacks, traversal combined with a file-write primitive (through upload or log poisoning) escalates all the way to code execution. That escalation path is why the confine check is not optional hardening but a core control.

The Bulletproof Design: Serve Identifiers, Not Paths#

The strongest design does not let the user reference a path at all. Store files under server-generated names and map an opaque identifier to the real file through a lookup you control:

# The user asks for file "a1b2c3"; they never supply a path or a name.
FILES = {
    "a1b2c3": "/var/www/uploads/9f8e7d6c5b4a.bin",
    "d4e5f6": "/var/www/uploads/1122334455aa.bin",
}

def download(file_id: str, user) -> bytes:
    stored = FILES.get(file_id)                   # unknown id -> None
    if stored is None or not user_may_access(file_id, user):
        raise PermissionError("not found or not permitted")
    return open(stored, "rb").read()

Because the attacker can only reference keys you issued, there is no path to traverse. This also folds in authorization (IDOR is the sibling flaw where the id is honoured without an ownership check), so combine the manifest with a per-user access check.

The Wider Family of File-Path Bugs#

Traversal is one member of a family that appears wherever user input meets the filesystem:

  • Archive extraction (zip-slip): a crafted archive containing entries like ../../etc/cron.d/evil can write files outside the extraction directory. Validate each entry's resolved path before writing it, using the same confine check.
  • Symlink attacks: an attacker who can place a symlink in a writable directory can point a later read or write at a file elsewhere. Resolving symlinks (as resolve() does) and confining the result addresses this.
  • File upload paths: never trust a client-supplied filename for where a file is stored; generate the storage name yourself and sanitise the display name.

Review the moment any user-supplied path meets file access: the "download attachment" endpoint, the "process this upload" worker, the template or include loader, and the archive extractor.

Defence in Depth#

  • Keep upload and content directories on a separate volume, reachable only by the application account, so even a traversal cannot reach system files.
  • Run the application as a least-privilege user; /etc and the app's working directory should be different trust zones, reinforced by OS permissions and the segmentation ideas in the firewalls lesson.
  • On download, set Content-Disposition with a sanitised filename so the browser does not use attacker-controlled text.
  • Log access to file-serving endpoints and alert on .., encoded traversal sequences, or absolute paths in requests.

Checklist#

  • Every file path built from user input is resolve()-d and confined to an allowed root.
  • No feature accepts an absolute path or trusts a stripped-input blacklist.
  • Where possible, users reference opaque identifiers, not filenames, backed by an ownership check.
  • Archive extraction validates each entry's resolved destination before writing.
  • The app runs as a least-privilege account with content on a restricted volume.
  • Tested against DVWA / PortSwigger path traversal labs, with encoded and absolute-path payloads, on systems you are authorised to test.