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
<?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
; 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-chainsThe sinks to grep for
# 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:
- 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.
- 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.
- Is the check on the input or on the resolved path? Input checks fail; see Beating Blacklists.
- Is a wrapper reachable? If the parameter is at position zero of the argument, yes — and that means filter chains, not just traversal.
- Does the sink execute? Decides critical versus high, and decides the fix's urgency.
- Is
realpath() === falsehandled? A wrapper makes it false, and treating that as safe is a bypass. - Is the prefix comparison separator-terminated? Otherwise a sibling directory passes.
Related
Stack enough iconv conversions and php://filter stops reading files and starts producing them. A read-only primitive becomes code execution on a completely stock php.ini — no upload, no writable directory, no outbound connection.
A phar:// reference unserializes the archive's metadata on any stream operation. That makes file_exists() an execute path, and it is the reason a read-only sink is not safe.
An allowlist is the correct fix, so most of them are not really allowlists. Here is how to tell, and what a prefix does and does not stop.
Two hardening settings that are worth having and are not fixes. Knowing exactly what each one confines tells you what is still reachable when you meet them.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.