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
..withstrpos()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
- CWE-41: Improper Resolution of Path Equivalence
- PHP realpath() Documentation - Canonicalize and resolve symlinks
- PHP Filesystem Functions - is_file(), readfile(), file_exists()
- OWASP Path Traversal