Skip to content
CWE-22A01:2021 – Broken Access Control

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.

  1. 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.
  2. .. removes the previous component. It is textual, applied left to right, and it does not care whether the intermediate directories exist.
  3. .. at the root is a no-op. /../../.. is /. This is why over-padding is free and under-padding fails — always pad.
  4. Repeated separators and . collapse. a//b, a/./b and a/b are the same path.

Worked resolution

Payloadinclude('/var/www/html/pages/' . $page)
$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

FormLinuxWindowsNote
../YesYesWindows accepts forward slashes throughout the Win32 API.
..\No — a literal filenameYesWorth trying against a filter that only knows about /.
..%2fAfter one decodeAfter one decodeThe web server decodes before the app sees it.
....//Only after a filter rewrites itSameInert on its own; see Beating Blacklists.
C:\Windows\win.iniNoYesThe /etc/passwd of Windows targets.
\\host\share\fileNoYesA UNC path is a filesystem path, so no URL flag applies. See SMB and UNC Inclusion.

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/passwd with 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 check and 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.