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

Traversal Basics

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.

Nothing turns this off

There is no allow_local_include. No PHP release deprecated ... No default configuration stops include('../../../../etc/passwd'), and none ever will, because reaching a file by relative path is what relative paths are for.

That is the reason this guide, rather than Classic RFI, describes what you will actually be doing. The remote variant needs a configuration from 2006. This needs a sink and a parameter.

The first four requests

HTTP
# 1. Absolute path. Free to try, and if it works you know nothing is
#    being prepended — which tells you every wrapper is available too.
GET /index.php?page=/etc/passwd HTTP/1.1

# 2. Padded traversal. Ten levels costs nothing: extra .. at / are no-ops.
GET /index.php?page=../../../../../../../../../../etc/passwd HTTP/1.1

# 3. The application's own source, through the filter wrapper. This is the
#    one that actually advances the engagement — see /guide/php-filter-read
GET /index.php?page=php://filter/convert.base64-encode/resource=index.php HTTP/1.1

# 4. A file you know is there and is boring, to calibrate what success and
#    failure look like in the response.
GET /index.php?page=../pages/home.php HTTP/1.1

# Windows targets: swap step 2 for
GET /index.php?page=../../../../../../../../Windows/win.ini HTTP/1.1

Reading the response

You are almost never told what happened. Learn the failure shapes:

What you seeWhat it means
The file contentsTraversal works, sink reads or the file has no PHP tags
A blank page where content used to beOften success — the file was executed rather than printed. Try a file with no PHP tags
failed to open stream: No such filePath resolution reached somewhere real but wrong. Adjust depth or spelling
failed to open stream: Permission deniedThe path resolved and the worker cannot read it. The traversal works; pick a different target
open_basedir restriction in effectConfirmed traversal, confined. See open_basedir
Identical response to a garbage pathEither a fallback default, or your input is not reaching the sink at all
500 with no bodySuppressed errors. Move to timing or out-of-band confirmation

The second row is the one that wastes the most time. A blank response to ?page=../config.php looks like failure and is frequently a total success — include() executed the file, and a config file that only assigns variables produces no output. Point it at /etc/passwd to confirm the traversal, then use php://filter to read the source you actually wanted.

What to read, in order

TargetWhyNotes
The app's own sourceTells you the sink, the filters, and what else is reachableNeeds php://filter — see the source-read guide
config.php / .env / settings.pyDatabase credentials, framework keys, API tokensThe usual endgame for a read primitive
/proc/self/environInjected secrets in a containerised deploymentOften better than the config file now
/proc/self/cmdlineThe worker's argv, which sometimes carries credentialsNull-separated; view as raw bytes
/etc/passwdProof of arbitrary read; usernames for laterProves the bug, not the impact
/proc/self/mountsContainer detection and where volumes are mountedTells you what else is worth reading
~/.ssh/id_rsa, ~/.aws/credentialsDirect lateral movementRarely readable by the web user; try anyway
/var/log/*/access.logA write primitive, if readableSee Log Poisoning
/etc/shadowNothing — you cannot read itThe worker is not root. Stop trying

Finding the depth without guessing

Padding makes depth mostly irrelevant. When it is not — because something normalises the path before checking it, or a filter caps the number of ../ — you can measure instead.

Read the error. failed to open stream messages usually contain the fully resolved path. One request tells you the base directory and therefore the exact depth. This is the fastest method by a wide margin, and suppressed errors are the only reason not to use it.

Bisect on a known file. /etc/passwd exists on every Linux host. Increase the count until it returns. The transition point is the depth.

Read the source. If php://filter works, read the including file and count the directories in the prefix. This is also how you learn whether the sink executes and whether an extension is appended.

Ask the framework. A stack trace, a debug page, or a 404 handler will often print an absolute path. So will phpinfo() if it is exposed.

The constraints you will actually hit

In rough order of how often they stop people:

  • A prepended directory. include('pages/' . $page). Traversal still works. Every php:// wrapper does not — see PHP Wrappers and Streams.
  • An appended extension. include($page . '.php'). You can only reach files ending .php, and the historic bypasses are dead. See Extension Append Bypasses.
  • A filter on the input string. str_replace, a regex, a denylist. Almost always beatable, because it is checking spelling rather than resolution. See Beating Blacklists.
  • open_basedir. Confines you to the app tree — which usually still contains the config file. See open_basedir and disable_functions.
  • An allowlist. This one is the actual fix. If the parameter is mapped through a fixed table, you are done — go and find a different bug.

Work through them in the lab, which lets you toggle each one and watch the resolution change.

When nothing comes back

A sink whose output never reaches the response is still exploitable, just slower.

Timing. Reading a large file takes measurably longer than failing to open a small one. /dev/random blocks. A deliberately deep traversal into a directory with many entries is slower than a shallow one. Compare a path you know exists against one you know does not.

Differential responses. A 500 for a missing file and a 200 for a present one is an oracle even with no content. That is enough to enumerate the filesystem one path at a time.

Side effects. If the sink executes, an included file that makes an outbound DNS or HTTP request confirms it out of band. This needs a write primitive first — see Log Poisoning.

Error suppression is rarely total. display_errors=Off still writes to the log, and a distinct HTTP status or response length frequently leaks the same information the message would have.