VNX-PHP-003 – PHP File Inclusion with Variable Path

Overview

This rule flags PHP include, require, include_once, and require_once statements where the path argument is derived from user-supplied superglobals ($_GET, $_POST, $_REQUEST, $_COOKIE), inline or through a variable assigned from one a few lines above. Allowing user-controlled values to determine which file PHP loads is a Local File Inclusion (LFI) vulnerability; if the server permits URL wrappers in include paths, it escalates to Remote File Inclusion (RFI) and full remote code execution.

Severity: Critical | CWE: CWE-98 – Improper Control of Filename for Include/Require Statement | CAPEC: CAPEC-193 | ATT&CK: T1059

PHP default behavior: allow_url_include is Off by default since PHP 5.2.0, which prevents RFI. However, allow_url_fopen is On by default, and LFI works regardless of either setting — the attacker only needs the web process to be able to read the targeted file.

Why This Matters

LFI allows an attacker to include any file readable by the web server process. Against a typical PHP application this means:

  • /etc/passwd and /etc/shadow (if world-readable)
  • /proc/self/environ (environment variables with secrets)
  • Application .env files with database credentials and API keys
  • PHP source files to extract logic and credentials
  • Uploaded files such as images that contain embedded PHP

The escalation path from read-only LFI to full RCE is well-established: if an attacker can write to any file the server will later include — via a log file, an image upload, or a session file under /tmp/sess_* — they can inject PHP into that file and then trigger its inclusion through the LFI vector (log poisoning). This reliably converts an LFI into RCE without enabling allow_url_include.

If allow_url_include is enabled (non-default but common on legacy hosts), the attacker supplies a remote URL directly as the include path for immediate RCE.

OWASP ASVS v4.0 mapping: V12.3.1 — Verify that user-submitted filenames are validated against an allowlist of permitted extensions; V5.2.4 — Verify that the application does not use dynamic file inclusion.

What Gets Flagged

An inclusion statement (with or without parentheses) whose path holds a request value, inline or through a variable assigned from one a few lines above. In-place filters such as str_replace("../", "", $file) do not clear the value.

// FLAGGED
$page = $_GET["page"];
include($page);

include "templates/" . $_GET["tpl"] . ".php";
require "views/{$_POST["view"]}.php";

$file = str_replace(array("../", "..\\"), "", $_GET["file"]);
include( $file );

Not flagged: a path passed through basename(), intval() or a preg_replace('/[^a-z0-9_]/', '', $x) that strips every character but letters, digits, _ and -; a file looked up in a map by the request value (include $pages[$_GET['p']]); a value checked with in_array(), isset($map[$v]) or preg_match() before the include; constant and __DIR__-relative paths; variables set in another file or passed as a parameter (a documented gap: there is no cross-file dataflow); the keywords in comments and strings.

Remediation

1. Replace variable includes with a strict allowlist map.

// SAFE: only these pages can ever be included
$pages = [
    'home'    => __DIR__ . '/pages/home.php',
    'about'   => __DIR__ . '/pages/about.php',
    'contact' => __DIR__ . '/pages/contact.php',
];

$page = $_GET['page'] ?? 'home';

if (!array_key_exists($page, $pages)) {
    http_response_code(404);
    exit('Page not found');
}

require $pages[$page];

2. Use basename() to strip directory components before validation.

// SAFE: basename() strips path traversal, then allowlist check
$allowed = ['header', 'footer', 'sidebar'];
$component = basename($_GET['component'] ?? '');

if (!in_array($component, $allowed, true)) {
    http_response_code(400);
    exit;
}

require __DIR__ . '/components/' . $component . '.php';

3. Use realpath() to confirm the resolved path stays within the expected directory.

// SAFE: realpath() confirms the file is inside the allowed base directory
$base = realpath(__DIR__ . '/templates');
$file = realpath($base . '/' . basename($_GET['tpl'] ?? '') . '.php');

if ($file === false || strncmp($file, $base, strlen($base)) !== 0) {
    http_response_code(403);
    exit;
}

require $file;

4. Disable URL includes and remote file access in php.ini unless genuinely required:

; php.ini — disable remote file inclusion (RFI prevention)
; allow_url_include is Off by default — confirm it has not been changed
allow_url_include = Off

; allow_url_fopen is On by default — consider disabling if not needed
allow_url_fopen = Off

5. Refactor page routing to a dispatcher pattern. Map routes to controller classes or functions rather than including files named by the user. Modern frameworks (Laravel, Symfony) do this automatically through their router.

// SAFE: Laravel route dispatch — no dynamic include
Route::get('/page/{slug}', [PageController::class, 'show']);

References