PHP Wrappers and Streams
PHP's stream layer makes every filesystem function accept a URL. That one design decision is why file inclusion in PHP is a different bug from file inclusion anywhere else.
Why PHP is different
In Node, Java, .NET, Python, Ruby and Go, a filename is a filename. fs.readFile('php://filter/…') looks for a directory called php: and fails.
In PHP, a filename goes through the stream wrapper layer first. Any registered scheme is reachable from any function that takes a path — include, file_get_contents, fopen, copy, file_exists, getimagesize. A single unvalidated parameter therefore reaches a great deal more than the filesystem.
This is the reason the cheatsheet has one column with a stream layer and six without, and the reason a PHP inclusion bug escalates in ways the same bug in Express does not.
The wrappers that matter
Filters are the interesting half
php://filter is not itself a source of data — it wraps another stream and transforms what comes through it. The syntax is:
php://filter/<filter>|<filter>|…/resource=<the actual stream>
Filters compose left to right. The families that matter:
convert.base64-encode/convert.base64-decode— the workhorses. Encoding is how you read PHP source without executing it; decoding is the terminator on an RCE chain.convert.iconv.<from>.<to>— character-set transcoding. Individually dull. Chained, they are the entire basis of PHP filter chains, because some conversions emit bytes of their own.string.toupper/string.tolower/string.rot13— occasionally enough to slip a payload past a naive content check.zlib.deflate/zlib.inflate— compression.zlib.inflateon a file of your choosing is sometimes a way to turn uploaded bytes into different bytes.
The crucial property, and the one that decides everything else on this site: php://filter is not governed by allow_url_include. It is not a URL in the sense that flag means. It runs on a completely stock php.ini.
Filters in practice
# Read PHP source instead of executing it — the reliable first move.
php://filter/convert.base64-encode/resource=config.php
# Filters compose. This one base64s, then uppercases the base64.
php://filter/convert.base64-encode|string.toupper/resource=config.php
# Transcode on the way out. Occasionally slips past a check on the response body.
php://filter/convert.iconv.UTF-8.UTF-16LE/resource=config.php
# The resource can itself be a wrapper.
php://filter/convert.base64-encode/resource=/proc/self/environ
# An empty scratch stream. A chain that synthesises its own content needs no real file.
php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode/resource=php://tempA wrapper only counts at position zero
This is the constraint that decides whether any of the above is available to you, and it is easy to miss.
PHP looks for a scheme at the start of the string. So:
include($_GET['page']); // php:// works
include('pages/' . $_GET['page']); // php:// is now a directory name
In the second case your input starts at offset 6, and pages/php://filter/... is just a path with colons in it. Every wrapper on this page becomes unavailable at once.
This is why a prefix — which nobody adds for security reasons — is quietly one of the more effective mitigations against the PHP-specific half of this bug class. It does nothing at all against traversal: pages/../../../etc/passwd still resolves fine. See Allowlist and Prefix Bypasses, and try it in the lab.
There is no traversal trick that recovers a wrapper from a prefixed sink. The scheme has to be first, and nothing you put in the parameter can move text that is already in front of it.
Finding out what is registered
<?php
// What this build actually has. Worth running on a matching version
// locally rather than guessing at the target's.
print_r(stream_get_wrappers());
// Typical PHP 8 default build:
// https, ftps, compress.zlib, php, file, glob, data, http, ftp, phar
print_r(stream_get_filters());
// convert.iconv.*, string.rot13, string.toupper, string.tolower,
// convert.*, consumed, dechunk, zlib.*
// Note what is NOT there by default: expect, ssh2, ogg, rar, bzip2.
// Anything that needs a PECL extension is almost certainly absent.Turning wrappers off
PHP has no supported way to unregister the built-in wrappers from php.ini. stream_wrapper_unregister() exists but is a runtime call in application code, which means it runs after any attacker-reachable code that loads first — and it is trivially undone by anything with code execution.
What is available:
allow_url_fopen=Offremoveshttp,https,ftp,ftpsanddataas readable streams. This is a real reduction and worth doing on anything that does not make outbound HTTP through PHP's filesystem functions.phar.readonly=Onis the default and prevents writing Phar archives. It does not prevent reading them, so it does not stop phar deserialization.open_basedirconfines path resolution, which limitsfile://,zip://,phar://and theresource=of a filter — but notdata://or a self-synthesising filter chain, neither of which touches the filesystem. See open_basedir and disable_functions.
None of these is the fix. The fix is not letting a request parameter be a path at all — see Defense in Depth.
Related
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.
Stack enough iconv conversions and php://filter stops reading files and starts producing them. A read-only primitive becomes code execution on a completely stock php.ini — no upload, no writable directory, no outbound connection.
Carry the payload inside the URL itself. No outbound connection, no host to serve from — and gated by exactly the same flag as http://, despite never touching the network.
Two wrappers that turn an include straight into execution with no file involved at all. One needs an extension nobody installs; the other needs the flag that has been off since 2006.
Two hardening settings that are worth having and are not fixes. Knowing exactly what each one confines tells you what is still reachable when you meet them.