Local File Inclusion is a vulnerability in which an attacker controls the path passed to a PHP file-inclusion function, letting them read arbitrary local files and — via stream wrappers or log poisoning — escalate to remote code execution.
PHP's include, include_once, require, and require_once do not merely read a file — they execute any embedded PHP inside it. When the path argument is built from user input (a query-string page name, a language selector, a template ID) without validation, an attacker can redirect the inclusion to files outside the intended directory.
Root cause: untrusted data flows into a filesystem path that is then interpreted as PHP. Classic vulnerable patterns look like include $_GET['page'] . '.php';. The bug commonly appears in:
- Home-grown routers / front controllers (
?page=about). - Template and theme loaders.
- Language / locale switchers (
?lang=en). - Legacy CMS plugins that dispatch on a user-supplied module name.
LFI differs from Remote-File-Inclusion (RFI) in that the included resource is a local path, but the two share a root cause and LFI is frequently the more dangerous because allow_url_include is off by default on modern PHP, killing most RFI while LFI remains exploitable. LFI is a specialised, high-impact form of Directory-Traversal where the read file is also executed.
<?php
// VULNERABLE: user input is concatenated directly into an include path
declare(strict_types=1);
$page = $_GET['page'] ?? 'home';
// Attacker fully controls the path prefix/suffix
include __DIR__ . '/pages/' . $page . '.php';Even the appended .php suffix is not a real defence — it can be defeated with a null byte on old PHP, with a path-truncation trick, or bypassed entirely when the goal is code execution via a wrapper or a poisoned log rather than reading .txt files.
1. Arbitrary file read via traversal. Walk out of pages/ to sensitive files:
GET /index.php?page=../../../../etc/passwd%00 HTTP/1.1
Host: victim.exampleOn modern PHP (>= 5.3.4) the %00 null-byte truncation is patched, but plain traversal still reads any file the web-server user can access when no suffix is enforced — configuration files, .env secrets, SSH keys.
2. Source disclosure with php://filter. Base64-encode PHP source so it is returned instead of executed, leaking credentials and further bugs:
GET /index.php?page=php://filter/convert.base64-encode/resource=../config/database HTTP/1.13. RCE via data:// wrapper (only if allow_url_include=On, which is off by default):
GET /index.php?page=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOz8+ HTTP/1.14. RCE via log poisoning. Inject PHP into a file the attacker can influence and then include it. Send a request whose User-Agent contains <?php system($_GET['c']); ?>; the web server writes it to access.log, then:
GET /index.php?page=../../../../var/log/apache2/access.log&c=id HTTP/1.1The included log is executed and the injected command runs as the web-server user. Similar sinks: session files under /tmp/sess_*, /proc/self/environ, mail spools, and uploaded files (File-Upload-Bypass).
Educational, defensive framing only — perform these tests exclusively against systems you are authorised to assess.
| Dimension | Risk |
|---|---|
| Confidentiality | High — read source, config, secrets, /etc/passwd |
| Integrity | High when chained to RCE (log/session poisoning) |
| Availability | Medium — including huge/device files can exhaust resources |
| Typical outcome | Source disclosure → credential theft → remote code execution → full host compromise |
| Detection difficulty | Moderate — traversal sequences and php:// wrappers leave log traces |
Prioritised, strongest first:
- Do not pass user input to include/require. Map requests to files through a fixed allow-list, never through a path built from input.
- Allow-list the set of includable views by exact key, then include a hard-coded constant path.
- If a filename genuinely must come from input, take only the basename (
basename()), append a fixed extension, and confirm the resolvedrealpath()stays inside the intended base directory. - Disable dangerous wrappers/config: keep
allow_url_include=Offandallow_url_fopen=Offwhere feasible; restrict withopen_basedir. - Least privilege: run PHP-FPM as a low-privilege user with no read access to secrets, logs, or SSH keys outside the app root.
- Prefer a real router/framework dispatch (controllers) over dynamic file inclusion entirely.
<?php
declare(strict_types=1);
// 1) Allow-list: request key -> fixed, hard-coded file. No user path is ever built.
$routes = [
'home' => __DIR__ . '/pages/home.php',
'about' => __DIR__ . '/pages/about.php',
'contact' => __DIR__ . '/pages/contact.php',
];
$page = $_GET['page'] ?? 'home';
// Reject anything not explicitly permitted.
if (!array_key_exists($page, $routes)) {
http_response_code(404);
// Escape any reflected value to avoid a secondary XSS.
echo 'Unknown page: ' . htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
exit;
}
require $routes[$page];If a dynamic filename is unavoidable, canonicalise and containment-check it:
<?php
declare(strict_types=1);
function safeInclude(string $baseDir, string $userFile): void
{
$base = realpath($baseDir);
// Strip directory components; force a single, fixed extension.
$candidate = $base . DIRECTORY_SEPARATOR . basename($userFile) . '.php';
$resolved = realpath($candidate);
if ($base === false || $resolved === false
|| !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) {
http_response_code(400);
exit('Invalid resource');
}
require $resolved;
}
safeInclude(__DIR__ . '/pages', $_GET['page'] ?? 'home');The allow-list form is strongly preferred — realpath() containment is a fallback, not a first choice.
Code review — grep for the sinks and check whether the argument is tainted:
grep -rnE '\b(include|include_once|require|require_once)\b' --include='*.php' . \
| grep -E '\$_(GET|POST|REQUEST|COOKIE|SERVER)'Flag any inclusion whose path contains string concatenation with a superglobal, basename()-only "sanitisation" that still trusts input, or a suffix appended for safety.
Static analysis: Psalm/PHPStan taint analysis, Semgrep (php.lang.security.include-arg), RIPS-style dataflow. Treat the four inclusion functions and fopen/file_get_contents as sinks.
Logs / runtime: alert on request parameters containing ../, ..%2f, php://filter, data://, expect://, phar://, or paths pointing at /etc/passwd, /proc/self/, or your own log files. Watch for a value first appearing in a header (User-Agent, Referer) then reappearing as a ?page= argument — the signature of log-poisoning-to-RCE.
WAF: ModSecurity CRS rules 930100–930130 cover LFI/traversal and PHP wrapper schemes; keep them in blocking mode. Add file-integrity monitoring on log directories so poisoned-then-included logs are caught.
- OWASP File Inclusion / Path Traversal
- OWASP Testing Guide — Testing for Local File Inclusion
- PHP Manual — include
- PHP Manual — Supported protocols and wrappers
- Remote-File-Inclusion — sibling class where the included resource is remote; shares the root cause
- Directory-Traversal — the traversal primitive LFI builds on to escape the base directory
- Path-Traversal-Prevention — canonicalisation and containment checks that also harden includes
- File-Upload-Bypass — uploaded files become an LFI RCE sink when their path can be included
- Command-Injection — the frequent end state once LFI reaches code execution
- Input-Sanitization — why allow-lists beat blacklist filtering of
../sequences