Skip to content

CWE-41: Improper Resolution of Path Equivalence - PHP

Overview

Path equivalence vulnerabilities in PHP applications occur when user-supplied paths are filtered with string functions such as strpos(), str_contains() or str_replace() instead of being canonicalized. A single-pass str_replace('../', '') is defeated by sequences such as ....// that reassemble once the inner match is removed, and a .. denylist never sees the paths that need no .. at all - an absolute path, or a symbolic link. String concatenation for path construction prevents neither, and resolves no path equivalents.

Primary Defence: Use realpath() to canonicalize existing user-supplied targets, resolving ., .. and symlinks and normalizing separators. Then check that the filename contains no path separators (/ or \\), that the canonical path is inside the canonicalized allowed directory by a directory-separator boundary comparison, that realpath() did not return false (the file must exist), and that the target is a regular file according to is_file() before access.

Common Vulnerable Patterns

Stripping Traversal Sequences Instead of Canonicalizing

<?php
// VULNERABLE - Stripping Traversal Sequences Instead of Canonicalizing
$base_dir = '/var/www/uploads/';

// Weak sanitization - removes '../' once, non-recursively
$file = str_replace('../', '', $_GET['file']);
$full_path = $base_dir . $file;

// Attack: file=....//....//....//....//etc/passwd
// Each '....//' loses its inner '../', and the characters either side rejoin as '../'
// Result: /var/www/uploads/../../../../etc/passwd -> /etc/passwd
readfile($full_path);

Why this is vulnerable:

  • str_replace() makes a single pass, so a sequence built to reassemble after its own removal survives - ....// becomes ../, and repeating it climbs as far as the attacker wants
  • Rejecting the literal .. with strpos() stops this payload, but not the paths that need no .. at all: an absolute path, or a symbolic link inside the uploads directory that points outside it
  • No canonicalization means the string that was validated and the path that readfile() opens are different values, and only the second one matters
  • Equivalent spellings such as //, a trailing /., and ./ prefixes reach the same file through strings that do not match any filter written for the plain form

Secure Patterns

RealPath with Prefix Validation

<?php
$base_dir = realpath('/var/www/uploads');
$file = $_GET['file'] ?? '';

if ($base_dir === false) {
    http_response_code(500);
    die('Upload directory unavailable');
}

if (empty($file)) {
    http_response_code(400);
    die('Filename required');
}

// Prevent path separators, traversal, or null bytes in filename (filename only, no dirs)
if (strpos($file, '/') !== false || strpos($file, '\\') !== false ||
    strpos($file, "\0") !== false || basename($file) !== $file) {
    http_response_code(400);
    die('Invalid filename');
}

// Construct full path
$full_path = $base_dir . DIRECTORY_SEPARATOR . $file;

// Canonicalize path (resolves .., ., symlinks)
$real_path = realpath($full_path);

// Check if realpath succeeded (file exists)
if ($real_path === false) {
    http_response_code(404);
    die('File not found');
}

// Verify canonical path is within allowed directory using a path boundary
$base_prefix = rtrim($base_dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($real_path, $base_prefix) !== 0) {
    http_response_code(403);
    die('Access denied');
}

// Verify it's a file
if (!is_file($real_path)) {
    http_response_code(400);
    die('Not a file');
}

readfile($real_path);

Why this works:

  • realpath() canonicalizes both base and requested paths, resolving symlinks and path components
  • The containment check compares canonical paths and includes a directory-separator boundary, preventing sibling-directory prefix bypasses
  • Rejects filenames containing path separators or null bytes, and requires basename($file) === $file, so no directory component survives into $full_path
  • Validates file exists (realpath returns false if not) and is a regular file
  • readfile() opens $real_path, the same value the containment check ran against, so no equivalent spelling of the name reaches a different file

Additional Resources