How to Stop Malicious Vulnerability Scanners in Laravel with Fail2ban

Every website is bombarded with dozens of unwanted requests every day. The talk here is of so-called vulnerability scanners. In this article you will learn what they are all about and how you can fight the uninvited guests.

What is a Vulnerability Scanner?

Before any successful burglary, the object of desire must be scouted. Of course, you don't do this yourself. A self-respecting burglar has his henchmen for that. Imagine a small, inconspicuous guy. An everyman. Someone who disappears in the crowd. He walks past your house and casually looks under the doormat in front of your door. Is there a spare key lying there? No. Then he strolls relaxed along the wall of your house and rattles a window from time to time. Quietly, of course. Locked. If he's even braver, he might make a little detour into your garden. Maybe he'll have more luck with the patio door. Bingo, only ajar. He immediately informs his client and the latter quickly gets to work.

<a href=

You can imagine a vulnerability scanner like this or something similar. Of course, this is not a real person. Would be a miserably boring job, after all. No, it is an automatic program. This just pops in and checks if you might have done something stupid when setting up your website. Publicly available Git repository? A database backup lying around? A CMS that hasn't been updated? Anything can be useful. Most bots take a relatively similar approach. That is, they call similar paths. And that's exactly what you can take advantage of. Wouldn't it be great if a man with a big baton would watch over your property, respectively your website? He would then simply hit anyone over the head who shook the window. Well, the man with the stick has a name: fail2ban.

What is fail2ban?

The main purpose of fail2ban is to temporarily block certain IP addresses based on filters. There are several predefined filters. For example, filters that block attackers after so and so many unsuccessful login attempts. For this purpose, log files are usually searched for certain patterns. You can also define these patterns yourself. For example, you can specify a list of paths where you want to kick out an uninvited guest. But then you have to write a long regex pattern. Quite confusing. Why don't we just kick attackers out when they trigger a certain HTTP code? It should be quite unique, of course, so you don't accidentally scare away your desired visitors. I have built a small solution for this in Laravel, which I would like to present to you.

Mmmmh... Hooooneeeey

Winnie Pooh eats Honey

The basic idea behind this solution is that we want to set a trap for the scanner. In technical jargon, this is called a honeypot. As soon as the scanner tampers with the honeypot, the stick swooshes down respectively the scanner is blocked.

But how could we best trigger fail2ban if we don't want to check the path directly? Well for example with a response code. The best thing to do here is to take something that can generally never occur and then block the scanner immediately after the first request. Unfortunately there is no response code for a honeypot. But there is the Teapot. This is actually an April Fool's joke. But this code is really part of the standard. And it has the added benefit of never being used otherwise. But before we can set up a filter, we first need to install fail2ban.

We install fail2ban

Under Ubuntu it can be done like this:

sudo apt update
sudo apt install fail2ban

Then we start and activate the service:

sudo systemctl start fail2ban
sudo systemctl enable fail2ban

Afterwards, we create a new filter. Here we simply check if the status code 418 appears in any string whatsoever. I am assuming here that we are using Nginx as our web server. But the filter will probably work for Apache as well.

/etc/fail2ban/filter.d/nginx-honeypot.conf

[Definition]
failregex = ^<HOST>.*"(GET|POST).*" (418) .*$
ignoreregex =

Accordingly, we then additionally create a jail. We want to block the scanner immediately at the first attempt, so we set maxretry to 1. When using Apache, the path to the Apache log must of course be entered under logpath.

/etc/fail2ban/jail.d/honeypot.conf

[nginx-honeypot]
enabled = true
filter = nginx-honeypot
port = http,https
logpath = /var/log/nginx/access.log
maxretry = 1

After that we have to restart fail2ban.

$ fail2ban-client restart

With the status command we can check if our jail is really activated.

fail2ban-client status

It's All About The Honey

Now we have fail2ban set up. However, there is no way to generate a 418 code in our app yet. That is why we now first create a configuration file. In this we enter all the paths which vulnerability scanners usually call. We can create a list of paths from a log file, for example, and it is also always very individual. In a Laravel installation, for example, we can block all access to wp-admin (Wordpress) across the board. In wordpress, that would be rather inconvenient. But what we can definitely block are requests to .git or .env.

config/honeypot.php

return [
    'paths' => [
        '.env',
        '.git/config',
        '.git/HEAD',
        '.well-known/security.txt',
        // And so on...
    ],
];

Then we create a controller in which we access our configuration. If the user calls a path that starts with one of our registered paths, we return a 418 code.

namespace App\Http\Controllers;

use Illuminate\Http\Response;

class Honeypot extends Controller
{
    public function __invoke(string $path = '')
    {
        // Load the array of honeypot paths from the configuration.
        $honeypot_paths_array = config('honeypot.paths', []);

        // Turn the path array into a regex pattern.
        $honeypot_paths = '/^(' . str_replace(['.', '/'], ['\.', '\/'], implode('|', $honeypot_paths_array)) . ')/i';

        // If the user tries to access a honeypot path, fail with the teapot code.
        if (preg_match($honeypot_paths, $path)) {
            abort(Response::HTTP_I_AM_A_TEAPOT);
        }

        // Otherwise just display our regular 404 page.
        abort(Response::HTTP_NOT_FOUND);
    }
}

We now need to register a route. And in this case it will be a wildcard route. Here it is especially important that the route is registered last. Otherwise we will overwrite all other paths at this point. A positive side effect is that we cannot accidentally mark a known route as a honeypot. Even if we enter it in the configuration, Laravel will always prefer the correct route. Our honeypot controller will only be considered if no other controller is responsible.

use App\Http\Controllers\Honeypot;

Route::get('{path}', Honeypot::class)->where('path', '.*');

That's about it. Our trap is set. All we have to do now is wait and enjoy the fruits of our labor. It is also recommended to regularly check the log files and update the honeypot.php. If you don't want to wait that long, you can also do it yourself. At OWASP you can find a list of scanners that you can unleash on your site. The called paths you can then take over into the configuration.

A Few Words in Conclusion

One thing should be clear to everyone. This measure is not a substitute for a secure CMS/framework or whatever. With this method you can prevent most of the automatic scans on your system. But of course not all of them. That's because if you really have a database dump lying around, for example, our honeypot controller would not take action at all.

What you can achieve is that you get less unwanted traffic. No more and no less. Also, you may be able to prevent vulnerabilities from being found that would otherwise have been discovered. For example, because scanners often call common paths first. So it increases your security level. But it does not provide one hundred percent protection. That should be clear to you at this point.

#1 Adam wrote:

16.03.2023 17:10

Very neat solution. However would it not be better to save the list of paths inside a database table instead of a config file as this list may grow very long and if you have a versioned controlled application you'd constantly be having to commit it?

Also how would you manage logs where the path is empty but the user agent as you can see below appears malicious?

3.82.214.51 - - [16/Mar/2023:01:46:43+0000] POST / HTTP/1.1 404 134 - python-requests/2.28.2

#2 Christian Hanne wrote:

16.03.2023 21:37

Hi Adam, you are right. The config file is part of the revisions and the paths array might become pretty big. And in order to update the paths, you would have to commit the changes to the config file.

If for whatever reason you don't want to do this, you could move the paths configuration to a text file in your application folder and exclude this file from your version control system of choice. Then you can read the contents of this text file in your config file and turn the paths into an array (explode by line break for example). Because the config is cached, IO should not be a problem. Just remember to rebuild the configuration cache, whenever you update your text file.

If you want to distribute your settings across multiple environments or applications, you could create a Gist with the path configuration and download it via cronjob every day or so.

You can technically also use a database table for it. Personally I think that this would be a little bit of an overkill. But it's absolutely possible and if this is your preferred solution, go for it.

Regarding your question about user agents. I think there is a "bad bots" filter that comes shipped with fail2ban, which might also check the user agent. However user agents can easily be spoofed and I wouldn't rely on checking them for strange content. Whoever is able to hack your site, will probably just use an unsuspicious user agent.

Hope this answers your questions.

Cheers

#3 Adam wrote:

17.03.2023 10:01

Hi Christian, Thanks for the suggestions.

Where would you recommend placing the txt path file in a typical Laravel app and what file permissions should it be set to - apologies if this sounds like a stupid question but I'm still new to Laravel and webserver security.

Also how long would you ban the ips for or would it be a permanent ban?

If its a permanent ban how well would a 1cpu, 1gb ram, 25gb ubuntu server cope with managing those blocked ips as I'm getting loads of daily attempts to scan vulnerabilities on my site?

Just out of curiosity how well has this solution worked for you? Have you seen any performance degradation on your site and has the unwanted traffic reduced?

Finally, I get a lot of attempts to read the .env file and I've set all my file permissions to 664 following an answer from a stack overflow article (https://stackoverflow.com/questions/30639174/how-to-set-up-file-permissions-for-laravel). This means my .env ends up with -rw-rw-r--. Whilst the nginx server block configuration denies all file beginning with a dot, should I still change the permissions for this file?

Many thanks for both the article and your help.

#4 Christian Hanne wrote:

17.03.2023 18:43

That's a lot of questions.

> Where would you recommend placing the txt path file in a typical Laravel app

Depends on how you want to manage it. You could for example place it in the root folder and name it ".honeypot" for example. So it kind of acts like the other configuration files. If you want to create a UI later on, you might want to put it into the storage folder. This way it is easier to access through the Laravel filesystem.

> Also how long would you ban the ips for or would it be a permanent ban?

I normally ban for 24 hours. Although most scanners might be servers with a fixed ip address, chances are the ip will get reassigned to someone else eventually.

> If its a permanent ban how well would a 1cpu, 1gb ram, 25gb ubuntu server cope with managing those blocked ips

iptables & fail2ban can have an impact on performance. The performance impact is might be negligible for a website with normal traffic, but can be a problem, when you have thousands of requests per second for example. I can't tell if your setup would have problems or not. You might want to keep an eye on your server load or do some performance testing.

> Just out of curiosity how well has this solution worked for you? Have you seen any performance degradation on your site and has the unwanted traffic reduced?

I use this solution for all of my private projects and it is also used for some projects at work. As far as I can tell, I had no performance issues so far and the unwanted traffic died done a lot. It will never reach zero though. I am using other measures too, so it is hard to tell how effective the honeypot method is in isolation.

> Whilst the nginx server block configuration denies all file beginning with a dot, should I still change the permissions for this file?

Your .env file should be located outside of the public folder of your Laravel application. So it shouldn't be accessible through the browser at all. It should be readable & writeable by you (or the user you use for deployment). If you don't use configuration caching, it also needs to be readable by the webserver's user.

#5 Adam wrote:

18.03.2023 16:20

Hi Christian, Thanks for the advice. I was thinking rather than saving the paths to a txt or config file and going through the entire route controller process to trigger a 418 why not just add the paths to the failregex parameter in nginx-honeypot.conf file e.g. failregex = ^ -.*"(GET|POST) (/.env|/.git/config|/.git/HEAD|/.well-known/security\.txt) HTTP/1\.1"

This would resolve any version control issues or is your method more efficient than the above i.e. fail2ban only needs to search for 418 error codes rather than trawling through a long regex to identify paths?

#6 Christian Hanne wrote:

18.03.2023 20:56

Hi Adam, that was my first approach. However, it has a few downsides. Long regular expressions quickly become difficult to maintain and debug. With a 100 or more paths, it's basically impossible. There is also an increased risk of creating false positives. When blocking so aggressively, you want to make sure, that you only get the right ones. And yes, performance might also be an issue for longer regular expressions. You also always need to log in to your server, to update the settings.

So I came up with a solution, that is easy to setup and maintain. I also wanted it to be deployable. All I need to do right now is to edit the configuration file and commit the changes. Than the new path configuration will be deployed to all environments.

It also has a few other advantages. For example, it is a 100% safe to use, even in a shared hosting environment. You can have different rules per application. If you don't own the server, it is also way easier to convince a server admin to setup a fail2ban rule for http code 418, than to add a regular expression filter to a shared hosting. Because literally nobody else uses this response code. You also need zero knowledge about how fail2ban works, to update the configuration. You can even give this task to a junior developer or an intern.

Hope this answers your question.