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
# 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.1Reading the response
You are almost never told what happened. Learn the failure shapes:
| What you see | What it means |
|---|---|
| The file contents | Traversal works, sink reads or the file has no PHP tags |
| A blank page where content used to be | Often success — the file was executed rather than printed. Try a file with no PHP tags |
failed to open stream: No such file | Path resolution reached somewhere real but wrong. Adjust depth or spelling |
failed to open stream: Permission denied | The path resolved and the worker cannot read it. The traversal works; pick a different target |
open_basedir restriction in effect | Confirmed traversal, confined. See open_basedir |
| Identical response to a garbage path | Either a fallback default, or your input is not reaching the sink at all |
| 500 with no body | Suppressed 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
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. Everyphp://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.
Related
Traversal works because of specific, boring rules about how a string becomes a file. Knowing them turns guessing at ../ counts into arithmetic.
include() executes PHP instead of showing it to you. Base64-encode the stream first and it comes back as data — the single most useful request in a PHP inclusion engagement.
In a containerised deployment the secrets are in the environment, not the config file. /proc/self/environ is one traversal away and is frequently the whole engagement.
include($page . '.php') is the most common shape of this bug, and the two famous bypasses for it both died in 2010. Here is what is actually left.
A filter that removes bad strings has to anticipate every spelling. Resolution has to anticipate nothing. That asymmetry is why str_replace loses.