Skip to content
Path Traversal

Path Traversal: How ../ Reads Files You Never Meant to Share

Path traversal lets an attacker climb out of an intended directory with dot-dot-slash sequences and read arbitrary files on the server. Here is how it works, the encodings that defeat naive filters, and the canonicalization fix that stops it.

Part 1 of 3From the Path Traversal series

On this page

Path traversal — also called directory traversal — happens when an application builds a filesystem path out of user input and never checks that the result stays where it is supposed to. By injecting ../ sequences, an attacker walks up and out of the intended folder and reaches files anywhere the application can read: /etc/passwd, configuration secrets, application source, even credentials.

It is one of the oldest bugs on the web and still one of the most common, because the vulnerable pattern looks so reasonable: take a filename, open it, return it. This post shows how that single missing check turns into arbitrary file disclosure, and how to close it for good.

How it happens

The root cause is concatenating user input straight into a path. A download endpoint that serves files from a fixed folder is the classic example:

$file = $_GET['file'];
$path = "/var/www/uploads/" . $file;

readfile($path);

For a normal request like ?file=invoice.pdf, the path resolves to /var/www/uploads/invoice.pdf and everything works. The developer assumes file is just a name. But it is data the attacker controls, and / and . are perfectly valid characters in it — so the input can describe a journey out of the uploads directory.

A concrete attack

Instead of a filename, the attacker sends a chain of parent-directory hops:

GET /download?file=../../../etc/passwd HTTP/1.1
Host: app.example

The path the application opens becomes:

/var/www/uploads/../../../etc/passwd  ->  /etc/passwd

Each ../ cancels one directory level. Three of them climb from uploads past www and var to the filesystem root, and the server happily returns the contents of /etc/passwd. When a filter naively strips the literal string ../, attackers reach for encoded and malformed variants that decode back to the same sequence later in the pipeline:

..%2f..%2f..%2fetc/passwd        # URL-encoded slash
..%252f..%252f..%252fetc/passwd  # double-encoded
....//....//....//etc/passwd     # survives a single "../" -> "" strip
..%c0%af..%c0%afetc/passwd       # overlong UTF-8 slash
..%00/etc/passwd                 # null-byte truncation of an appended suffix

The application thought it was choosing the file. The attacker only had to phrase the filename as directions, and the filesystem followed them right out of the sandbox.

What an attacker gains

Arbitrary read access to the server's filesystem is a deep foothold:

  • Credential and config theft — .env files, database passwords, API keys and private keys.
  • Source code disclosure — reading the application's own code to find more vulnerabilities.
  • System reconnaissance — /etc/passwd, /proc/self/environ and process data that map out the host.
  • Escalation to code execution — when the same flaw allows writing files, an attacker can drop a web shell or poison a config.

The fix: canonicalize, then verify the path stays inside

The dependable fix is not to filter bad strings — it is to resolve the final path and confirm it still lives under your base directory. Canonicalize first (so every ../, symlink and encoding is collapsed to one real path), then compare:

$base = realpath("/var/www/uploads");
$requested = realpath($base . "/" . $_GET['file']);

if ($requested === false || !str_starts_with($requested, $base . DIRECTORY_SEPARATOR)) {
    http_response_code(404);
    exit;
}

readfile($requested);

realpath() resolves the path to its true location on disk; the str_starts_with check rejects anything that escaped the base folder. Layer extra defenses on top: where you can, avoid user-controlled paths entirely and map an opaque ID to a known filename in a lookup table; validate filenames against a strict allowlist (^[a-zA-Z0-9._-]+$, no slashes); and run the service with least privilege so even a successful traversal reads as little as possible.

Module: Path Traversal. How it probes: Traversal, encoding and truncation variants against every file-referencing parameter, including archive and upload paths. How it confirms: The response has to carry file content the baseline request did not return. A finding moves from Possible to Confirmed only after this check. Reported as CWE-22 · ATT&CK T1083 · SARIF 2.1.0.
How SelfSec proves this one
Module
Path Traversal
How it probes
Traversal, encoding and truncation variants against every file-referencing parameter, including archive and upload paths.
How it confirms
The response has to carry file content the baseline request did not return.
Reported as CWE-22 · ATT&CK T1083 · SARIF 2.1.0

A reflected response alone is not enough to promote a Path Traversal finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find Path Traversal in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list