FlawOpen

flawopen.com/path-traversal/Php

● CWE-22 · High
FlawOpen Security Research

Path Traversal in PHP (realpath, include)

include with a page name from the request runs any PHP file the attacker can reach with ../, a realpath() check with str_starts_with() and no trailing separator accepts /srv/uploads-old, and file_put_contents() under a user-chosen name can drop a .php file into the web root. How each mistake turns a file name into data exposure or code execution, and how an allowlist map, a separator-terminated realpath check and strict name validation stop it.

💡 Plain English Explainer (ELI5)

Think of a film booth with a catalog of approved reels. A viewer may choose a title from the catalog, but must not hand the operator a path to any reel in the building: PHP include runs the selected file as code. realpath() resolves ../ and symlinks, then str_starts_with() must compare against the resolved directory plus DIRECTORY_SEPARATOR; checking only /srv/uploads also accepts the sibling /srv/uploads-old. Keep that directory private and non-writable to avoid link races. For reports, file_put_contents() should use a validated name and fixed .csv extension, never a request-chosen path.

Core Concepts & Subsystem Terms

Local file inclusion (include with user input)
include executes the file it is given as PHP. With 'pages/' . $page . '.php', a value such as ../../uploads/avatar runs an uploaded file or any other .php script on disk. The null-byte trick that cut off the .php suffix was fixed in PHP 5.3.4, but ../ still works.
realpath() and str_starts_with() without a separator
realpath() resolves .. and symlinks and returns false for missing files, which is useful. str_starts_with($path, '/srv/uploads') then compares characters, so /srv/uploads-old/secret.txt passes; compare with the resolved base plus DIRECTORY_SEPARATOR.
Allowlist map for include
A fixed array from page keys to file paths means request data can only choose among files you listed. Nothing the user sends is ever part of the path passed to include.
Writing files under user-chosen names
file_put_contents(REPORT_DIR . '/' . $name, ...) with $name = '../public/report.php' creates a file the web server will execute. Validate names with an anchored pattern, add the extension yourself, or generate the name.
open_basedir
A php.ini setting that limits file functions and include to listed directories, so a traversal cannot open files elsewhere. It is defense in depth, not a replacement for validating paths.

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

Request value reaches a file function

A router passes $_GET['page'], $_GET['file'] or a form field for a report name to include, readfile or file_put_contents.

Step 2

The value leaves the intended folder

It contains ../ segments or names a sibling folder with the same prefix. The benign test value ../../etc/hostname illustrates the read case.

Step 3

PHP opens or executes the resolved path

readfile returns another file's contents, include runs another script, or file_put_contents writes outside the reports folder.

Step 4

The attacker reads files or runs code

Reads expose .env and config.php credentials; an included or written .php file runs with the PHP-FPM user's permissions.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
<?php
// files.php: each function lets request data choose a path
declare(strict_types=1);

const UPLOAD_DIR = '/srv/uploads';
const REPORT_DIR = '/srv/reports';

// ../ in $page runs any .php file on disk
function renderPage(string $page): void
{
    include __DIR__ . '/pages/' . $page . '.php';
}

// str_starts_with without a separator accepts /srv/uploads-old
function download(string $name): void
{
    $path = realpath(UPLOAD_DIR . '/' . $name);
    if ($path === false || !str_starts_with($path, UPLOAD_DIR)) {
        http_response_code(404);
        return;
    }
    readfile($path);
}

// a name such as ../public/report.php writes into the web root
function saveReport(string $name, string $csv): string
{
    $path = REPORT_DIR . '/' . $name;
    file_put_contents($path, $csv);
    return $path;
}
HARDENED SECURE PATCH
<?php
// files.php: allowlisted includes, separator-terminated checks, validated names
declare(strict_types=1);

const UPLOAD_DIR = '/srv/uploads';
const REPORT_DIR = '/srv/reports';
const PAGES = [
    'about' => __DIR__ . '/pages/about.php',
    'pricing' => __DIR__ . '/pages/pricing.php',
];

// request data only selects a key; the path comes from code
function renderPage(string $page): void
{
    $file = PAGES[$page] ?? null;
    if ($file === null) {
        http_response_code(404);
        return;
    }
    include $file;
}

// realpath resolves .. and links; the separator stops sibling folders
function inside(string $base, string $name): ?string
{
    $root = realpath($base);
    $real = realpath($base . DIRECTORY_SEPARATOR . $name);
    if ($root === false || $real === false
        || !str_starts_with($real, $root . DIRECTORY_SEPARATOR) || !is_file($real)) {
        return null;
    }
    return $real;
}

// downloads go through the containment check
function download(string $name): void
{
    $path = inside(UPLOAD_DIR, $name);
    if ($path === null) {
        http_response_code(404);
        return;
    }
    readfile($path);
}

// a strict pattern and a fixed extension decide the file name
function saveReport(string $name, string $csv): string
{
    if (preg_match('/^[A-Za-z0-9_-]{1,64}$/D', $name) !== 1) {
        throw new InvalidArgumentException('invalid report name');
    }
    $path = REPORT_DIR . DIRECTORY_SEPARATOR . $name . '.csv';
    file_put_contents($path, $csv, LOCK_EX);
    return $path;
}

Engineering & System Hardening Checklist

Sources