Somewhere in most web apps there's an endpoint like GET /download?file=report.pdf. The code takes the filename, sticks it onto a folder path and reads the file. It works, the tests pass, and nobody looks at it again.

Then someone sends ?file=../../../etc/passwd, and the "download" endpoint turns out to read any file the app's process can open.

Path traversal is old, well documented and still everywhere, mostly because the first fix people reach for, removing ../ from the input, doesn't work. This article covers where the bug hides, how to prove it without pulling anything sensitive, the five ways common filters fail, and a fix that holds, with code I've run.

Only test applications you own or are explicitly authorized to test. Every example here uses stand-ins like app.example.com and /srv/app/downloads.

What it is

Path traversal (also called directory traversal) happens when user input ends up in a filesystem path and the app doesn't stop it pointing outside the folder it was meant for. MITRE tracks it as CWE-22, "Improper Limitation of a Pathname to a Restricted Directory".

The mechanics are simple. The app means to read:

/srv/app/downloads/report.pdf 

The attacker supplies ../../../etc/passwd, and the path becomes:

/srv/app/downloads/../../../etc/passwd → /etc/passwd 

Each ../ steps up one directory. On Windows, ..\ works too. As PortSwigger's write-up puts it, the result can be reading application code, back-end credentials and OS files, and in some cases writing files, which can lead to full control of the server.

Where it shows up

Look for any feature where a parameter names a file rather than an ID:

  • Downloads and exports: ?file=, ?path=, ?doc=, ?attachment=.
  • Image and asset loaders: /loadImage?filename=218.png, thumbnail and avatar endpoints.
  • Templates and languages: ?page=about, ?lang=en where the value picks a file on disk.
  • The write side: upload handlers that keep the client's filename, and archive extraction that trusts the paths stored inside a zip or tar.

A parameter value that ends in a file extension, or that changes the response when you change one character of it, is worth a look.

How to find it

Start with a baseline: request a known-good value (?file=report.pdf) and save the response. Then change one thing at a time:

  1. A harmless in-folder traversal: ?file=x/../report.pdf. If you get the same bytes back, the input reaches a filesystem API that resolves ... That isn't a vulnerability yet, but it tells you where to keep going.
  2. The classic escape: ../../../etc/passwd (or ..\..\..\windows\win.ini on Windows). Using more ../ than needed is harmless; you can't go above the root.
  3. The bypasses, if something is filtering. These are straight from PortSwigger's list of common obstacles:
The defense What gets past it
Blocks ../ sequences An absolute path: /etc/passwd
Strips ../ once Nested sequences: ....// becomes ../ after stripping
Blocks ../ before decoding URL encoding %2e%2e%2f, or double encoding %252e%252e%252f
Requires the path to start with the folder /var/www/images/../../../etc/passwd
Requires a .png ending A null byte: ../../../etc/passwd%00.png (runtime-dependent)

That last row depends on the runtime. In current Python, for example, open() refuses a path with a null character and raises ValueError, so the trick fails there. Test it, don't assume it.

How to prove it, safely

A traversal you suspect and one you've proven are different findings. The error message changed, the response got longer, a scanner flagged "possible path traversal": those are leads. The proof is reading one specific file outside the intended folder and showing its contents match what that file must contain.

Keep the proof small:

  • Pick one predictable, low-value file. On a Linux target, /etc/passwd is the usual choice: it exists on every system and its name:x:uid:gid:... format is unmistakable, so a match can't be a coincidence. Show the first line, not the whole file.
  • Don't go shopping. Once one out-of-folder read works, stop. Don't pull .env, private keys, database configs or source code to "show impact". Describe what could be read (anything the app's process can open) and let the owner judge.
  • Record the exact request and response so anyone can reproduce it in one step.

A clean report line looks like this:

GET /download?file=..%2f..%2f..%2fetc%2fpasswd HTTP/1.1 Host: app.example.com HTTP/1.1 200 OK Content-Type: application/octet-stream root:x:0:0:root:/root:/bin/bash [remaining lines omitted] 

One request, one harmless file, one result anyone can check. A finding isn't real until it's proven, and this kind of proof is easy to check and hard to argue with.

Five filters that fail and the check that holds

Why the usual filters fail

Here's the vulnerable pattern most fixes start from, and five variations of "fixed" that aren't. I ran each of these in Python against a throwaway folder to confirm the behavior.

1. Stripping ../ once.

import os BASE = "/srv/app/downloads" def get_path(name): return os.path.join(BASE, name.replace("../", "")) get_path("....//config.ini") # → /srv/app/downloads/../config.ini 

Removing ../ from ....// leaves ../. Looping until nothing changes is better, but you're still writing a blocklist, and blocklists lose to encodings.

2. Trusting os.path.join to keep you in the folder. The Python docs are explicit: if a segment is an absolute path, everything before it is thrown away. pathlib behaves the same way.

os.path.join("/srv/app/downloads", "/etc/passwd") # → "/etc/passwd" 

3. A string prefix check without a separator.

real = os.path.realpath(os.path.join(BASE, name)) if real.startswith(BASE): # BASE = "/srv/app/downloads"  ... 

../downloads-private/keys.txt resolves to /srv/app/downloads-private/keys.txt, which does start with /srv/app/downloads. Any sibling folder whose name begins with the same characters is reachable.

4. Checking before decoding. If the code checks for ../ and then calls unquote() (or a framework decodes the value again later), ..%2f sails through the check and becomes ../ afterwards. Validate the exact value you hand to the filesystem.

5. is_relative_to() on a path you haven't resolved. The pathlib docs note the method is string-based and doesn't treat .. specially:

from pathlib import Path (Path(BASE) / "../config.ini").is_relative_to(BASE) # → True, but it escapes 

The common thread: every one of these checks the string the user sent, not the file the OS will actually open.

The fix that holds

Best: don't put user input in a path at all

The strongest fix is to avoid passing user input to filesystem APIs. Most download features can work on IDs:

REPORTS = { "q3-summary": "q3-summary.pdf", "q3-detail": "q3-detail.pdf", } def report_path(report_id: str) -> Path: filename = REPORTS.get(report_id) if filename is None: raise KeyError("unknown report") return BASE_DIR / filename 

The user picks a key; the server picks the file. There's nothing to traverse. A database row with the stored filename works the same way, provided that the filename is generated by the server, not copied from an upload.

If you must accept a filename: resolve, then compare

When the input really has to be a path, canonicalize it first and then check where it ended up:

from pathlib import Path BASE_DIR = Path("/srv/app/downloads").resolve() def safe_path(user_input: str) -> Path: candidate = (BASE_DIR / user_input).resolve() if not candidate.is_relative_to(BASE_DIR): # Python 3.9+  raise PermissionError("path escapes the base directory") return candidate 

resolve() collapses .., follows symlinks and turns the result into an absolute path, so the comparison is made against the real location. is_relative_to() compares path components, not characters, so downloads-private no longer matches downloads. Absolute inputs are caught too: they resolve outside BASE_DIR and fail the check. Pair this with input validation (for example, only letters, digits, -, _ and one .), as PortSwigger recommends: two layers, not one.

Use your framework's helper

  • Flask: send_from_directory(directory, path) uses Werkzeug's safe_join to keep path inside directory, and returns 404 if the result isn't a regular file. The directory argument must never come from the client.
  • Go: Go 1.24 added os.Root, whose methods refuse paths that leave the directory, including through symlinks. For one-off opens there's os.OpenInRoot:
f, err := os.OpenInRoot("/srv/app/downloads", name) if err != nil { // name escaped the directory, or the file doesn't exist return err } defer f.Close() 

The Go team's post on traversal-resistant file APIs is worth reading even if you don't write Go. It explains why "check, then open" stays racy when an attacker can create symlinks between the check and the open, which is exactly the gap a root-scoped API closes.

Lock it in with a test

Whichever fix you choose, keep the payloads from this article as a regression test so a later refactor can't quietly undo it:

import pytest ESCAPES = [ "../config.ini", "../../../etc/passwd", "/etc/passwd", "../downloads-private/keys.txt", ] @pytest.mark.parametrize("payload", ESCAPES) def test_rejects_escapes(payload): with pytest.raises(PermissionError): safe_path(payload) def test_allows_normal_files(): assert safe_path("report.pdf") == BASE_DIR / "report.pdf" 

Add encoded variants (..%2fconfig.ini) at the HTTP layer in an integration test, since decoding happens before your function ever sees the value.

Try it yourself

PortSwigger's Web Security Academy has six free path traversal labs, and they line up almost exactly with the bypass table above:

Solve them in order. For each one, write down which defense the lab uses and which line of the bypass table beats it. Then take safe_path() from above, put it in a tiny Flask app on your own machine, and throw every lab payload at it. None of them should get through.

Conclusion

Path traversal survives because the obvious fix is a filter on the input string, and the filesystem doesn't care what the string looked like. It cares where the path ends up. So skip the filter: map IDs to files when you can, and when you can't, resolve the path and check the real location, or let a root-scoped API like Flask's send_from_directory or Go's os.Root do it for you. When you test, prove it with one harmless file and stop there.

Further reading

I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.

🔐 If this was useful, follow me for more hands-on web & API security write-ups.

🔗 Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/