Skip to content
mediumCVSS 5.3CWE-22A01:2021 – Broken Access Control

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

PHPthe mistakeVulnerable
<?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

FilterPayloadWhy it works
str_replace('../', '') — one pass....//The replacement leaves a working ../ behind
str_replace('..', '') — one pass....//Same idea; the outer dots survive
Rejects the literal string ../%2e%2e%2fThe decode happens after the check
Rejects ../ and %2e%2e%2f%252e%252e%252fTwo decode layers; see Encoding Bypasses
Rejects forward slashes only..\ or ..%5cWindows accepts both separators
Rejects paths starting with /Any relative traversalThe check was on the prefix, not the resolution
Rejects the string php://PHP://, pHp://, php:/\/Case, or a scheme parser more lenient than the check
Rejects php://filterphp://filter with read= formA different literal for the same behaviour
Rejects .phpphp://filter/…/resource=configThe extension is appended by the app, not by you
Requires the path to start with a known prefixpages/../../etc/passwdThe prefix is present; the traversal follows it
Recursive replace until cleanNothing here — go to encodingsRecursion closes the ....// class

When the replace is recursive

PHPbetter, still not right
<?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-resolution

Denylists 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

PHPtwo correct shapesSecure
<?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.