Skip to content

CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') - PHP

Overview

Path traversal in PHP typically appears where a request value is concatenated into a path handed to file_get_contents(), readfile(), fopen(), unlink() or copy(). PHP has no path-joining function that enforces a base directory - $base . '/' . $_GET['file'] is the idiom, and it does exactly what it says.

The canonicalizing function is realpath(). It resolves ., .. and symbolic links and returns an absolute path, but it has one behaviour that shapes every correct use of it: it returns false unless the whole path already exists, including every intermediate directory. That makes it the right tool for validating a read and the wrong tool for validating a destination that has not been created yet.

basename() looks like a containment control and is not. It reduces a path to its last component, which is useful, but it is platform-dependent: on Windows both / and \ are separators, while everywhere else only / is. On Linux, basename('..\\..\\etc\\passwd') returns the whole string unchanged. It is also documented as locale-aware, so it can leave or strip bytes depending on the active locale.

Primary Defence: Use indirect reference mapping (map IDs to filenames). Where the path must be derived from input, build it, canonicalize it with realpath(), and confirm the result sits inside the canonicalized base directory using a check that respects the directory separator - not a bare str_starts_with(). Reject separators in the input wherever a single filename is what the code expects, as it is for an upload name; where subpaths are legitimate, containment is the control and the separator is not the signal. Set open_basedir as a backstop, not as the fix.

Common Vulnerable Patterns

Direct Path Concatenation

<?php
// VULNERABLE - the request value becomes part of the path
$file = $_GET['file'];
readfile('/var/www/documents/' . $file);

// Attack: ?file=../../../etc/passwd
// Result: reads /etc/passwd

Why this is vulnerable: Nothing between $_GET and readfile() inspects the result, so every ../ the attacker supplies is a directory the filesystem walks up. readfile(), file_get_contents(), fopen() and include all behave the same way here; the difference is only what the attacker gets back.

Validating Before Canonicalizing

<?php
// VULNERABLE - the check runs on a string, the read runs on a path
$file = $_GET['file'];
$full = '/var/www/documents/' . $file;

if (!str_starts_with($full, '/var/www/documents/')) {
    http_response_code(403);
    exit;
}

readfile($full);            // resolves '..' at the filesystem layer

// Attack: ?file=../../../etc/passwd
// $full is '/var/www/documents/../../../etc/passwd', which does start with
// the base as text, so the check passes. The open then resolves it to
// /etc/passwd.

Why this is vulnerable: The comparison happens on the concatenated string and the read happens on the resolved path, and those are different values whenever the input contains ... String prefix checks also have a second failure that survives even after canonicalization: /var/www/documents_backup starts with /var/www/documents as text, so a sibling directory passes a check meant to confine the reader to one folder.

Falling Back to the Unresolved Path When realpath Fails

<?php
// VULNERABLE - the fallback validates the string realpath refused to resolve
$uploads = realpath('/var/www/uploads');
$path    = $uploads . '/' . $_POST['filename'];

$full = realpath($path) ?: $path;       // added because realpath() returns
                                        // false for a file being created
if (!str_starts_with($full, $uploads . '/')) {
    exit('denied');
}

file_put_contents($full, $body);

// Attack: filename=../../evil.txt
// realpath() returns false, so $full is the raw
// '/var/www/uploads/../../evil.txt', which does start with '/var/www/uploads/'
// as text. The check passes and the write lands in /var/.

Why this is vulnerable: realpath() returns false unless the entire path already exists, which is never true for a file about to be created - so any code that writes hits this and something has to give. The ?: $path fallback is the usual answer, and it silently converts a canonical check into a textual one for exactly the inputs that need it most. The unresolved string still contains ../, so it passes the prefix test while the filesystem resolves it on the way out of the directory.

Leaving the fallback off is not the fix either: on PHP 8 the false propagates into str_starts_with(), which under declare(strict_types=1) raises TypeError and without it coerces to '' and returns false. Both fail closed, so nothing escapes, but every legitimate upload is rejected or fatals. A write destination needs the parent resolved instead - see the secure pattern below.

basename as a Containment Check

<?php
// VULNERABLE - basename contains the name, not the directory
$name = basename($_GET['file']);
readfile('/var/www/documents/' . $name);

Why this is vulnerable: This one does stop ../ on Linux, which is why it survives review. What it does not do is any of the rest: it never confirms the result is inside the intended directory, it applies no allowlist to the extension, and it is separator-dependent - on Linux a payload using backslashes passes through as a single, strange filename rather than being reduced. Used as the only control it is a name filter, not a containment check.

Source Disclosure Through Stream Wrappers

<?php
// VULNERABLE - the path is a URL as far as PHP is concerned
$page = $_GET['page'];
echo file_get_contents($page . '.php');

// Attack: ?page=php://filter/convert.base64-encode/resource=config
// Result: the source of config.php is returned base64-encoded rather than
//         executed, which discloses database credentials

Why this is vulnerable: PHP's filesystem functions accept stream wrappers wherever they accept a path, so user control over the string is user control over the scheme as well as the location. php://filter reads a file through an encoder, which turns an include-style feature into source disclosure without needing a traversal sequence at all. Canonicalization does not help, because realpath() returns false for a wrapper URL - the containment check has to run on input that has already been confirmed to be a plain relative filename.

Null bytes are no longer part of this class. file_get_contents("config\0.txt") throws ValueError: ... must not contain any null bytes on PHP 8, so the truncation trick that once defeated appended extensions is closed at the runtime.

Secure Patterns

Canonicalize and Check Containment

<?php
declare(strict_types=1);

final class DocumentStore
{
    private string $base;

    public function __construct(string $baseDir)
    {
        $base = realpath($baseDir);
        if ($base === false || !is_dir($base)) {
            throw new RuntimeException('document root does not exist');
        }
        $this->base = $base;
    }

    // SECURE - resolve first, then confirm containment, then read
    public function read(string $userPath): string
    {
        $full = realpath($this->base . DIRECTORY_SEPARATOR . $userPath);

        if ($full === false) {
            throw new RuntimeException('no such document');
        }
        if (!$this->isInside($full)) {
            throw new RuntimeException('outside the document root');
        }
        if (!is_file($full)) {
            throw new RuntimeException('not a regular file');
        }

        $body = file_get_contents($full);
        if ($body === false) {
            throw new RuntimeException('could not read document');
        }

        return $body;
    }

    private function isInside(string $candidate): bool
    {
        return $candidate === $this->base
            || str_starts_with($candidate, $this->base . DIRECTORY_SEPARATOR);
    }
}

Why this works: realpath() is the only step that consults the filesystem. It collapses . and .., follows symbolic links and returns an absolute path, so isInside() compares the path file_get_contents() will actually open rather than the string the request carried.

An absolute payload is not the escape route it looks like here. PHP concatenates strings; it has no join that treats a leading / as a fresh start. '/var/www/documents' . '/' . '/etc/passwd' is /var/www/documents//etc/passwd, which realpath() collapses to /var/www/documents/etc/passwd - a path inside the base, which normally does not exist, so realpath() returns false and the read is refused as missing rather than as forbidden. It is a spelling of an in-base path, not a way out of one.

This is where PHP and Python differ, and copying a pattern across the two gets it wrong. Python's os.path.join() and pathlib's / both discard the base when the right-hand side is absolute, so there the same payload really does escape and the containment check really is what stops it. If a leading / should be an error here rather than an odd way of naming a file in the base, reject it explicitly - the containment check will not, because there is nothing wrong with the result.

The separator in isInside() is what makes the prefix comparison correct. str_starts_with('/var/www/documents_backup/x', '/var/www/documents') is true; adding DIRECTORY_SEPARATOR to the base makes it false, which is the intended answer. The $candidate === $this->base arm covers the base directory itself, which the separator form would otherwise reject.

Handling false before the comparison matters for the reason the vulnerable version above fails: it is the branch a probing attacker takes.

A Write Destination Cannot Be Canonicalized

<?php
declare(strict_types=1);

// SECURE - the file does not exist yet, so validate the name and the parent
function createUpload(string $uploadDir, string $filename): string
{
    if ($filename === '' || $filename === '.' || $filename === '..'
        || str_contains($filename, '/') || str_contains($filename, '\\')
        || str_contains($filename, "\0")) {
        throw new InvalidArgumentException('filename must be a single component');
    }

    $parent = realpath($uploadDir);              // the directory does exist
    if ($parent === false || !is_dir($parent)) {
        throw new RuntimeException('upload directory is missing');
    }

    $dest = $parent . DIRECTORY_SEPARATOR . $filename;

    // 'x' fails if the file already exists, rather than truncating it
    $fh = @fopen($dest, 'xb');
    if ($fh === false) {
        throw new RuntimeException('destination already exists');
    }
    fclose($fh);

    return $dest;
}

Why this works: realpath() cannot validate a destination, because the destination is what the code is about to create - it would return false every time. Resolving the parent keeps the guarantee where it matters: symbolic links in the directory chain are followed, so the check runs against the real directory rather than a link pointing somewhere else.

The name is rejected rather than reduced with basename(). Both separators are tested explicitly because basename() only knows the platform's own, and rejecting tells the caller and the audit log that something was refused - stripping produces a routine-looking upload under a name the caller never chose, and makes two different hostile inputs collide on one file.

The x mode is the second half. Without it, fopen($dest, 'w') truncates whatever is already there, and follows a symbolic link an attacker planted at that name.

Indirect Reference

<?php
declare(strict_types=1);

// SECURE - the request never names a path
final class ReportDownloader
{
    private const REPORTS = [
        'q3-summary' => 'reports/2026-q3-summary.pdf',
        'q3-detail'  => 'reports/2026-q3-detail.pdf',
    ];

    public function __construct(private DocumentStore $store) {}

    public function download(string $reportId): string
    {
        if (!array_key_exists($reportId, self::REPORTS)) {
            throw new RuntimeException('unknown report');
        }

        return $this->store->read(self::REPORTS[$reportId]);
    }
}

Why this works: The user supplies a key, not a path, and an exact array_key_exists() lookup on a fixed map has no traversal surface - there is no string for .. to appear in. The containment check behind it stays, because the map is data and data drifts; if a future entry is generated rather than written by hand, the check is what still holds.

Framework-Specific Guidance

open_basedir as a Backstop

; php.ini - a process-wide ceiling, not a substitute for the checks above
open_basedir = /var/www/documents:/var/www/uploads:/tmp

Why this works: open_basedir is enforced by the runtime on every filesystem call, so it applies to code paths that forgot to validate, including third-party libraries. It resolves the path before comparing, so both /var/www/secret.txt and /var/www/documents/../secret.txt are refused from a process limited to the directories above.

Treat it as containment of last resort. It is process-wide rather than per-request, so it cannot distinguish one user's uploads from another's, and applications routinely need /tmp, the session path and the include path in the list - which widens it until it stops being a meaningful boundary. A finding is not remediated by adding a directory to this line.

Archive Extraction

<?php
// SECURE - ZipArchive::extractTo() sanitizes member names itself
$zip = new ZipArchive();
if ($zip->open($archive) === true) {
    $zip->extractTo('/var/www/uploads');   // a member named ../../evil.txt
    $zip->close();                          // is written as uploads/evil.txt
}
<?php
// VULNERABLE - hand-rolled extraction reapplies the attacker's path
$zip = new ZipArchive();
$zip->open($archive);
for ($i = 0; $i < $zip->numFiles; $i++) {
    $name = $zip->getNameIndex($i);                    // '../../evil.txt'
    file_put_contents('/var/www/uploads/' . $name, $zip->getFromIndex($i));
}

Why this works: extractTo() strips the traversal from member names before writing, so the safe path is the one that does less. The exposure is code that reads getNameIndex() and builds the destination itself, which is common where the extraction needs to filter or rename entries - getNameIndex() returns the name exactly as the archive author wrote it, so it is untrusted input and needs the same treatment as a request parameter. Run each name through createUpload() above, or reject any entry whose name is not a single safe component.

Considerations

Whether the finding is material depends on what the base directory reaches. A traversal confined to a directory of public PDFs is a weaker finding than one that reaches /etc, .env, or another tenant's uploads. That is a priority judgement, not a dismissal - the reachable set changes when the deployment does. Recording a false positive is legitimate where the value is not attacker-controlled at all: a filename read from a column the application itself wrote, with no request data in its history, is a different question from $_GET.

realpath() requires every component to exist, not just the last one - on Linux. realpath('/var/www/uploads/pending/../file.txt') returns false there if pending does not exist, even though the path it describes is valid, because the underlying realpath(3) walks each component. PHP's Windows implementation collapses the .. lexically first and returns the path - measured on PHP 8.5.8 - so code that works on a Windows workstation can start returning false on the Linux host. Code that builds paths through directories created on demand will see false for legitimate input and, if false is treated as "denied", will reject work that should succeed. That is the failure mode worth testing with normal input, on the platform that runs in production.

Containment is not authorization. Confirming a path is inside /var/www/uploads says nothing about whether this user may read that file. Where files belong to accounts, that lookup is separate and belongs before the read. See CWE-73 for the case where the weakness is file selection rather than escaping the directory, and CWE-98 where the sink is include or require rather than a read.

The check and the open are separate syscalls. Every pattern here validates a path and then uses it, which leaves a window an attacker with write access to the directory can exploit by swapping a component for a symlink. PHP offers no O_NOFOLLOW equivalent through its stream layer, so where untrusted local processes share the directory the answer is filesystem permissions rather than more application code.

Testing

Re-running the scanner shows the concatenation is gone, not that containment holds - and the containment fix is the one that breaks legitimate downloads. Assert both directions:

  • $store->read('reports/q3.pdf') returns the file's contents. A fix that rejects every subdirectory passes a traversal test and fails users.
  • $store->read('../../../etc/passwd') throws. Assert the message as well as the exception: this one is refused for being outside the base.
  • $store->read('/etc/passwd') throws too, but for a different reason, and the test is worth writing precisely because the reason is not the obvious one. Concatenation makes it /var/www/documents//etc/passwd, which is inside the base and normally absent, so it fails as a missing file. Create /var/www/documents/etc/passwd on disk and the same call returns its contents - correctly, since that file is in the base. If a leading / should be rejected outright, that is a separate rule and needs its own assertion.
  • With /var/www/documents_backup/notes.txt present on disk, $store->read('../documents_backup/notes.txt') throws. This is the case a bare str_starts_with() gets wrong and the separator-terminated comparison gets right, so it is the assertion that tells the two apart.
  • $store->read('missing.txt') throws "no such document" rather than a TypeError. A false return from realpath() reaching str_starts_with() fatals under declare(strict_types=1), so this asserts the branch handling it, not just that access was denied.
  • createUpload($dir, 'report.pdf') succeeds for a file that does not exist yet, and throws when called a second time. The first half is the assertion the realpath() fallback bug hides from: an implementation that canonicalizes the destination rejects every legitimate upload, and one that patches around that with ?: $path accepts traversal - only a normal-input test tells those apart. The second half covers the x mode, without which the repeat call silently truncates the first file.
  • createUpload($dir, '../../evil.txt') throws rather than creating evil.txt. Assert the exception, not the absence of a file above the directory - an implementation that calls basename() also leaves nothing above the directory, and only the exception distinguishes the two.

Common Pitfalls

  • Checking one string and opening another. Validating $_GET['file'] and then rebuilding the path from a different variable, or concatenating a second time before the read, means the value that was checked is not the value that is opened. Resolve once into a variable and pass that variable to the read.
  • str_starts_with($full, $base) without the separator. /var/www/documents_backup starts with /var/www/documents as text. Append DIRECTORY_SEPARATOR to the base before comparing, and handle the base-equals-candidate case separately.
  • Treating basename() as the fix. It reduces the input to a filename, which is one step of the write pattern above. It confirms nothing about which directory the result lands in, whether it overwrites an existing file, or whether this user may write there - and on Linux it does not treat \ as a separator at all.
  • Adding a directory to open_basedir and closing the ticket. The directive stops the process reaching outside a list of trees; it does not stop one user reaching another user's files inside them, and every entry added to make the application work makes it a weaker boundary.

Additional Resources