How Paths Resolve
Traversal works because of specific, boring rules about how a string becomes a file. Knowing them turns guessing at ../ counts into arithmetic.
The rules
Every traversal payload is an application of four rules. None of them is subtle, and knowing them is the difference between counting ../ by trial and error and knowing how many you need.
- A path starting with
/is absolute. Anything the application prepended is irrelevant — unless it prepended it as a string, in which case the result is no longer absolute. That distinction is the whole of rule 4. ..removes the previous component. It is textual, applied left to right, and it does not care whether the intermediate directories exist...at the root is a no-op./../../..is/. This is why over-padding is free and under-padding fails — always pad.- Repeated separators and
.collapse.a//b,a/./banda/bare the same path.
Worked resolution
$page = ../../../etc/passwd
/var/www/html/pages/../../../etc/passwd
/var/www/html/pages <- start
/var/www/html <- ..
/var/www <- ..
/var <- ..
/var/etc/passwd <- and now we are lost
Three was not enough. The directory depth is four:
$page = ../../../../etc/passwd
/var/www/html/pages/../../../../etc/passwd
-> /etc/passwd <- correct
But you do not know the depth from outside. So pad:
$page = ../../../../../../../../../../etc/passwd
-> /etc/passwd <- extra .. at / are no-ops (rule 3)
Ten is free. Use ten.When absolute paths work
If the sink takes your input unmodified, skip the traversal entirely:
?page=/etc/passwd
This is worth trying first, always. It is shorter, it survives filters that only look for .., and if it works it tells you immediately that nothing is being prepended.
If a prefix is concatenated, an absolute path does not help in PHP, Node, Java or Go — 'pages/' . '/etc/passwd' is 'pages//etc/passwd', which collapses to pages/etc/passwd by rule 4.
But in Python and .NET it does, and this is the single most valuable runtime-specific fact on this page:
os.path.join('templates', '/etc/passwd') # -> '/etc/passwd'
Path.Combine("templates", "/etc/passwd") // -> "/etc/passwd"
Both discard the first argument entirely when the second is absolute. An application that carefully builds a safe base directory and then joins user input onto it has, in those two runtimes, built nothing at all. See Python and ASP.NET Core.
Separators and platform
realpath(), symlinks, and why the check goes last
realpath() resolves .., ., repeated separators and symlinks, returning the canonical path — or false if the file does not exist.
That last clause is why the correct pattern is to resolve first and check second:
$real = realpath($base . '/' . $page);
if ($real === false || !str_starts_with($real, $base)) { /* reject */ }
Checking the input string instead of the resolved path is the mistake that every bypass on this site exists to exploit. A string check has to anticipate every encoding; a resolved-path check has to anticipate nothing, because resolution has already happened.
Symlinks are the reason a string check fails even against an opponent who never encodes anything. If an upload directory inside the app tree contains a symlink pointing at /, then uploads/link/etc/passwd contains no .. at all and still leaves the tree. realpath() catches it. str_replace('../', '') does not.
The cost of doing it correctly is that realpath() returns false for a file that does not exist, so the check rejects nonexistent files. That is usually correct behaviour and occasionally a bug report from a developer who wanted a 404 instead of a 400.
Try the rules
The lab implements exactly these rules and shows you the path at each stage of the pipeline. Three things worth doing there:
- Send
../../../etc/passwdwith no defences and watch it resolve. Then add../and confirm the extra ones cost nothing. - Turn on
str_replace('../', '')and send....//....//....//etc/passwd. Watch the filter construct the traversal it was meant to remove. - Turn on
realpath() + prefix checkand try every payload on the page. This is the one that holds.
The last of those is the point of the exercise. Every encoding in Encoding Bypasses targets a string check. None of them targets resolution, because resolution cannot be tricked by spelling.
Related
The technique that needs no configuration, no flag, and no version. Nothing in any php.ini stops it, which is why every live finding in 2026 starts here.
Every encoding here targets a filter that inspects the input string. None of them targets path resolution, because resolution cannot be fooled by spelling.
A filter that removes bad strings has to anticipate every spelling. Resolution has to anticipate nothing. That asymmetry is why str_replace loses.
os.path.join throws away the base directory the moment the second argument is absolute. No ../ required, and no filter looking for one will see it.
Path.Combine discards everything before an absolute path. An application that carefully builds a safe base directory and then combines user input onto it has built nothing.