July 28, 2026
Path Traversal in PHP — How ../ Escapes Your Application
One unsanitized file path is all it takes for an attacker to steal database credentials, SSH keys, or your entire codebase.
By Nchiminyi Jezreel
7 min read
This is the eighth installment in our series on PHP and Laravel application security.
So far, we've covered.
- Detecting SQL injection attempts in PHP logs
- Why URL encoding blinds most PHP security checks
- The decode bomb problem and unlimited URL decoding
- Why parameterized queries are the only real defense against SQL injection
- XSS prevention in Laravel and why {!! !!} marks the line between safety and compromise
- How attackers enumerate your Laravel app before exploitation
- File upload security identifying files that aren't what they claim to be
Every article in this series follows a consistent philosophy understand the attack before attempting to defend against it.
Path traversal is no exception. It's one of the most quietly dangerous vulnerabilities in PHP applications because it hides within features that appear completely legitimate file downloads, template loading, and document viewers.
What Path Traversal Actually Is
Path traversal is an attack where an adversary manipulates a file path to access files outside the directory your application intends to serve.
The attack leverages ../ which on any operating system means "go up one directory level." By chaining multiple ../ sequences, an attacker ascends the directory tree to access files your application was never meant to expose.
This attack requires no login credentials, no specialized tools just one unsanitized file path and a browser.
The Attack in Plain Terms
Imagine your application has a file download feature.
$filename = $_GET['file'];
$filepath = '/var/www/storage/files/' . $filename;
readfile($filepath);$filename = $_GET['file'];
$filepath = '/var/www/storage/files/' . $filename;
readfile($filepath);A legitimate user sends
file=document1.pdffile=document1.pdfThe resulting path
/var/www/storage/files/document1.pdf/var/www/storage/files/document1.pdfClean. Expected. Safe.
An attacker sends.
file=../../config/database.phpfile=../../config/database.phpThe path becomes.
/var/www/storage/files/../../config/database.php/var/www/storage/files/../../config/database.phpWhich resolves to.
/var/www/config/database.php/var/www/config/database.phpYour database credentials have just left the server.
Going Further Up the Tree
Attackers don't stop at your application directory they keep climbing
file=../[redacted]/etc/pafile=../[redacted]/etc/paResolves to.
/etc/passwd/etc/passwdOn Unix-like systems, /etc/passwd is typically world-readable and lists local user accounts. From there, attackers map users, identify privilege levels, and plan their next move.
Even more dangerous targets
More dangerous targets:
- `[up-directory]/var/www/.env` — your Laravel environment file
- `[up-directory]/proc/self/environ` — server environment variables
- `[up-directory]/var/log/apache2/access.log` — server access logs
- `[up-directory]/home/ubuntu/.ssh/id_rsa` — SSH private keyMore dangerous targets:
- `[up-directory]/var/www/.env` — your Laravel environment file
- `[up-directory]/proc/self/environ` — server environment variables
- `[up-directory]/var/log/apache2/access.log` — server access logs
- `[up-directory]/home/ubuntu/.ssh/id_rsa` — SSH private keyAny single one of these constitutes a complete breach.
How Attackers Bypass Basic Filters
As discussed in article 2, attackers employ URL encoding here as well.
A naive filter that strips ../ is bypassed immediately
// This fails
$filename = str_replace('../', '', $_GET['file']);// This fails
$filename = str_replace('../', '', $_GET['file']);An attacker sends ....//. After stripping ../ from the middle, it becomes ../. The traversal still works. The filter accomplished nothing.
Encoded variations evade strpos checks
%2e%2e%2f — URL encoded ../
%2e%2e/ — partially encoded
..%2f — partially encoded
%2e%2e%5c — Windows path separator
....// — double sequence evasion%2e%2e%2f — URL encoded ../
%2e%2e/ — partially encoded
..%2f — partially encoded
%2e%2e%5c — Windows path separator
....// — double sequence evasionA check looking for the literal string ../ misses every one of these. This is why string-based filters invariably fail for path traversal they match representations, not what the path actually resolves to.
The Four PHP Functions Most Vulnerable to Path Traversal
- readfile()
readfile('/var/www/storage/files/' . $_GET['file']);readfile('/var/www/storage/files/' . $_GET['file']);Reads and outputs file content directly. Attackers can read any file the web server user has permission to access.
- file_get_contents()
$content = file_get_contents('/var/www/templates/' . $_GET['template']);$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 exposed.
- include() and require()
include('/var/www/views/' . $_GET['page'] . '.php');include('/var/www/views/' . $_GET['page'] . '.php');This is the most dangerous case. Not only can the attacker read files if they can first inject PHP code into a readable file and then include it, they achieve Remote Code Execution. This chained attack is called Local File Inclusion and transforms a read vulnerability into complete server compromise.
- fopen()
$handle = fopen('/var/www/uploads/' . $_GET['filename'], 'r');$handle = fopen('/var/www/uploads/' . $_GET['filename'], 'r');Opens a file handle for reading. Carries the same risk as readfile().
Path Traversal vs Local File Inclusion
These terms are related but describe different severity levels.
Path traversal refers to reading files outside the intended directory. The attacker accesses your .env file, database configuration, SSH keys serious damage, confidential data exposed.
Local File Inclusion occurs when path traversal combines with include() or require(). The attacker doesn't just read files they execute PHP code. This elevates a bad vulnerability to complete server takeover.
The LFI attack chain works as follows.
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']); ?>User-Agent: <?php system($_GET['cmd']); ?>This gets written into your server access log at.
/var/log/apache2/access.log/var/log/apache2/access.logNow they use path traversal to include the log file
page=../../../var/log/apache2/access.logpage=../../../var/log/apache2/access.logYour server executes the PHP code injected into the log. Success depends on server configuration, log file permissions, and how the application includes the file. This is why include() with user-controlled input ranks among 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']);$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');
}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);$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 serving files from subdirectories. It works for flat file structures but is not a complete solution. Treat basename() 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);$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 doesn't exist,
realpath()returnsfalsecaught immediately strpos($requestedPath, $baseDir . DIRECTORY_SEPARATOR)verifies the resolved path still starts with your intended base directory- If it doesn't the traversal moved outside the allowed directory access is denied
DIRECTORY_SEPARATORensures the check works correctly on both Linux and Windows
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);$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 filesystem path. They send file=invoice and your code performs the mapping. No traversal is possible because user input never touches the path directly.
This is the strongest fix when dealing with a known set of files. Use it wherever possible.
Fix 3 — PHP open_basedir.
PHP provides a configuration directive that restricts which directories PHP can access.
; php.ini
open_basedir = /var/www/storage/files/:/tmp/; 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 serves as a server-level safety net that limits damage if application-level validation fails.
It doesn't replace application validation it provides an additional layer beneath it.
Laravel Path Traversal
Laravel's Storage facade provides a safer abstraction for file operations, but user-controlled paths still require validation and authorization.
// Safer — Storage facade reduces common mistakes
$content = Storage::disk('local')->get($filename);// Safer — Storage facade reduces common mistakes
$content = Storage::disk('local')->get($filename);However, raw PHP file functions bypass this protection entirely.
// Dangerous — raw function with user input
$content = file_get_contents(storage_path($filename));// 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);// 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 doesn't eliminate the need to validate and authorize user-controlled paths. Raw PHP file functions require the same vigilance.
The Path Traversal Prevention Checklist
For plain PHP.
- Never concatenate user input directly into file paths
- Use
realpath()to resolve paths and validate they remain within the intended base directory - Use
DIRECTORY_SEPARATORin path comparisons for cross-platform safety - Use the whitelist approach whenever you have a known set of files
- Configure
open_basedirin php.ini as a server-level safety net - Never use
include()orrequire()with user-controlled input - Use
basename()as an additional safeguard for flat file structures — never 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 toresponse()->file()orresponse()->download() - Never pass user input directly to
storage_path(),public_path(), orbase_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. These behavioral signals distinguish automated scanning tools from legitimate users.
These patterns appear in your request logs before a successful traversal occurs. A security layer that monitors for these patterns provides visibility into probing activity before it becomes a breach.
Prevention stops the traversal. Detection alerts you that someone is looking for one.
Both layers work 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 directory traversal sequences targeting sensitive locations like system password files and application environment configurations. 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
Installation docs : Kriosa Documentation.
Built by a developer for PHP and Laravel developers who want to understand their security — not just outsource it.
Sleep better we're awake — kriosa
The Series So Far
- Article 1: What your PHP logs reveal during a SQL injection attack
- Article 2: Why URL encoding can break PHP security checks
- Article 3: The decode bomb problem — why unlimited URL decoding creates its own vulnerability
- Article 4: Parameterized queries — the only real solution for SQL injection
- Article 5: XSS prevention in Laravel and why {!! !!} separates safe from compromised
- Article 6: How attackers enumerate your Laravel app and what you need to hide
- Article 7: File upload security in PHP and Laravel
- Article 8: This article — path traversal in PHP and how directory traversal escapes your application