Skip to content
CWE-98A03:2021 – Injection

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

WrapperWhat it doesGated byGuide
file://Explicit local filesystem. Identical to a bare path.Nothing (open_basedir applies)This page
http:// https://Fetches a remote URL.allow_url_fopen to read, allow_url_include to includeClassic RFI
ftp://Same, over FTP.The same two flagsClassic RFI
php://filterApplies conversion filters to another stream.Nothingphp://filter source reads
php://inputThe raw request body.allow_url_include to includeexpect:// and php://input
php://temp php://memoryAn empty read/write scratch stream.NothingPHP filter chains
data://Content inline in the URL itself.allow_url_fopen to read, allow_url_include to includeThe data:// wrapper
zip://A file inside a ZIP archive.NothingUploads, zip:// and phar://
phar://A file inside a Phar archive, plus metadata deserialization.NothingPhar deserialization
glob://Directory enumeration. Not usable by include().NothingThis page
expect://Executes a command. Requires the PECL expect extension.The extension being installed, which it is notexpect:// and php://input

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.inflate on 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

Payloadparameter values
# 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://temp

A 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

PHPon a target you control
<?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=Off removes http, https, ftp, ftps and data as 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=On is the default and prevents writing Phar archives. It does not prevent reading them, so it does not stop phar deserialization.
  • open_basedir confines path resolution, which limits file://, zip://, phar:// and the resource= of a filter — but not data:// 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.