Beating Blacklists
A filter that removes bad strings has to anticipate every spelling. Resolution has to anticipate nothing. That asymmetry is why str_replace loses.
Why denylists lose
A denylist has to enumerate every way of writing something dangerous. Resolution has to enumerate nothing — it just resolves.
So the defender is playing a game they have to win every round and the attacker only has to win once. That is the structural reason str_replace('../', '') is on this page and realpath() plus a prefix check is not.
The most instructive case is the one where the filter does not merely fail to catch the payload but creates it.
The filter that builds the traversal for you
<?php
// One pass, non-recursive. This is the single most common attempt at
// fixing traversal, and it is worse than doing nothing — because it is
// believed to work.
$page = str_replace('../', '', $_GET['page']);
include('/var/www/html/pages/' . $page);
/*
Send: ....//....//....//etc/passwd
The filter finds the inner ../ in each ....// and removes it:
....// -> .. + / -> ../
^^--^^
Result: ../../../etc/passwd
The filter did not fail to stop the traversal. It assembled it.
Same idea, other spellings:
..././ -> ../
....\/ -> ..\/ (Windows)
..;/ -> some proxies strip the ;
*/What to try, against what
When the replace is recursive
<?php
// Loop until the string stops changing. This DOES close the ....// class —
// the filter keeps going until nothing is left to remove.
do {
$before = $page;
$page = str_replace('../', '', $page);
} while ($page !== $before);
// So what is left?
//
// 1. Encodings, if anything decodes after this runs.
// %2e%2e%2f — decoded by the server BEFORE this, so no help
// %252e%252e%252f — decoded once before, once after: works if the app
// urldecode()s the value itself, which many do
//
// 2. Separators the filter does not know about.
// ..\ on Windows
//
// 3. Symlinks. There is no ../ anywhere in this and it still escapes:
// uploads/link-to-root/etc/passwd
// A string filter cannot see a symlink. realpath() resolves it.
//
// 4. Absolute paths, if nothing is prepended.
// /etc/passwd
//
// The symlink case is the one that matters: it shows the approach is wrong
// in principle, not merely incomplete. See /guide/path-resolutionDenylists on wrappers
The same reasoning applies to a filter that tries to block php:// or data://.
Case. PHP's scheme matching is case-insensitive; a strpos($p, 'php://') check is not. PHP://filter/... and PhP:// both work.
Alternative spellings for the same behaviour. php://filter/read=convert.base64-encode/resource=x is the older form of php://filter/convert.base64-encode/resource=x. A denylist written against one string misses the other.
A different wrapper entirely. Blocking php:// leaves zip://, phar://, glob:// and file://. Blocking data:// leaves everything else.
Reaching the same file without a wrapper. If the goal was to read config.php and php://filter is blocked, an upload plus zip:// or a straightforward traversal to a non-PHP file may get you there anyway.
A denylist on wrappers is not useless — it is a reasonable belt-and-braces measure, and phar:// in particular has no legitimate business in a request parameter. It is just not a substitute for an allowlist.
What the filter should have been
<?php
// Best: the parameter stops being a path. There is nothing to filter,
// nothing to encode, and no wrapper is reachable, because the value never
// touches the filesystem API.
$pages = [
'home' => 'home.php',
'about' => 'about.php',
'contact' => 'contact.php',
];
$file = $pages[$_GET['page'] ?? 'home'] ?? 'home.php';
include __DIR__ . '/pages/' . $file;
// Acceptable when the set genuinely cannot be enumerated: resolve first,
// then check the RESOLVED path. Every payload on this page is already
// collapsed by the time the comparison happens — including symlinks,
// which no string filter can see.
$base = realpath(__DIR__ . '/pages');
$real = realpath($base . '/' . ($_GET['page'] ?? ''));
if ($real === false || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) {
http_response_code(400);
exit;
}
include $real;
// Note the trailing separator in the comparison. Without it, a sibling
// directory named pages-backup passes the prefix check.Working out what the filter is
You are usually probing blind. Send inputs designed to distinguish, not just to succeed.
Is it removing or rejecting? Send ..%2f and ....//. A rejection (error, 403, redirect) is a check. A silent wrong-file response is a removal. This one observation halves your search space.
Is it one pass or recursive? ....// works on one pass and fails on recursive. ........//// — which needs two passes to collapse — distinguishes further.
Where is it? Send an obviously bad payload and compare timings and error pages. A WAF usually answers faster and with a different page than the application does.
Does it decode first? Send %2e%2e%2f and ../ separately. Same response means it decodes before checking; different responses mean it checks the raw string.
The lab implements the naive filter, the recursive one, and the correct check, so you can watch the same payload succeed and fail against each.
Related
Every encoding here targets a filter that inspects the input string. None of them targets path resolution, because resolution cannot be fooled by spelling.
Traversal works because of specific, boring rules about how a string becomes a file. Knowing them turns guessing at ../ counts into arithmetic.
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.
include($page . '.php') is the most common shape of this bug, and the two famous bypasses for it both died in 2010. Here is what is actually left.
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.