These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecurityLocal File Inclusion

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=php

Also check php.ini:

php -i | rg 'allow_url_(fopen|include)'

Test inputs

Source disclosure:

  • php://filter/convert.base64-encode/resource=index.php
  • php://filter/read=convert.iconv.utf-8.utf-16/resource=/etc/passwd (defeats simple strpos("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 — expect extension)

Audit focus

For each dynamic include:

  1. Origin$_GET, $_POST, header, cookie, decoded JSON, session set from request, DB value originally from request?
  2. allow_url_include — must be Off. Check every environment (dev, staging, prod, CI containers, shared hosting).
  3. Filter shape — allowlist of filenames (good) vs denylist of substrings (bad)? Substring denylists fall to encoding, double encoding, charset chains, or absolute paths.
  4. Forced extensioninclude $base . $page . ".php" stops some payloads (PHP < 5.3.4 needs null byte) but not php://filter or data://.
  5. 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.
  6. 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;