Path Traversal in PHP โ€” How `../` Escapes Your Application
DEV Community

Path Traversal in PHP - How ../ Escapes Your Application

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 securitythe 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);

A legitimate user sends: file=document1.pdf
The path becomes: /var/www/storage/files/document1.pdf - Clean. Expected. Safe.

An attacker sends: file=../../config/database.php
The path becomes: /var/www/storage/files/../../config/database.php
Which resolves to: /var/www/config/database.php

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 resolves to: /etc/passwd

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

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']);

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

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']);
    

    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']);
    

    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');
    

    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');
    

    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']); ?>
    
  • This gets written into your server access log at: /var/log/apache2/access.log

  • Now they use path traversal to include the log file:

    page=../../../var/log/apache2/access.log
    

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']);

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

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

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

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);

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);

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);

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/

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);

But raw PHP file functions bypass this protection completely:

// Dangerous - raw function with user input
$content = file_get_contents(storage_path($filename));

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);

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 integration: 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**

Comments

No comments yet. Start the discussion.