🕵️ LFI: when the server reads what you want, not what it should 📂
We already understood what file inclusion vulnerabilities are and why they occur. Now it’s time for the practical step: exploiting them in different scenarios to read local files from the back-end server. A well-exploited Local File Inclusion (LFI) can leak configurations, source code, and even credentials, so it’s worth mastering its variants (you can check out more techniques and payloads in the HackTricks guide on File Inclusion).
🔍 Basic scenario: the language parameter
The exercise in this section shows a web application that lets users set their language to English or Spanish. If we select, for example, Spanish, the content changes to Spanish and we notice that the URL includes a language parameter that now points to es.php.
There are several ways an application could load different content depending on the language: it might pull data from a different table in the database, or it might load a completely different version of the site. However, as is often the case, the most common method is including a template or file through a template engine.
If the application is really including a file based on that parameter, we may be able to change it to read the content of another local file. Two very common readable files on most servers are /etc/passwd on Linux and C:\Windows\boot.ini on Windows.
💡 Step 1 — Modify the parameter: we change the value of language from es to /etc/passwd.
The result confirms that the page is vulnerable: we can read the content of the passwd file and identify which users exist on the back-end server.
🧭 Path Traversal: bypassing the directory prefix
In the previous example we read the file by specifying its absolute path (for example, /etc/passwd). This works when the parameter value is used directly inside the include() function, without any addition:
include($_GET['language']);
In this case, when trying to read /etc/passwd, the include() function looks for that file directly. However, it’s common for developers to prepend a string to the parameter, for example a directory:
include("./languages/" . $_GET['language']);
🚫 What went wrong? If we try to read /etc/passwd, the resulting path passed to include() would be ./languages//etc/passwd, which doesn’t exist, so we fail to read anything. PHP’s detailed error shows us the full string that was passed to include(), confirming that file doesn’t exist inside the languages directory.
Note: PHP errors were enabled in this application for educational purposes only, to understand how it handles our input. In production, this kind of error should never be shown. Also, all these attacks should work equally well without relying on errors being displayed.
💡 Step 2 — Use relative paths to traverse directories: we can bypass this restriction by adding ../ before the filename, which refers to the parent directory. For example, if the full path of the language directory is /var/www/html/languages/, using ../index.php would refer to /var/www/html/index.php.
We can use this trick to go back several directories until we reach the root (/) and then specify the absolute path of the file we want, for example:
../../../../etc/passwd
This time we manage to read the file regardless of which directory we were in. This technique works even if the whole parameter is used directly in include(), so it’s worth applying by default: it works in both scenarios. Also, if we were already at the root path (/) and added ../, we would still be at the root — so, if we’re not sure how deep the application’s directory is, we can add ../ many times (even a hundred!) without breaking the path.
Tip: it’s always cleaner and more professional to use the minimum number of ../ needed, especially when writing a report or an exploit. You can calculate how many directories deep you are from the root and use exactly that amount. For example, /var/www/html/ is 3 directories from the root, so ../../../ is enough.
💡 Filename prefix
In the previous example, the language parameter was used after the directory, which allowed us to traverse paths to read passwd. Other times, the input might be appended after a different string, such as a prefix that forms the full filename:
include("lang_" . $_GET['language']);
🚫 What went wrong? If we try direct traversal with ../../../etc/passwd, the final string would be lang_../../../etc/passwd, which isn’t a valid path. The error confirms that file doesn’t exist.
💡 Step 3 — Prepend a slash: instead of using path traversal directly, we can prepend a / to our payload. This makes the prefix be interpreted as a directory, allowing us to skip the original filename and traverse directories normally — thus achieving reading /etc/passwd.
Note: this technique doesn’t always work, since in this example there might not be a directory called lang_/, so the relative path might not be correct. Also, any prefix added to our input can break other file inclusion techniques; in upcoming sections we’ll see how to address this with PHP wrappers and filters or with RFI.
🚫 Appended extensions
Another very common case is having an extension appended to the language parameter, like this:
include($_GET['language'] . ".php");
This is common because it avoids having to type the extension every time the language is changed, and it can also be considered a security measure, since it restricts inclusion to PHP files. In this case, if we try to read /etc/passwd, the file actually included would be /etc/passwd.php, which doesn’t exist, so exploitation fails outright.
There are several techniques to bypass this restriction, which will be covered in upcoming sections.
Exercise: try reading any PHP file (for example, index.php) via LFI and see whether you get its raw source code or the file gets rendered as HTML.
🔐 Second-Order Attacks
As we’ve seen, LFI attacks can take very different forms. Another common, somewhat more advanced vector is the Second Order Attack. It occurs because many features of a web application may be pulling files from the back-end server insecurely, based on parameters indirectly controlled by the user.
For example, an application might allow downloading your own avatar through a URL like /profile/$username/avatar.png. If we register a malicious username, for example ../../../etc/passwd, it’s possible to change the file that gets pulled to another local server file, obtaining it instead of the avatar.
In this case, we would be poisoning a database entry (our username) with a malicious LFI payload. Later, another feature of the application would use that poisoned value to carry out the attack —when downloading the avatar based on the username—. Hence the name Second-Order: the attack executes at a later stage than the payload injection.
Developers often overlook this type of vulnerability because they protect direct user input (for example, a ?page parameter), but blindly trust values already stored in their database, such as the username in this case. If we manage to poison that value during registration, the attack becomes possible.
Exploiting LFI through second-order attacks follows the same logic we’ve seen in this section. The only difference is that we need to identify a function that includes a file based on a value we control indirectly, and then manipulate that value to exploit the vulnerability.
Note: all the techniques described in this article should work against any LFI vulnerability, regardless of the back-end language or framework used.
