Skip to content

Latest commit

 

History

History
189 lines (128 loc) · 8.57 KB

File metadata and controls

189 lines (128 loc) · 8.57 KB

Local File Inclusion (LFI)

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.


Overview

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.


Vulnerable Code

<?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.


Exploitation

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.example

On 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.1

3. 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.1

4. 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.1

The 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.


Impact

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

Remediation

Prioritised, strongest first:

  1. Do not pass user input to include/require. Map requests to files through a fixed allow-list, never through a path built from input.
  2. Allow-list the set of includable views by exact key, then include a hard-coded constant path.
  3. If a filename genuinely must come from input, take only the basename (basename()), append a fixed extension, and confirm the resolved realpath() stays inside the intended base directory.
  4. Disable dangerous wrappers/config: keep allow_url_include=Off and allow_url_fopen=Off where feasible; restrict with open_basedir.
  5. Least privilege: run PHP-FPM as a low-privilege user with no read access to secrets, logs, or SSH keys outside the app root.
  6. Prefer a real router/framework dispatch (controllers) over dynamic file inclusion entirely.

Secure Example

<?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.


Detection & Blue-Team

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.


References


Related