Inclusion vs Traversal
The same missing validation, the same payload, and two findings with different severities and different fixes. Whether the sink executes is the only thing that separates them.
One bug, two names
Path traversal is reaching a file outside the intended directory. File inclusion is the application loading a file you named. They overlap almost completely in how you find them and diverge completely in what they are worth.
The deciding question is one line long: does the sink execute what it loads?
If it does, the file you reach becomes code. If it does not, the file you reach becomes output. Everything else — the traversal encoding, the wrapper, the extension bypass — is plumbing that gets you to the same place either way.
The same payload into two sinks
<?php
// include() parses the file as PHP and runs it in the current scope.
// Reaching an attacker-controlled file here is code execution.
include($_GET['page']);
// Same for all four of these:
require($_GET['page']);
include_once($_GET['page']);
require_once($_GET['page']);Telling them apart on a live target
You usually cannot read the source, so you infer the sink from behaviour.
Point it at a file containing PHP tags. If the response contains the source of the file, the sink reads. If the response contains the result of that source, the sink executes. The application's own index.php is the convenient candidate — you know it exists and you know it contains PHP.
Point it at /etc/passwd. Both sink types return it verbatim, because it contains no PHP tags. This tells you the traversal works and nothing about the sink. It is the single most over-reported piece of evidence in this class of bug.
Read the source properly. php://filter/convert.base64-encode/resource=index.php returns base64 from either sink type, because the filter transforms the bytes before the sink ever sees them. This is why it is the correct first move — it works regardless of which sink you have, and it tells you what the application is doing. See Reading Source with php://filter.
Check whether output is even reflected. A sink whose output never reaches the response is blind. You have a bug, but you will be confirming it by timing, error messages, or a side effect rather than by reading the file.
Rating the two
A read primitive is not a consolation prize
It is tempting to write up arbitrary read as the disappointing version. In a modern deployment it is frequently the whole engagement.
Secrets are injected as environment variables, so /proc/self/environ hands you the database password, the object-store credentials, and whatever API keys the orchestrator mounted — see /proc, environ, and File Descriptors. Configuration is baked into the image, so config.php or .env is right there. Framework signing keys let you forge sessions. Cloud metadata credentials sit in a file on disk on some setups.
An arbitrary read that yields the application's signing key is usually worth more than a shell on one ephemeral container, and it does not trip the alerting that a spawned process does.
The corollary matters for triage: do not downgrade a read-only inclusion to medium because "it didn't execute". Rate what it reached.
Where the boundary is blurry
Three cases sit awkwardly between the two:
- Template loaders.
env.get_template(name)in Jinja,render file:in Rails, a Razor view name in ASP.NET. These execute, but only files the template engine will parse — which usually means the payload has to land in a template file first. Treat as executing, and say what the constraint is. phar://through a read sink. Covered above: read-only by appearance, execute-capable in practice. See Phar Deserialization.- A read primitive plus a write primitive. Reading a log file is disclosure. Reading a log file whose contents you control through your
User-Agent, through a sink that executes, is RCE — see Log Poisoning. Neither half is the finding; the combination is.
When it is genuinely ambiguous, describe the mechanism instead of picking a label. A triager can rate a mechanism. They cannot rate an acronym.
Related
An application decides which file to load based on something you control. Everything else on this site is a consequence of that one sentence.
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.
A phar:// reference unserializes the archive's metadata on any stream operation. That makes file_exists() an execute path, and it is the reason a read-only sink is not safe.
A read primitive plus a file whose contents you control is code execution. Write PHP into a log through a request header, then include the log.
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.