Skip to content
All posts
webrevised May 1, 20267 min read

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.php tells 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/hostname is one line and unambiguous. /proc/self/cmdline tells you what the process is, which shapes everything after.
TargetWhat it gives you
/etc/passwdConfirmation, plus the list of users and their home directories.
/proc/self/environEnvironment variables - often database credentials, secret keys, and the flag itself.
/proc/self/cmdlineThe 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/NOpen file descriptors, including log files whose paths you do not know.
/app/.env, config.php, settings.pyApplication secrets. Guess from the framework the errors reveal.
/root/.ssh/id_rsa, ~/.bash_historyOnly if the process runs as root, which in a container it often does.
.git/config and .git/HEADThe 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.

FilterBypassWhy
Strips ../ once, non-recursively....// or ..././Removing the inner ../ leaves a ../ behind.
Blocks the literal string ../..%2f, %2e%2e%2f, ..%252fThe check runs before the URL decode, or before a second decode.
Requires the path to start with a known prefix/var/www/images/../../../etc/passwdThe prefix is present. It is just not where the path ends.
Appends an extension, e.g. .phpA null byte (PHP < 5.3.4), or find a file that has itRarely bypassable on modern PHP. Use a wrapper instead.
Blocks absolute pathsTraverse from the relative base insteadYou never needed an absolute path.
Allowlists a set of filenamesLook for one that is itself an inclusionThe 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.php returns 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://input with PHP code in the body is direct code execution - when allow_url_include is 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.iconv conversions can be made to produce arbitrary bytes from nothing, turning a read-only php://filter primitive 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=id
The User-Agent is the usual carrier because it is logged unescaped by default. Common paths: /var/log/apache2/access.log, /var/log/nginx/access.log, /var/log/httpd/access_log. A log you cannot read is a log you cannot poison, so confirm the read first.

3. 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.static with dotfiles misconfigured serves .env and .git. Look also for res.sendFile without a root option.
  • Python: os.path.join(base, user) returns user outright if user is absolute - a widely-missed footgun. Flask's send_from_directory is safe; send_file with concatenation is not.
  • Java: traversal in a ZipEntry name is Zip Slip, which writes outside the extraction directory rather than reading outside a serving directory.
  • Nginx alias misconfiguration: a location /static with alias /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

  1. Find every parameter that names a resource.
  2. Confirm concatenation with ./file before traversing.
  3. Read the error message and take the document root, the sink function, and any appended suffix from it.
  4. Over-traverse to /etc/passwd or /etc/hostname.
  5. If blocked, work through the encoding table - it is six requests.
  6. If PHP: read the vulnerable script's own source with php://filter immediately.
  7. Read /proc/self/environ and the app config for secrets before escalating - the flag is frequently just there.
  8. 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.