- The AppSec Augury
- Posts
- Path Traversal Reversal - The Clever Trick You Never Knew
Path Traversal Reversal - The Clever Trick You Never Knew
Say goodbye to dot-dot-slash attacks.

Path Traversal
An attack against applications that rely on user input to read data from files, using specially crafted inputs to grab files from other locations in the system.
I used to work at a company called SoftwareSecured a few years back. Recently they changed their logo to 3 characters: ../
Bit weird for a security company to have that as their logo, right? Except, if you’re in the know, not really! Those 3 characters represent perhaps one of the most annoying types of attack to mitigate (at least, in theory) once the possibility is introduced: Path traversals.
But what’s the connection? Why those 3 characters in particular? And what’s this got to do with reversals?
A Crash Course On Filesystems
If you’re in development or security, you’re probably familiar with navigating a terminal. (If not, don’t worry, this is part is fairly light.) One of the most common things you need to do is navigate to a new directory. Say you want to go into your Documents folder from your home page.
In a file explorer, you’d just click on your Documents folder. In a terminal, you need to use the cd
command, which does roughly the same thing:
robert@roberts-pc:~$ cd Documents
robert@roberts-pc:~/Documents$
Note: I’m on Linux, so if you’re only used to Windows, the commands here might look a little funky. Worry not, the basic concepts still apply!
Now, let’s say you want to go back. That’s actually where the ../
comes in handy. ..
is a shorthand for “whatever the parent directory is.” So if you’re in /your/home/path/Documents
, ..
would reference /your/home/path
.
Trying to go back home, we need to do this:
robert@roberts-pc:~/Documents$ cd ../
robert@roberts-pc:~$
And boom! We’re back. You can also chain these. A typical home directory in Linux looks like /home/<username>
, and the filesystem root is at /
1 . If we want to go to the filesystem root, we can just stick these ../
tidbits together:
robert@roberts-pc:~$ pwd # where am I?
/home/robert
robert@roberts-pc:~$ cd ../../
robert@roberts-pc:/$ pwd
/
robert@roberts-pc:/$
And we’re at the filesystem root, where you can navigate to pretty much anywhere in the system as long as you have permissions to do so. Of course, there’s no way this can be used for evil, right?
Right?
When A Feature Turns Into A Bug Turns Into A Nightmare
Let’s draft a super simple web server in FastAPI that just gives us some file from an `archive/`
directory. Don’t worry about the FastAPI specific stuff — we’re just focusing on working with files here.
# main.py
import os
from fastapi import FastAPI
from fastapi.responses import FileResponse
app = FastAPI()
@app.get("/archive")
async def read_item(file: str = "README.txt"):
file_path = os.path.join("archive", file)
return FileResponse(file_path)
If you already see the problem here, congratulations, you’ve worked in AppSec before.
Remember how I said that ..
basically means “one directory up?” What do you think happens when os.path.join
runs into that?
Let’s try it.
Following Along
If you plan on following along with this, here’s a setup script you can copy-paste into your terminal on Linux2 :
mkdir path-traversals # Make the project
cd path-traversals # Jump into it
python -m venv .venv # Make a virtual environment to keep things isolated
source .venv/bin/activate # Activate the virtual environment
pip install "fastapi[standard]" # Install the dependencies
mkdir archive # Make the archive directory
Make a new file called README.txt
inside path-traversals/archive
:
Welcome to the archive!
Once you’re done that, copy-paste the main.py
Python file I showed earlier into path-traversals/main.py
. Then all you need to do is run:
fastapi dev main.py
Now to test this thing.
Exploiting It
There are hundreds of ways you could make a request here, but the easiest is to just visit http://localhost:8000/archive in your browser:

Going to the Archive page without any shenanigans.
Yeah, that’s par for the course.
Now, let’s take another look at that path handler:
@app.get("/archive")
async def read_item(file: str = "README.txt"):
file_path = os.path.join("archive", file)
return FileResponse(file_path)
We have a query parameter called file
. That gets joined with “archive” to make a filepath that the server opens and sends to us. So what happens if we, I don’t know, use that ../
trick to get main.py
?
Let’s navigate to http://localhost:8000/archive?file=../main.py:

Uh oh. That’s our source code. That’s not good.
So what happened? In the os.path.join()
call, we merged “archive” and “../main.py”. Now, os.path.join()
doesn’t really know that we don’t want to leak our source code, so it dutifully respects the ..
and returns “main.py” for the path to open up.
This is already bad enough, since source code leaks are a great way for hackers to get information on your application that can lead to way, way worse things happening down the line. Alternatively, path traversals can also lead to some critical information about your system leaking, like the /etc/passwd
file using an absolute path, starting from the filesystem root:

Once you can get the /etc/passwd file? You’re kinda done for.
If you’re here, you probably don’t need me to explain the business risk to you — you’re looking for a way to fix it, right?
Well, there are 2 ways one might think of to intuitively block this kind of attack, if they think to do so at all:
Denylists
Escaping
The problem with denylists here is the same as everywhere else: good luck making sure you don’t have any holes in it. Hackers will 9 times out of 10 find some crafty way of escaping whatever tricks you have up your sleeve. For instance, if you try banning ..
in your source code, attackers can just alternate characters that, to Python, map to ..
anyway. Or, like I showed above, they may not even use ..
and just use /
as a base to get what they want.
Escaping kind of falls into the same trap. You can try doing a search-replace of ../
in your code, but if someone has a path that contains something weird like ….//
, the search-replace doesn’t catch the resulting ../
.
So what’s a dev to do? Easy.
You let them do what they want.
Path Traversal Judo
“Robert, that’s nuts, you can’t just let hackers do what they want!”
True! Here’s the thing, though: I’m not. Bear with me as I walk through an analogy.
In martial arts like Judo, Aikido, and Jiu-Jitsu, brute force blocking an attack isn’t usually the best way of counteracting it. In many cases, it’s actively the worst: if it fails, you get wrecked, and if you succeed, you just burn energy that costs you later.
Instead, many throws and sweeps in grappling arts revolve around using your opponent’s momentum against them3 . You let your opponent go for a takedown, a punch, whatever, and you work with them to take them down. You let them strike — you just weren’t in the way of it and, and, at the same time, punished them for it.
What if we applied the same trick here?
Here’s the breakdown of how this is going to work:
We resolve the path the client gives us as is.
We then stick the result on relative to the safe and happy base path we want to use.
This means that even though the attacker did all the path resolving they wanted, even if they get right down to /
, their efforts are still moot — they’re still stuck in our sandbox.
Making It Happen
Here’s the code4 :
# main.py
import os
from pathlib import Path
from fastapi import FastAPI, status
from fastapi.responses import FileResponse, Response
app = FastAPI()
@app.get("/archive")
async def read_item(file: str = "README.txt"):
base_dir = Path(os.getcwd())
archive_dir = base_dir / "archive"
try:
# Step 1: Path Traversal Check
path = (
archive_dir.joinpath(file)
.resolve()
.relative_to(archive_dir.resolve())
)
# Step 2: Regular Existence Checks
full_path = archive_dir / path
if not full_path.exists():
raise FileNotFoundError
# Step 3: Returning the file
return FileResponse(full_path)
except ValueError:
return Response(
content="Archive file not found.",
status_code=status.HTTP_404_NOT_FOUND,
)
except FileNotFoundError:
return Response(
content="Archive file not found.",
status_code=status.HTTP_404_NOT_FOUND,
)
Okay, let’s break this one down because there’s a fair bit that’s going on at once.
First we just get the directory the app is executing from — that’s base_dir
, which should map to “path-traversals”.
Then, we get where we want to load files from — that’s “path-traversals/archive”, stored in archive_dir
.
Once we’re in the try/except
section, we do 3 things:
The actual path traversal check itself.
Checking if the file exists.
Returning the file if all goes well.
For the check itself, we start by joining archive_dir
and file
.
Then, .resolve()
and .relative_to()
are the tickets to solving our problem. These evaluate the paths to see if our joined path is actually reachable from the archive/
directory without backtracking.
If it is? No problem, just return the path and we can do our regular “not found” checks on the file as normal5 . The only catch here is we need to redo the join, because we set the path relative to the archive/
directory, not the base directory. If we tried to retrieve it as relative to the archive/
directory, we’re not going to be able to.
If it isn’t reachable? The function throws a ValueError
, which, here, we manage to catch with a 404 “Not Found” response. Note that this is identical to the response we give if we didn’t have an attack but didn’t find a file, either.
That should be the attack mitigated.
Testing It Again

Homepage — check!

Retrieving a file explicitly — check!

Nonexistent files — check!

Relative path traversal — check!

Absolute path traversal — check!
And it works perfectly without compromising functionality.
Conclusion
This was a look at how to mitigate path traversals in a slightly unconventional way: By letting them resolve!
FastAPI was the framework of choice here, but the principles apply everywhere: Let the path resolve, and then check if it’s within your bounds.
Hope you get some use out of this in your endeavours, and safe coding, folks!
1 This is akin to something like the C:
path on Windows.
2 While I do use the python venv
module here, I highly recommend using something like uv
, since it manages all the virtual environment stuff for you in a simple and consistent manner. https://docs.astral.sh/uv/
3 Did I mention I’ve done martial arts since I was 6? Probably should have led with that.
4 Shout out to Maarten Fabré on StackOverflow for the answer that inspired this article (that I had to adapt for this particular use case): https://stackoverflow.com/questions/45188708/how-to-prevent-directory-traversal-attack-from-python-code
5 We still have to do these check, otherwise the system will complain about paths that are within our bounds but don’t exist.
Reply