Skip to content
CWE-98A03:2021 – Injection

PHP

The only runtime with a stream-wrapper layer, and therefore the only one where a filename can become a URL. What to fix, in the order it matters.

Why PHP is the hard case

Every other runtime on the cheatsheet treats a filename as a filename. PHP routes it through the stream layer first, so a single unvalidated parameter reaches http://, data://, php://filter, zip:// and phar:// as well as the filesystem.

That is the difference between a traversal bug and an RCE bug. It is also why the PHP fix has to be stricter than the equivalent fix elsewhere: a realpath() prefix check that is sufficient in Go has to additionally reject wrappers in PHP, because realpath() returns false for one and a careless check treats false as "no traversal detected".

The fix

PHPthe right answerSecure
<?php
// The parameter is a key, never a path. Nothing to encode, nothing to
// traverse, no wrapper reachable — because no attacker-influenced string
// ever reaches a filesystem function.

const PAGES = [
    'home'    => 'home.php',
    'about'   => 'about.php',
    'contact' => 'contact.php',
];

$key = $_GET['page'] ?? 'home';
if (!array_key_exists($key, PAGES)) {
    http_response_code(404);
    exit;
}

include __DIR__ . '/pages/' . PAGES[$key];

// If the list is long enough to feel unwieldy, derive it — glob the
// directory once at boot and cache the names. The point is that the
// attacker chooses from a set you built, not that you typed it by hand.

php.ini, as defence in depth only

php.iniphp.ini
; Worth setting. None of these fixes the sink.

; Removes remote reads as well as remote includes. Set it unless the
; application genuinely fetches URLs through filesystem functions — and if
; it does, that is worth revisiting on its own.
allow_url_fopen = Off
allow_url_include = Off        ; already the default since 5.2.0

; Note the trailing slashes. Without them the values are treated as string
; prefixes and /var/www/html-backup is also permitted.
open_basedir = /var/www/html/:/var/lib/php/sessions/

; Removes the temp-file race and, combined with open_basedir, keeps uploads
; out of reach.
upload_tmp_dir = /var/www/tmp/

; Removes the PHP_SESSION_UPLOAD_PROGRESS variant of session poisoning.
session.upload_progress.enabled = Off

; Do not leak absolute paths in errors — they are how an attacker learns
; the traversal depth in one request.
display_errors = Off
log_errors = On

; What NONE of this stops: a php://filter iconv chain. See
; /guide/php-filter-chains

The sinks to grep for

Bashcode review
# Executing sinks — these are RCE if reachable.
grep -rnE '\b(include|include_once|require|require_once)\s*\(?\s*\$' .

# Reading sinks — disclosure, and a phar:// deserialization entry point.
grep -rnE '\b(file_get_contents|readfile|fopen|file|fpassthru|parse_ini_file|' \
         'highlight_file|show_source|SplFileObject)\s*\(\s*\$' .

# The surprising ones. Every one of these is a stream operation and
# therefore a phar:// sink. See /guide/phar-deserialization
grep -rnE '\b(file_exists|is_file|is_dir|is_readable|filesize|filemtime|' \
         'stat|md5_file|sha1_file|hash_file|getimagesize|copy|unlink|' \
         'rename|touch)\s*\(\s*\$' .

# Templating and autoloading that take a name from somewhere.
grep -rn 'spl_autoload_register' .
grep -rnE '\$(_GET|_POST|_REQUEST|_COOKIE)\[' . | grep -iE 'file|page|path|tpl|template|view|lang|module|include'

# The last one is usually the most productive: find the parameters whose
# NAMES suggest a path, then trace them.

Framework notes

Modern PHP frameworks do not hand you a raw include on a request parameter, but they all have a name-to-file resolver somewhere, and that is the equivalent sink.

Laravel. view($name) resolves through the view finder. view($request->input('page')) is the bug. Blade compiles templates to PHP under storage/framework/views, which is a writable directory inside the app tree — relevant if you have a write primitive. APP_KEY in .env is the highest-value read, because it forges sessions.

Symfony. $twig->render($name) with a request-derived name. Twig's loader rejects .. segments, so this is generally a constrained inclusion rather than a traversal — but the name can still reach templates the developer did not intend to be renderable.

WordPress. The historic source of this bug class. Plugin and theme code doing include($_GET['file']) is still a regular CVE. wp-config.php is the read target and it is one directory above the webroot in a default install, so ../wp-config.php is the payload.

Anything with a front controller that maps a URL segment to a class or file name. $_GET['module'] reaching require "modules/$module.php" is the same bug with better manners.

Review checklist

For each sink found above:

  1. Can any part of the argument come from a request? Query, body, cookie, header, path segment, or a database value that was originally one of those.
  2. Is the parameter a key or a path? If it is a key looked up in a map, you are done. If it is concatenated into a path, continue.
  3. Is the check on the input or on the resolved path? Input checks fail; see Beating Blacklists.
  4. Is a wrapper reachable? If the parameter is at position zero of the argument, yes — and that means filter chains, not just traversal.
  5. Does the sink execute? Decides critical versus high, and decides the fix's urgency.
  6. Is realpath() === false handled? A wrapper makes it false, and treating that as safe is a bypass.
  7. Is the prefix comparison separator-terminated? Otherwise a sibling directory passes.