flawopen.com/path-traversal/Php
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.
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)includeexecutes the file it is given as PHP. With'pages/' . $page . '.php', a value such as../../uploads/avatarruns an uploaded file or any other.phpscript on disk. The null-byte trick that cut off the.phpsuffix was fixed in PHP 5.3.4, but../still works.realpath() and str_starts_with() without a separatorrealpath()resolves..and symlinks and returnsfalsefor missing files, which is useful.str_starts_with($path, '/srv/uploads')then compares characters, so/srv/uploads-old/secret.txtpasses; compare with the resolved base plusDIRECTORY_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 namesfile_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.inisetting that limits file functions andincludeto 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
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.
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.
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.
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
<?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;
}
<?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
- ✓Enable Psalm taint analysis (TaintedInclude and TaintedFile) or an equivalent rule and fail CI on request data reaching
include,require,readfile,fopenorfile_put_contents. - ✓Include only files from a fixed allowlist array keyed by page name; never build an include path from request data.
- ✓Resolve read paths with
realpath()and requirestr_starts_with($real, realpath($base) . DIRECTORY_SEPARATOR). - ✓Validate file names for writes with an anchored
/Dpattern, append the extension yourself or generate the name, and never write into the document root. - ✓Set
open_basedir, and configure the web server so upload and report folders never execute PHP.