Methodology
An order of operations that answers the questions that change what you do next, before spending time on payloads that the answers would have ruled out.
The order matters
Most time lost on this bug class goes to trying payloads against a target where the answer was already determined. There is no point cycling through encodings against a real allowlist, or trying filter chains against a sink with a directory prefix.
So the order below is arranged by how much each answer eliminates. Four questions decide almost everything:
- Does the sink execute, or only read?
- Is your input at the start of the string?
- Is a suffix appended?
- Is the check on the input string, or on the resolved path?
One good source read answers all four at once, which is why it comes early.
1. Find the parameters
Look for values that name a resource rather than describe one. The names repeat across applications:
page file path template tpl view include inc document doc folder dir root pg style theme lang locale module mod action cat content name download report attachment img image src url load read show detail type layout skin conf config
Do not only look in the query string. Check the POST body, cookies, JSON fields, path segments, and headers the application reads. A lang cookie reaching a template loader is the same bug with less traffic through it.
And look at what the values look like: a parameter whose normal value is home or en or invoice.pdf is naming a file even if the parameter is called t. A parameter whose normal value is 47 probably is not.
2. Confirm the sink, cheaply
# Four requests. Compare responses rather than looking for success.
# a. The baseline. Record length, status, and timing.
GET /index.php?page=home
# b. A path that certainly does not exist. The DIFFERENCE from the baseline
# is the oracle you will use for everything else.
GET /index.php?page=zzzznonexistentzzzz
# c. Absolute. If this works, nothing is prepended — every wrapper is live.
GET /index.php?page=/etc/passwd
# d. Padded traversal.
GET /index.php?page=../../../../../../../../etc/passwd
# Read the errors. An error naming the full attempted path answers the
# prefix and suffix questions in one request and is worth more than the
# next ten payloads:
#
# failed to open stream: No such file or directory
# in /var/www/html/index.php on line 4
# include(/var/www/html/pages/zzzz.php)
# ^^^^^^ ^^^^
# prefix suffix3. Read the source
On PHP, this is the request that changes the engagement:
?page=php://filter/convert.base64-encode/resource=index.php
It tells you the sink function, the prefix, the suffix, every filter applied, and the names of every file the application includes next. See Reading Source with php://filter.
If it returns base64, you have also just confirmed that wrappers are reachable, which means filter chains are available and you have a path to code execution.
If it does not work, that is equally informative — it means either a prefix is in the way, a suffix breaks the resource, or the sink is not PHP. Each of those redirects you somewhere specific.
On other runtimes there is no equivalent, so substitute: look for exposed .git, a source map, a stack trace, a composer.lock or package.json, or a debug endpoint. Failing that, work from the error messages.
4. Establish impact before escalating
Reading /etc/passwd proves arbitrary read. It is not impact and it should not be the only evidence in a report.
Go for the things that mean something, in this order:
- Application config.
config.php,.env,credentials.yml.encplusmaster.key,appsettings.json,settings.py. Database credentials and framework signing keys. /proc/self/environ. On a container this is frequently better than the config file — see /proc, environ, and File Descriptors.- Cloud and orchestrator credentials.
/var/run/secrets/kubernetes.io/serviceaccount/token,~/.aws/credentials, mounted secret files. - Anything that forges identity.
secret_key_base,APP_KEY, a JWT signing key. A read primitive that yields one of these is usually worth more than a shell, and it is quieter.
Stop and write up once you have something that demonstrates real impact. Escalating to code execution is often unnecessary and is always noisier — see the note in The Temp-File Race.
5. Escalate, if it is in scope
In order of cost — try them in this order, not in order of how impressive they are:
| Technique | Cost | Needs |
|---|---|---|
| Filter chain | One request | Executing sink, input at position 0, no suffix |
| Upload + zip:// or phar:// | A few requests | An upload feature and a known path |
| phar:// deserialization | A few requests | An upload and a gadget chain |
| Session poisoning | A few requests | File sessions, a controllable value |
| Log poisoning | A few requests | A readable, small log |
| Temp-file race | Hundreds of requests | Concurrency; very loud |
| Classic RFI | One request | allow_url_include=On, which it will not be |
The filter chain is first because it is one quiet request and needs nothing from the environment. The temp-file race is last because it is loud enough to matter operationally and can fill /tmp on the target.
6. Write it up
A report on this bug class needs six things, and the ones people leave out are the third and fourth:
- The exact request, including the method and any body.
- The response, trimmed to the evidence.
- The sink type — executes or reads. This decides the severity and the reader cannot infer it.
- The configuration you observed — runtime, version, and the relevant settings. "Confirmed on PHP 8.3 with a stock
php.ini" is a sentence that survives triage. - What you reached, and what it is worth. Not
/etc/passwd. - What you did not try, and why. "Escalation to RCE via a filter chain is available on this configuration and was not attempted, per the rules of engagement" tells the reader the true severity without you having to demonstrate it.
For the fix, recommend the allowlist first and describe open_basedir and friends as defence in depth. See Defense in Depth. Recommending allow_url_include=Off as a remediation is recommending a setting that is already off and was never relevant — it is the fastest way to signal that the report was written from a 2010 cheatsheet.
Related
What to use, what each tool is actually good at, and the wordlist problem that makes most automated LFI scanning less useful than it looks.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.
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.
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.
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.