DEV Community

Cover image for Path Traversal in PHP — How `../` Escapes Your Application

Path Traversal in PHP — How `../` Escapes Your Application

Originally published on Medium
One unsanitized file path is all an attacker needs to read your database credentials, your SSH keys, or your entire codebase.

This is the eighth article in a series on PHP and Laravel application security.

So far we have covered:

  • Detecting SQL injection attempts in PHP logs
  • Why URL encoding blinds most PHP security checks
  • The decode bomb problem with unlimited URL decoding
  • Why parameterized queries are the only real fix for SQL injection
  • XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • How attackers enumerate your Laravel app before exploiting it
  • File upload security the file that isn't what it claims to be

Every article in this series follows the same principle understand the attack before you try to stop it.

Path traversal is no different. And it is one of the most quietly dangerous vulnerabilities in PHP applications because it hides inside features that look completely legitimate file downloads, template loading, document viewers.

What Path Traversal Actually Is

Path traversal is an attack where an attacker manipulates a file path to access files outside the directory your application intended to serve.

The attack uses ../ which in any operating system means "go up one directory level." By chaining multiple ../ sequences an attacker climbs up the directory tree and accesses files your application was never supposed to touch.

It does not require a login. It does not require special tools. It requires one unsanitized file path and a browser.

The Attack in Plain Terms

Your application has a file download feature:

$filename = $_GET['file'];
$filepath = '/var/www/storage/files/' . $filename;
readfile($filepath);
Enter fullscreen mode Exit fullscreen mode

A legitimate user sends:

file=document1.pdf
Enter fullscreen mode Exit fullscreen mode

The path becomes:

/var/www/storage/files/document1.pdf
Enter fullscreen mode Exit fullscreen mode

Clean. Expected. Safe.

An attacker sends:

file=../../config/database.php
Enter fullscreen mode Exit fullscreen mode

The path becomes:

/var/www/storage/files/../../config/database.php
Enter fullscreen mode Exit fullscreen mode

Which resolves to:

/var/www/config/database.php
Enter fullscreen mode Exit fullscreen mode

Your database credentials just left the server.

Going Further Up the Tree

An attacker does not stop at your application directory. They keep climbing:

file=../../../etc/passwd
Enter fullscreen mode Exit fullscreen mode

Resolves to:

/etc/passwd
Enter fullscreen mode Exit fullscreen mode

On Unix-like systems, /etc/passwd is typically world-readable and lists local user accounts. From there an attacker maps users, identifies privilege levels, and plans their next move.

More dangerous targets:

../../../var/www/.env              — your Laravel environment file
../../../proc/self/environ         — server environment variables
../../../var/log/apache2/access.log — server access logs
../../../home/ubuntu/.ssh/id_rsa   — SSH private key
Enter fullscreen mode Exit fullscreen mode

Each one of these is a complete breach on its own.

How Attackers Bypass Basic Filters

You already know about URL encoding from article 2. Attackers use it here too.

A simple filter that strips ../ gets bypassed immediately:

// This fails
$filename = str_replace('../', '', $_GET['file']);
Enter fullscreen mode Exit fullscreen mode

An attacker sends ....//. After stripping ../ from the middle it becomes ../. The traversal still works. The filter protected nothing.

Encoded variations bypass strpos checks:

%2e%2e%2f       — URL encoded ../
%2e%2e/         — partially encoded
..%2f           — partially encoded
%2e%2e%5c       — Windows path separator
....//          — double sequence evasion
Enter fullscreen mode Exit fullscreen mode

A check looking for the literal string ../ misses every one of these. This is why string-based filters always fail for path traversal they match representations, not what the path actually resolves to.

The Four PHP Functions Most Vulnerable to Path Traversal

1. readfile()

readfile('/var/www/storage/files/' . $_GET['file']);
Enter fullscreen mode Exit fullscreen mode

Reads and outputs file content directly. An attacker reads any file the web server user has permission to access.

2. file_get_contents()

$content = file_get_contents('/var/www/templates/' . $_GET['template']);
Enter fullscreen mode Exit fullscreen mode

Returns file content as a string. Same risk as readfile() any readable file on the system is accessible.

3. include() and require()

include('/var/www/views/' . $_GET['page'] . '.php');
Enter fullscreen mode Exit fullscreen mode

This is the most dangerous case. Not only can the attacker read files if they can get PHP code into a readable file first and then include it, they achieve Remote Code Execution. This chained attack is called Local File Inclusion and it turns a read vulnerability into a full server takeover.

4. fopen()

$handle = fopen('/var/www/uploads/' . $_GET['filename'], 'r');
Enter fullscreen mode Exit fullscreen mode

Opens a file handle for reading. Same risk as readfile().

Path Traversal vs Local File Inclusion

These two terms are related but describe different levels of severity.

Path traversal is the ability to read files outside the intended directory. The attacker reads your .env file, your database config, your SSH keys. Serious damage. Confidential data exposed.

Local File Inclusion happens when path traversal is combined with include() or require(). The attacker does not just read files they execute PHP code. This turns a bad vulnerability into a complete server takeover.

The LFI attack chain works like this:

An attacker knows your server logs User-Agent headers. They send a request with a PHP payload as their User-Agent:

User-Agent: <?php system($_GET['cmd']); ?>
Enter fullscreen mode Exit fullscreen mode

This gets written into your server access log at:

/var/log/apache2/access.log
Enter fullscreen mode Exit fullscreen mode

Now they use path traversal to include the log file:

page=../../../var/log/apache2/access.log
Enter fullscreen mode Exit fullscreen mode

Your server executes the PHP code that was injected into the log. Whether this succeeds depends on the server configuration, log file permissions, and how the application includes the file.

This is why include() with user-controlled input is one of the most dangerous patterns in PHP. Path traversal reads files. LFI executes them.

The Wrong Fixes

Wrong fix 1 — Stripping ../:

$filename = str_replace('../', '', $_GET['file']);
Enter fullscreen mode Exit fullscreen mode

An attacker sends ....//. After stripping ../ it becomes ../. Bypassed.

Wrong fix 2 — Checking for ../ with strpos:

if (strpos($_GET['file'], '../') !== false) {
    die('Invalid path');
}
Enter fullscreen mode Exit fullscreen mode

An attacker sends %2e%2e%2f. Your check never sees ../. Bypassed.

Wrong fix 3 — Using basename() alone:

$filename = basename($_GET['file']);
readfile('/var/www/storage/files/' . $filename);
Enter fullscreen mode Exit fullscreen mode

basename() strips directory components and returns only the filename. This prevents traversal but breaks any feature that serves files from subdirectories. It works for flat file structures but is not a complete solution. basename() should be viewed as an additional safeguard, not a replacement for proper path validation.

The Correct Fixes

Fix 1 — realpath() validation

realpath() resolves the actual absolute path after processing all ../ sequences and symbolic links. By the time realpath() is called, PHP has already received the decoded path from the HTTP request. realpath() then resolves the resulting filesystem path, eliminating . and .. segments and resolving symbolic links.

$baseDir = realpath('/var/www/storage/files');
$requestedPath = realpath($baseDir . '/' . $_GET['file']);

if ($requestedPath === false) {
    http_response_code(404);
    die('File not found.');
}

if (strpos($requestedPath, $baseDir . DIRECTORY_SEPARATOR) !== 0) {
    http_response_code(403);
    die('Access denied.');
}

readfile($requestedPath);
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • realpath() resolves the full path including any ../ sequences
  • If the file does not exist realpath() returns false caught immediately
  • strpos($requestedPath, $baseDir . DIRECTORY_SEPARATOR) checks that the resolved path still starts with your intended base directory
  • If it does not the traversal moved outside the allowed directory access is denied
  • DIRECTORY_SEPARATOR ensures the check works correctly on both Linux and Windows systems

Fix 2 — Whitelist approach

For a known set of files never accept a filename from user input at all. Accept an identifier and map it to a filename in your code:

$allowedFiles = [
    'invoice'  => 'invoice_template.pdf',
    'receipt'  => 'receipt_template.pdf',
    'contract' => 'contract_template.pdf',
];

$fileKey = $_GET['file'] ?? '';

if (!array_key_exists($fileKey, $allowedFiles)) {
    http_response_code(404);
    die('File not found.');
}

$filepath = '/var/www/storage/files/' . $allowedFiles[$fileKey];
readfile($filepath);
Enter fullscreen mode Exit fullscreen mode

The attacker never controls any part of the file system path. They send file=invoice and your code looks up the mapping. No traversal is possible because user input never touches the path directly.

This is the strongest fix when you have a known set of files. Use it wherever possible.

Fix 3 — PHP open_basedir

PHP has a configuration directive that restricts which directories PHP is allowed to access:

; php.ini
open_basedir = /var/www/storage/files/:/tmp/
Enter fullscreen mode Exit fullscreen mode

With this configured any attempt to access a file outside the specified directories produces a PHP error even if the traversal successfully constructs the path. This is a server-level safety net that limits damage if application-level validation fails.

It does not replace application validation. It is an additional layer underneath it.

Laravel Path Traversal

Laravel's Storage facade provides a safer abstraction for file operations, but user-controlled paths should still be validated and authorized:

// Safer — Storage facade reduces common mistakes
$content = Storage::disk('local')->get($filename);
Enter fullscreen mode Exit fullscreen mode

But raw PHP file functions bypass this protection completely:

// Dangerous — raw function with user input
$content = file_get_contents(storage_path($filename));
Enter fullscreen mode Exit fullscreen mode

And Laravel's response helpers pass paths directly to PHP you must validate before calling them:

// Validate before passing to response()
$baseDir = realpath(storage_path('app/files'));
$requestedPath = realpath(storage_path('app/files/' . $filename));

if (!$requestedPath || strpos($requestedPath, $baseDir . DIRECTORY_SEPARATOR) !== 0) {
    abort(404);
}

return response()->file($requestedPath);
Enter fullscreen mode Exit fullscreen mode

The Storage facade helps reduce common mistakes, but it does not remove the need to validate and authorize user-controlled paths. Raw PHP file functions require the same care.

The Path Traversal Prevention Checklist

For plain PHP:

  • Never concatenate user input directly into file paths
  • Use realpath() to resolve paths and validate they stay within the intended base directory
  • Use DIRECTORY_SEPARATOR in path comparisons for cross-platform safety
  • Use the whitelist approach whenever you have a known set of files
  • Configure open_basedir in php.ini as a server-level safety net
  • Never use include() or require() with user-controlled input
  • Use basename() as an additional safeguard for flat file structures — not as a replacement for proper path validation

For Laravel

  • Use the Storage facade for file operations it provides a safer abstraction
  • Validate paths with realpath() before passing to response()->file() or response()->download()
  • Never pass user input directly to storage_path(), public_path(), or base_path()
  • Validate and authorize user-controlled paths even when using the Storage facade
  • Use Laravel's authorization layer to control which users can access which files

Where Detection Fits

Path traversal attempts produce distinctive request patterns. Requests containing ../ sequences, URL-encoded traversal strings like %2e%2e, repeated requests probing different directory depths, and requests targeting known sensitive file locations like .env, passwd, and id_rsa are all behavioral signals that distinguish automated scanning tools from legitimate users.

These patterns appear in your request logs before a successful traversal ever occurs. A security layer that monitors for these patterns gives you visibility into probing activity before it turns into a breach.

Prevention stops the traversal. Detection tells you someone is looking for one.

Both layers together.

"To put this in concrete terms Kriosa blocked 306 attacks on a live PHP application in a single week including path traversal attempts where automated scanners were sending ../../../etc/passwd and ../../../var/www/.env in request parameters. These were not targeted attacks. They were automated tools probing every PHP app they could find."

Try it free: kriosa.com

Install it: composer require kriosa-ai/kriosa-php

see documentation for intergration kriosa.com/documentation.php
**
Built for PHP and Laravel developers who want to understand their security - not just outsource it.
Sleep better we're awake - kriosa**

Top comments (0)