Local File Inclusion (LFI)
include, require, and their _once variants compile and execute
the included file as PHP. If the path is attacker-controlled, the attacker
picks the code that runs. Even when the inclusion is “read-only intent”,
PHP wrapper streams turn it into source disclosure or code execution.
Why
include $_GET['page'] . ".php"; // textbook?page=../../../etc/passwd%00 (legacy null-byte truncation) reads
/etc/passwd. ?page=php://filter/convert.base64-encode/resource=index
leaks the application’s source. ?page=data://text/plain,<?php system($_GET[0]);?> runs arbitrary code (when allow_url_include is on,
see RFI).
The trick include has over file_get_contents is that the included file
is executed — so any path that lets the attacker plant bytes that look
like PHP becomes RCE. Log files, session files, /proc/self/environ,
uploaded images with embedded <?php …, the apache access log — all
classics.
Search patterns
# Dynamic include with user input
rg -n '\b(include|require|include_once|require_once)\b\s*\(?' --type=php \
| rg '\$_(GET|POST|REQUEST|COOKIE)|\$_SESSION'
# Concatenation patterns — extension forced but path tainted
rg -n '\b(include|require)\b\s*\(?\s*[''"][^''"]*[''"]\s*\.\s*\$' --type=php
rg -n '\b(include|require)\b\s*\(?\s*\$\w+\s*\.\s*[''"]\.php[''"]' --type=php
# Indirect — variable from a map
rg -n '\b(include|require)\b\s*\(?\s*\$\w+\[' --type=phpAlso check php.ini:
php -i | rg 'allow_url_(fopen|include)'Test inputs
Source disclosure:
php://filter/convert.base64-encode/resource=index.phpphp://filter/read=convert.iconv.utf-8.utf-16/resource=/etc/passwd(defeats simplestrpos("php://")denylists via charset chains)php://filter/convert.base64-encode/resource=/var/www/html/config.php
Direct read:
/etc/passwd/etc/hosts/proc/self/environ(env vars, sometimes RCE via env injection)/proc/self/cmdline/var/log/apache2/access.log(poison + replay → RCE)/var/log/nginx/access.log/var/lib/php/sessions/sess_<id>(session poisoning → RCE)
Traversal variants:
../../../../etc/passwd..%2f..%2f..%2fetc/passwd..%252f..%252fetc/passwd....//....//etc/passwd(filter that strips../once)- Absolute path:
/etc/passwd(defeats naive$base . $input) - Null byte (legacy PHP < 5.3.4):
../../etc/passwd%00.jpg
Direct execution:
data://text/plain,<?php phpinfo();?>data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+expect://id(rare —expectextension)
Audit focus
For each dynamic include:
- Origin —
$_GET,$_POST, header, cookie, decoded JSON, session set from request, DB value originally from request? allow_url_include— must be Off. Check every environment (dev, staging, prod, CI containers, shared hosting).- Filter shape — allowlist of filenames (good) vs denylist of substrings (bad)? Substring denylists fall to encoding, double encoding, charset chains, or absolute paths.
- Forced extension —
include $base . $page . ".php"stops some payloads (PHP < 5.3.4 needs null byte) but notphp://filterordata://. - Upload-then-include chain — does the application accept uploads that get written to a path includable by this call? That’s the cheap RCE path.
- Session / cache files — can the attacker write controllable bytes to a session file, log line, or cache entry that lands at a path the include can reach?
include failures are warnings, not fatal errors, by default. An LFI
endpoint that “appears to work” may still be silently disclosing source
via php://filter. Always check the response body when probing.
Fix
Static map. Concatenation with user input is never OK here.
$pages = [
"home" => "pages/home.php",
"contact" => "pages/contact.php",
"about" => "pages/about.php",
];
$key = $_GET['page'] ?? 'home';
$page = $pages[$key] ?? $pages['home'];
include $page;When the set is genuinely dynamic (a CMS), confine the path with
realpath() and verify it lives under an allowed base directory — and
still force the extension:
$base = realpath('/var/app/views');
$path = realpath($base . '/' . basename($_GET['page']) . '.php');
if ($path === false || !str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
abort();
}
include $path;