Path traversal: a local directory traversal experiment
Path traversal is a flaw where user input such as ../ makes an application read files outside the directory it should serve. We reproduced it in a small local file-download endpoint. In vulnerable mode, a request for /files/../secret.txt returned a synthetic secret that sits outside the download directory. A fix that strips ../ still leaked it through an absolute path and a nested sequence, while a fix that resolves the real path and checks it against the base directory blocked every variant and kept legitimate downloads working.
This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.
What path traversal is and what the fixture covers
Path traversal, also called directory traversal, happens when an application builds a file path from user input and the result points outside the directory the developer meant to expose. The OWASP description of the path traversal attack covers ../ sequences and their variations as well as absolute file paths. The matching weakness is CWE-22, Improper Limitation of a Pathname to a Restricted Directory.
The fixture is a toy download endpoint with three build modes of the same handler. It stays small so that every request and response can be read in full.
- Target: one HTTP server per mode, bound to
127.0.0.1on a port the operating system picks. Each server stops when its requests finish. - Layout: at start the fixture creates a temporary directory. It holds
public/withreport.txtanddocs/guide.txt, plussecret.txtnext topublic/, so the secret is outside the base directory but inside the temporary directory. The fixture deletes the whole directory when it stops. - Endpoint:
GET /files/<name>. The server URL-decodes the name once and then builds a path frompublic/and that name. - Data: synthetic text files only. The secret reads
SYNTHETIC-SECRET-0001: not a real credential.
The fixture is vulnerable on purpose and cannot bind to another address. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test any other system.
Reproduce the path traversal run with curl
Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl. Read the source, then run:
python3 verify.py --output rerun
The output directory must not exist yet. The runner starts the vulnerable, naive and fixed servers one after another, sends eight curl requests to each, saves every raw response and removes the servers and their temporary directories. Every request uses --noproxy '*', a one-second connect timeout and a three-second total timeout.
Every request also uses --path-as-is. Without that flag curl removes dot segments itself. In a manual check against the vulnerable server, curl http://127.0.0.1:PORT/files/../secret.txt sent GET /secret.txt and got a 404, so the server never saw the traversal. That manual check is not part of the recorded run.
The recorded command was python3 verify.py --output recorded-run. It printed {"checks": 24, "passed": true, "failures": []}. The run happened on 10 November 2026 at about 22:46 UTC with Python 3.15.2 and curl 8.7.1 on macOS. The three servers listened on ports 52737 (vulnerable), 52746 (naive) and 52755 (fixed). results.json holds exact versions, timestamps, commands and source hashes.
Observed results across three modes
Four requests are controls: three legitimate or harmless names and one double-encoded name that the server decodes only once. Four requests try to reach secret.txt. The runner checks each status code and also searches each body for the secret marker.
| Request path | Vulnerable | Naive strip | Real-path check |
|---|---|---|---|
/files/report.txt | 200 | 200 | 200 |
/files/docs/guide.txt | 200 | 200 | 200 |
/files/nope.txt | 404 | 404 | 404 |
/files/../secret.txt | 200: secret | 404 | 403 |
/files/..%2fsecret.txt | 200: secret | 404 | 403 |
Absolute path to secret.txt, URL-encoded | 200: secret | 200: secret | 403 |
/files/....//secret.txt | 404 | 200: secret | 404 |
/files/%252e%252e%252fsecret.txt | 404 | 404 | 404 |
The first three rows show that normal downloads work in every mode, including a file in a subdirectory, and that a missing file returns 404. Without those controls, a server that rejects everything would look secure.
The ../ and encoded ..%2f requests disclosed the secret in vulnerable mode, because the server decoded the name and joined it to public/ without checking where the path ended up. The absolute path worked as well: when the second argument to Python's os.path.join is absolute, it discards the first. In vulnerable mode the nested ....// request returned 404, because the join produced a path through a directory named .... that does not exist. That sequence only matters once a filter is in place.
Verify a directory traversal finding before reporting it
A 200 status alone proves little, because some applications answer every URL with 200 and a generic page. The vulnerable ../ request used port 52737. This is the executed command:
curl --noproxy '*' --silent --show-error --include --path-as-is \
--http1.1 --connect-timeout 1 --max-time 3 --request GET \
http://127.0.0.1:52737/files/../secret.txt
The recorded response has this status and body:
HTTP/1.0 200 OK
SYNTHETIC-SECRET-0001: not a real credential
Three facts together support the finding. The body is the content of a file that lives outside the directory the endpoint is meant to serve, the endpoint needs no login, and the same request with a normal name returns a different file. A report should give the method, the exact raw request path (and whether the client normalized it), the file that came back, and the intended boundary. This run shows disclosure of one synthetic file. It does not show what a production server would expose, and it says nothing about writes.
Why stripping "../" does not stop path traversal
A common first fix removes the dangerous sequence from the input. The fixture's naive mode does exactly that:
def resolve_naive(base, name):
return os.path.join(base, name.replace("../", ""))
This blocked the two textbook requests. After decoding, ../secret.txt and ..%2fsecret.txt both became secret.txt, which does not exist in public/, so the server returned 404 and disclosed nothing. Two other requests still worked. The absolute path contains no ../, so the filter left it alone and the server returned 200 with the secret. The nested name ....//secret.txt also returned 200. The filter removes the ../ in the middle of ....//, and the characters on either side join into a new ../ that the filter never sees again. Removal in a single pass can rebuild the sequence it removes.
Fix path traversal by resolving the real path
The fixed mode leaves the input alone. It computes where the path really points, then compares that location with the base directory:
def resolve_fixed(base, name):
root = os.path.realpath(base)
target = os.path.realpath(os.path.join(root, name))
if os.path.commonpath([root, target]) != root:
return None
return target
The handler turns None into a 403 response. realpath resolves dot segments and symbolic links before the comparison, and commonpath compares whole path components, so a sibling directory such as public-old would not pass for public. This is the code the fixture ships. We did not test the sibling or symlink cases.
The retest ran the same eight requests against a fresh fixed server on port 52755. Legitimate downloads returned 200 with their own content, and the missing file returned 404. The ../, encoded and absolute requests returned 403 with {"error": "forbidden"}. The nested request returned 404: after resolution it names a file called secret.txt inside a directory called .... under public/, which does not exist, and the body carries no secret. The runner marked all 24 checks as matching, including the absence of the secret marker in every fixed-mode body.
OWASP's guidance on avoiding the flaw also advises avoiding user input in file system calls where possible and mapping an index to a file name instead of accepting the name. With an allowlist, user input never becomes a path. It suits cases such as language files or fixed asset lists.
Inspect the evidence bundle
- results.json: all 24 cases with commands, ports, expected and observed status, secret-disclosure flags and response hashes.
- commands.sh records the exact curl commands. The ports belong to the finished run, so use
verify.pyto start fresh servers. - Raw responses: one
.httpfile per request, such asnaive-nested-dotdot.httpandfixed-absolute-path.http. - SHA256SUMS, README.txt, fixture.py and verify.py. The manifest is an integrity check, not independent attestation.
The bundle contains no real credentials or customer data. The temporary directory names in the recorded output are random and no longer exist.
What this experiment does not cover
We ran 24 requests against one toy endpoint on one platform. We did not test Windows paths with backslashes or drive letters, symbolic links, case-insensitive file systems, null bytes, upload or archive extraction paths, or file-writing endpoints. The fixture decodes the name exactly once. A real stack may sit behind a proxy or framework that decodes twice, normalizes slashes or rewrites paths, and each layer changes which variants arrive. The ....// result also depends on the exact filter, so a different filter needs its own variants. A passing retest here shows that this code resists these eight requests, and it does not show that another application is safe.
Path traversal belongs to the wider family of access-control failures. Our posts on object-level authorization testing, IDOR and broken access control cover the neighbouring cases where a client-supplied identifier selects the resource.
Excession Labs tests file-access boundaries like this one in its security assessments, with in-scope variants and a retest after each fix. See how a finding like this looks in a sample report.