skip to content
Back to GitHub.com
Home Bounties Research Advisories CodeQL Wall of Fame Get Involved Events
July 1, 2021

Fail2exploit: a security audit of Fail2ban

Kevin Backhouse

Security audits don’t always produce interesting results. As a member of GitHub Security Lab, my job is to help improve the security of open source software by finding and reporting vulnerabilities. On this occasion, I audited the open source project Fail2ban and I struck out: I didn’t find any issues worth reporting. From my perspective as a security researcher, that’s a fail, but for open source software, it’s a win.

Fail2ban is often recommended for anyone who’s running a Linux server that’s exposed to the public internet. For example, suppose you have enabled remote OpenSSH access on your home Linux server, but you don’t want your server to be subjected to a constant barrage of break-in attempts from internet pond-life who are trying to brute-force your password. You can protect your server by installing Fail2ban. Fail2ban is an open source software application that monitors system log files for repeated login failures and temporarily bans the responsible IP addresses to thwart brute-force attacks.

I’m always nervous about installing non-standard software. I generally prefer to stick with the default setup, because it’s likely to have received much more scrutiny from a security team. I also have a particular dislike of antivirus software (AV), due to its invasive nature and its ignominious track record of making security worse rather than better. Fail2ban appears to tick both those boxes, because it’s not installed by default (at least, not on the Linux distribution that I use), and it seems a bit AV-ish, considering that it runs as the root user and has the power to mess with your iptables. So, is it safe? I think so. In this blog post, I’ll describe what I did to test and audit Fail2ban, and why I think it’s safe.

The information in this blog post is based on Fail2ban version 0.11.2-1, installed on Ubuntu 21.04.

But first…

If you’re thinking of installing Fail2ban, there’s something else that I want you to check first. If you’re running an OpenSSH server, then the first thing that you should do to thwart brute force attacks is to disable password authentication. You can do that by editing this config file:

/etc/ssh/sshd_config

By default it has a line that looks like this:

#PasswordAuthentication yes

Replace that with:

PasswordAuthentication no

After that, remote clients can only log in using an SSH key. SSH keys are intended to be cryptographically secure, which means that it’s not feasible for an attacker to brute force the private key (assuming that the cryptographic scheme itself is both sound and implemented correctly). An attacker who discovers that your SSH server has password authentication disabled is likely to give up very quickly and move on to a different server.

If you haven’t used SSH keys before, then I recommend reading this overview of SSH keys from Ubuntu’s documentation.

Fail2ban installation and usage

Although Fail2ban isn’t usually installed by default, it is usually included in the standard package repositories. For example, on Ubuntu, installation is this simple:

sudo apt-get install fail2ban

That’s it! Your server is now protected from SSH password-guessing attacks. Of course, there are many configuration options that you can tweak if you wish (the manual is here), but the default configuration is fine.

If you’re interested in learning more about the internals of Fail2ban, then a useful configuration option to change is the logging level. You can do that by editing /etc/fail2ban/fail2ban.conf. Find the line that looks like this:

loglevel = INFO

And for the maximum amount of logging information, replace it with this:

loglevel = 1

Enabling email notifications

The other configuration change that I made on my own system was to add email notifications. The first step is to install an SMTP client:

sudo apt-get install msmtp-mta

Then create an msmtp config file:

touch ~/.msmtprc
chmod 600 ~/.msmtprc
emacs ~/.msmtprc  # edit contents

My ~/.msmtprc looks like this:

# Set default values for all following accounts.
defaults
auth           on
tls            on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile        ~/.msmtp.log

# Gmail
account        gmail
host           smtp.gmail.com
port           587
from           kev@gmail.com
user           kev
password       abcdabcdabcdabcd

# Set a default account
account default : gmail

(Sadly, my email address isn’t really kev@gmail.com.) For the password, I recommend generating an app password.

Now check that you’re able to send a simple email:

printf "Subject: Test email\nDate: `date --rfc-email`\nFrom: Kev <kev@gmail.com>\nTo: Kev <kev1337@mail.com>\nHi\n" | /usr/sbin/sendmail "kev1337@mail.com"

(Now you know my real email address! 😉) If that works, then copy your msmtp config file to the root user’s home directory to enable Fail2ban (which runs as the root user) to send emails.

sudo cp .msmtprc /root/

Next, create a file named /etc/fail2ban/jail.d/local.conf with the following contents:

[DEFAULT]
destemail = kev1337@mail.com
sender = kev@gmail.com
action = %(action_mwl)s

(Don’t forget to update the email addresses!)

The last step is to restart Fail2ban:

sudo systemctl restart fail2ban

If everything is working, you should receive an email from Fail2ban, with a title like “[Fail2Ban] sshd: started on …”.

Fail2ban attack surface

When I’m doing a security audit, my first question is always: “What’s the attack surface of this application?” The highest risk applications are those that are directly exposed to the public internet. OpenSSH is a good example: it listens on TCP port 22, so anybody on the internet can attempt to attack it by sending it malicious data. An application’s attack surface includes any interface that an attacker can use to interact with it. Fail2ban is closely related to OpenSSH, so you might expect it to have an equally high-risk attack surface. In fact, the opposite is true. Fail2ban’s core design is based on the clever idea of scanning the system log files. If an attacker tries to log in with SSH but fails to guess the correct password, then sshd writes an error message to the system log. Fail2ban scans the log files for those authentication error messages and, after multiple failed attempts from the same IP address, uses iptables to ban it. This design is clever because it means that there is no direct connection between Fail2ban and the public internet. Fail2ban’s primary attack surface is the contents of the system log files. In other words, without even looking at Fail2ban’s source code, I can already say that Fail2ban’s attack surface is relatively low risk.

Does Fail2ban have any other attack surface? Using lsof, I can see what files and sockets it has open:

f2b/serve 8859 root    0r      CHR                1,3      0t0      6 /dev/null
f2b/serve 8859 root    1u     unix 0xffff96308621a800      0t0  73846 type=STREAM
f2b/serve 8859 root    2u     unix 0xffff96308621a800      0t0  73846 type=STREAM
f2b/serve 8859 root    3w      REG                8,5     5852 811215 /var/log/fail2ban.log
f2b/serve 8859 root    4u     unix 0xffff96308621bc00      0t0  73885 /var/run/fail2ban/fail2ban.sock type=STREAM
f2b/serve 8859 root    6u      REG                8,5    73728 811221 /var/lib/fail2ban/fail2ban.sqlite3
f2b/serve 8859 root    8r  a_inode               0,14        0  10376 inotify
f2b/serve 8859 root    9r  a_inode               0,14        0  10376 inotify

The above shows that Fail2ban is using inotify to monitor file changes (it’s easy to confirm by checking the verbose output in fail2ban.log that it is only monitoring files in /var/log). It also shows that Fail2ban is accessing an SQLite database and a UNIX domain socket. But a quick check of the file permissions shows that they’re only accessible by the root user:

$ ls -l /var/lib/fail2ban/fail2ban.sqlite3
-rw------- 1 root root 90112 Jun  7 10:02 /var/lib/fail2ban/fail2ban.sqlite3
$ ls -l /var/run/fail2ban/fail2ban.sock
srwx------ 1 root root 0 Jun  2 15:22 /var/run/fail2ban/fail2ban.sock

In other words, neither of those files is interesting as an attack vector.

Attacking the log files

Based on the analysis so far, the system log files are the only interesting attack surface. The worst case scenario would be if a remote attacker could deliberately trigger the creation of a log message that causes Fail2ban to do something unintended. For example, is it possible for an attacker to lock a different user out by crafting an error message that contains a fake IP? I experimented with a few ideas like this:

ssh 'kev from 1.3.3.7 port 1337@hirsute-vm'

In other words, I inserted a fake IP address and port number into the username. That had the intended effect in /var/log/auth.log:

Jun 16 12:20:14 hirsute-vm sshd[193981]: Invalid user kev from 1.3.3.7 port 1337 from 10.0.2.2 port 50990

But it didn’t fool Fail2ban, as I saw when I checked /var/log/fail2ban.log:

2021-06-16 12:20:14,448 fail2ban.filter         [193923]: TRACE     Matched failregex 5: {'user': 'kev from 1.3.3.7 port 1337', 'ip4': '10.0.2.2', 'ip6': None, 'dns': None}

Fail2ban correctly identified kev from 1.3.3.7 port 1337 as the username and 10.0.2.2 as the source IP.

The regexes that Fail2ban uses to match log messages are stored in the directory /etc/fail2ban/filter.d. For example, the regex that matches the error message above is defined in /etc/fail2ban/filter.d/sshd.conf:

^[iI](?:llegal|nvalid) user <F-USER>.*?</F-USER> from <HOST>%(__suff)s$

Because the regex begins with ^ and ends with $, it must match the whole line, which means that a trick like embedding an IP address in the username cannot work. All of the sshd regexes match the whole line, so I don’t believe it is possible for a remote attacker to trigger a crafted log message, using only SSH, that will fool Fail2ban. Of course, if the machine is running another internet-connected service, and that service has a log injection vulnerability, then an attacker might be able to use that vulnerability to attack Fail2ban, but Fail2ban wouldn’t be to blame for that.

Local attacks

So I have concluded that it isn’t possible for a remote attacker to craft a fake log message. What about a local attacker? Since Fail2ban runs as root, a local attack could also be interesting. No special privileges are required to create a syslog message, so an unprivileged attacker could attack Fail2ban by creating a fake sshd log message. In fact, this risk is specifically called out in the Fail2ban documentation, because it’s trivial for a local attacker to trigger a denial of service (DOS). This command creates a fake sshd error message:

logger -p auth.warning -t 'sshd[123]' 'Illegal user bob from 1.2.3.4'

In other words, it is very easy for a local attacker to lock other users out of the system. That’s probably not a relevant security concern for your home server, but it could be a reason to avoid installing Fail2ban on, say, a server that’s shared by a class of computer science students.

But could a local attacker achieve something worse than a denial of service? For example, if there were a command injection vulnerability in Fail2ban, it could easily lead to local privilege escalation. It’s time to audit the source code.

Source code audit

A significant proportion of Fail2ban’s source code deals with things like interacting with the SQLite database. It also includes a client application which uses the UNIX domain socket that I mentioned earlier to communicate with the Fail2ban daemon. Since I already concluded that those parts of the application don’t have an interesting attack surface, I’m going to ignore them for the purposes of this security audit.

The verbose log output is very useful for identifying the parts of the source code that are the most important to audit. Here’s an excerpt from fail2ban.log (with loglevel = 1), just after a failed SSH attempt:

2021-06-16 12:20:14,447 fail2ban.filter         [193923]: TRACE   Working on line 'Jun 16 12:20:14 hirsute-vm sshd[193981]: Invalid user kev from 1.3.3.7 port 1337 from 10.0.2.2 port 50990'
2021-06-16 12:20:14,447 fail2ban.datedetector   [193923]: HEAVY   try to match time for line: Jun 16 12:20:14 hirsute-vm sshd[193981]: Invalid user kev from 1.3.3.7 port 1337 from 10.0.2.2 port 50990
2021-06-16 12:20:14,447 fail2ban.datedetector   [193923]: HEAVY     try to match last anchored template #00 ...
2021-06-16 12:20:14,447 fail2ban.datedetector   [193923]: #06-Lev.   matched last time template #00
2021-06-16 12:20:14,447 fail2ban.datedetector   [193923]: #06-Lev.   got time 1623842414.000000 for 'Jun 16 12:20:14' using template {^LN-BEG}(?:DAY )?MON Day %k:Minute:Second(?:\.Microseconds)?(?: ExYear)?
2021-06-16 12:20:14,447 fail2ban.filter         [193923]: HEAVY   Looking for match of [('', 'Jun 16 12:20:14', ' hirsute-vm sshd[193981]: Invalid user kev from 1.3.3.7 port 1337 from 10.0.2.2 port 50990')]
2021-06-16 12:20:14,447 fail2ban.filter         [193923]: HEAVY     Looking for prefregex '^(?P<mlfid>(?:\\[\\])?\\s*(?:<[^.]+\\.[^.]+>\\s+)?(?:\\S+\\s+)?(?:kernel:\\s?\\[ *\\d+\\.\\d+\\]:?\\s+)?(?:@vserver_\\S+\\s+)?(?:(?:(?:\\[\\d+\\])?:\\s+[\\[\\(]?sshd(?:\\(\\S+\\))?[\\]\\)]?:?|[\\[\\(]?sshd(?:\\(\\S+\\))?[\\]\\)]?:?(?:\\[\\d+\\])?:?)\\s+)?(?:\\[ID \\d+ \\S+\\]\\s+)?)(?:(?:error|fatal): (?:PAM: )?)?(?P<content>.+)$'
2021-06-16 12:20:14,447 fail2ban.filter         [193923]: TRACE     Pre-filter matched {'mlfid': ' hirsute-vm sshd[193981]: ', 'content': 'Invalid user kev from 1.3.3.7 port 1337 from 10.0.2.2 port 50990'}
2021-06-16 12:20:14,447 fail2ban.filter         [193923]: HEAVY     Looking for failregex 0 - '^[aA]uthentication (?:failure|error|failed) for (?P<user>.*) from (?:\\[?(?:(?:::f{4,6}:)?(?P<ip4>(?:\\d{1,3}\\.){3}\\d{1,3})|(?P<ip6>(?:[0-9a-fA-F]{1,4}::?|::){1,7}(?:[0-9a-fA-F]{1,4}|(?<=:):)))\\]?|(?P<dns>[\\w\\-.^_]*\\w))( via \\S+)?(?: (?:port \\d+|on \\S+|\\[preauth\\])){0,3}\\s*$'
2021-06-16 12:20:14,448 fail2ban.filter         [193923]: HEAVY     Looking for failregex 1 - '^User not known to the underlying authentication module for (?P<user>.*) from (?:\\[?(?:(?:::f{4,6}:)?(?P<ip4>(?:\\d{1,3}\\.){3}\\d{1,3})|(?P<ip6>(?:[0-9a-fA-F]{1,4}::?|::){1,7}(?:[0-9a-fA-F]{1,4}|(?<=:):)))\\]?|(?P<dns>[\\w\\-.^_]*\\w))(?: (?:port \\d+|on \\S+|\\[preauth\\])){0,3}\\s*$'
2021-06-16 12:20:14,448 fail2ban.filter         [193923]: HEAVY     Looking for failregex 2 - '^Failed publickey for invalid user (?P<user>(?P<cond_user>\\S+)|(?:(?! from ).)*?) from (?:\\[?(?:(?:::f{4,6}:)?(?P<ip4>(?:\\d{1,3}\\.){3}\\d{1,3})|(?P<ip6>(?:[0-9a-fA-F]{1,4}::?|::){1,7}(?:[0-9a-fA-F]{1,4}|(?<=:):)))\\]?|(?P<dns>[\\w\\-.^_]*\\w))(?: (?:port \\d+|on \\S+)){0,2}(?: ssh\\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)'
2021-06-16 12:20:14,448 fail2ban.filter         [193923]: HEAVY     Looking for failregex 3 - '^Failed (?:(?P<nofail>publickey)|\\S+) for (?P<cond_inv>invalid user )?(?P<user>(?P<cond_user>\\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+)) from (?:\\[?(?:(?:::f{4,6}:)?(?P<ip4>(?:\\d{1,3}\\.){3}\\d{1,3})|(?P<ip6>(?:[0-9a-fA-F]{1,4}::?|::){1,7}(?:[0-9a-fA-F]{1,4}|(?<=:):)))\\]?|(?P<dns>[\\w\\-.^_]*\\w))(?: (?:port \\d+|on \\S+)){0,2}(?: ssh\\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)'
2021-06-16 12:20:14,448 fail2ban.filter         [193923]: HEAVY     Looking for failregex 4 - '^(?P<user>ROOT) LOGIN REFUSED FROM (?:\\[?(?:(?:::f{4,6}:)?(?P<ip4>(?:\\d{1,3}\\.){3}\\d{1,3})|(?P<ip6>(?:[0-9a-fA-F]{1,4}::?|::){1,7}(?:[0-9a-fA-F]{1,4}|(?<=:):)))\\]?|(?P<dns>[\\w\\-.^_]*\\w))'
2021-06-16 12:20:14,448 fail2ban.filter         [193923]: HEAVY     Looking for failregex 5 - '^[iI](?:llegal|nvalid) user (?P<user>.*?) from (?:\\[?(?:(?:::f{4,6}:)?(?P<ip4>(?:\\d{1,3}\\.){3}\\d{1,3})|(?P<ip6>(?:[0-9a-fA-F]{1,4}::?|::){1,7}(?:[0-9a-fA-F]{1,4}|(?<=:):)))\\]?|(?P<dns>[\\w\\-.^_]*\\w))(?: (?:port \\d+|on \\S+|\\[preauth\\])){0,3}\\s*$'
2021-06-16 12:20:14,448 fail2ban.filter         [193923]: TRACE     Matched failregex 5: {'user': 'kev from 1.3.3.7 port 1337', 'ip4': '10.0.2.2', 'ip6': None, 'dns': None}
2021-06-16 12:20:14,448 fail2ban.filter         [193923]: DEBUG   Processing line with time:1623842414.0 and ip:10.0.2.2
2021-06-16 12:20:14,451 fail2ban.filter         [193923]: INFO    [sshd] Found 10.0.2.2 - 2021-06-16 12:20:14
2021-06-16 12:20:14,451 fail2ban.failmanager    [193923]: DEBUG   Total # of detected failures: 1. Current failures from 1 IPs (IP:count): 10.0.2.2:1

Summarized, this is the sequence of events:

  1. FilterPyinotify.callback is called because /var/log/auth.log was modified
  2. Filter.findFailure loops through the regexes, looking for a match
  3. Filter.findFailure finds a match
  4. Filter.processLineAndAdd processes the match
  5. Filter.processLineAndAdd decides whether to ban the IP

What I’m mainly looking for is some kind of injection vulnerability, like a SQL injection or command injection. Injection vulnerabilities are caused by an attacker-controlled string getting pasted into the middle of a longer string, such as a SQL query or a shell command. If the attacker-controlled string is permitted to contain things like punctuation characters then it might completely change the meaning of the enclosing command or query, thereby enabling the attacker to execute code. Injection attacks are prevented by carefully validating any attacker-controlled strings. At first glance, Fail2ban appears to have two layers of defense. The first layer of defense is that a malicious string has to pass through one of the regexes which are used for matching the log messages, which means that an attacker cannot, for example, insert a weird punctuation character into the IP address. But those regexes are defined in the config files, so there’s always a risk that a system administrator might add a custom regex that is less restrictive. So it’s important that there should also be a second layer of defense in the source code.

SQL Injection?

First, I’m going to check for SQL injections. When an IP address is banned, it’s added to the SQLite database, stored at /var/lib/fail2ban/fail2ban.sqlite3. This is done by Fail2BanDb.addBan:

cur.execute(
	"INSERT INTO bans(jail, ip, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)",
	(jail.name, ip, int(round(ticket.getTime())), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(),
		data))
cur.execute(
	"INSERT OR REPLACE INTO bips(ip, jail, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)",
	(ip, jail.name, int(round(ticket.getTime())), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(),
		data))

Those queries use placeholders for parameter substitution, rather than string concatenation, which protects against SQL injection. The other database queries in this source file follow the same good practice, so I’m confident that Fail2ban is safe from SQL injection.

Command Injection?

Second, I’m going to check for command injections. When an IP address is banned, Fail2ban runs an iptables command to add a firewall rule blocking that IP address. That command is run with root privileges, so if an attacker-controlled string can be inserted into the command then there’s a risk of a privilege escalation vulnerability.

The opportunities for a command injection are very limited, though. An IP address is added to the ban list by calling Filter.performBan:

def performBan(self, ip=None):
	"""Performs a ban for IPs (or given ip) that are reached maxretry of the jail."""
	try: # pragma: no branch - exception is the only way out
		while True:
			ticket = self.failManager.toBan(ip)
			self.jail.putFailTicket(ticket)
	except FailManagerEmpty:
		self.failManager.cleanup(MyTime.time())

performBan only has one parameter - the IP address - so the only way to smuggle a malicious string into the iptables command is via that parameter.

The code which extracts the IP address from the log message is in Filter.findFailure:

elif raw:
	ip = IPAddr(host, cidr)
	# check host equal failure-id, if not - failure with complex id:
	if fid is not None and fid != host:
		ip = IPAddr(fid, IPAddr.CIDR_RAW)
	ips = [ip]
# otherwise, try to use dns conversion:
else:
	ips = DNSUtils.textToIp(host, self.__useDns)

Now, for the first time, I see something that I don’t like, namely that if-statement involving the variable named fid. I don’t fully understand what that code is for, but what I do know is that you can trigger it in a way that the designers almost certainly didn’t intend by adding a filter regex like you see below to /etc/fail2ban/filter.d/sshd.conf:

^testfid <F-USER>.*?</F-USER> xxx <F-FID>.*?</F-FID> yyy <HOST>%(__suff)s$

I can trigger that regex like this:

logger -p auth.warning -t 'sshd[123]' 'testfid bob xxx `touch /etc/kevwozere` yyy 1.2.3.4'

Which leads to these rather worrying messages in fail2ban.log:

2021-06-16 12:41:49,473 fail2ban.utils          [193923]: ERROR   7f002fc09880 -- exec: ['f2bV_ip=$0 \niptables -w -I f2b-sshd 1 -s $f2bV_ip -j REJECT --reject-with icmp-port-unreachable', '`touch /etc/kevwozere`']
2021-06-16 12:41:49,474 fail2ban.utils          [193923]: ERROR   7f002fc09880 -- stderr: "Bad argument `/etc/kevwozere`'"
2021-06-16 12:41:49,474 fail2ban.utils          [193923]: ERROR   7f002fc09880 -- stderr: "Try `iptables -h' or 'iptables --help' for more information."
2021-06-16 12:41:49,474 fail2ban.utils          [193923]: ERROR   7f002fc09880 -- returned 2

Whoa! My command injection attempt (`touch /etc/kevwozere`) has made it all the way to a call to subprocess.Popen.

It’s not a big deal though (right?), because you need root privileges to modify the Fail2ban config files and nobody is going to accidentally add a regex that uses F-FID.

At first glance, the other codepaths look safer. When cidr is something other than IPAddr.CIDR_RAW, IPAddr’s constructor does a bunch of extra validation on the IP address, so presumably a malicious string like `touch /etc/kevwozere` will get rejected:

if cidr != IPAddr.CIDR_RAW:
	if cidr is not None and cidr < IPAddr.CIDR_RAW:
		family = [IPAddr.CIDR_RAW - cidr]
	else:
		family = [socket.AF_INET, socket.AF_INET6]
	for family in family:
		try:
			...

But just to be sure, I’ll test it by adding another regex that allows an arbitrary string as the IP address:

^testip <F-USER>.*?</F-USER> xxx <F-IP4>.*?</F-IP4>$

I can trigger that regex like this:

logger -p auth.warning -t 'sshd[123]' 'testip bob xxx `touch /etc/kevwozere`'

This time, I expect fail2ban.log to contain an error message about the IP address being invalid, and sure enough:

2021-06-16 12:51:30,828 fail2ban.actions        [193923]: ERROR   [sshd] unhandled error in actions thread: invalid literal for int() with base 10: 'etc/kevwozere`'

That’s better, although the error message isn’t quite as specific as I might have expected. Rather than something like “invalid IP address”, it only says “invalid literal for int()”, which sounds a bit like it’s just running int() on the string. So I should probably try throwing some digits in there just to be sure:

logger -p auth.warning -t 'sshd[123]' 'testip bob xxx `touch /etc/kevwozere1337`'

When I do that, this is the result in fail2ban.log:

2021-06-16 13:00:06,591 fail2ban.utils          [193923]: ERROR   7f002fc09640 -- exec: ['f2bV_ip=$0 \niptables -w -I f2b-sshd 1 -s $f2bV_ip -j REJECT --reject-with icmp-port-unreachable', '`touch /etc/kevwozere1337`']
2021-06-16 13:00:06,591 fail2ban.utils          [193923]: ERROR   7f002fc09640 -- stderr: "Bad argument `/etc/kevwozere1337`'"
2021-06-16 13:00:06,591 fail2ban.utils          [193923]: ERROR   7f002fc09640 -- stderr: "Try `iptables -h' or 'iptables --help' for more information."
2021-06-16 13:00:06,591 fail2ban.utils          [193923]: ERROR   7f002fc09640 -- returned 2

Oh.

So it turns out that the input validation isn’t so great after all.

I have played around with this command injection, and I don’t think it can be used to escalate privileges. The injection enables me to add arbitrary extra command line arguments to iptables. For example, it enables me to run commands like these (as root):

iptables -w -I f2b-sshd 1 -s 10.0.0.1 -m bpf --bytecode '4,48 0 0 9,21 0 1 6,6 0 0 1,6 0 0 0' -j REJECT --reject-with icmp-port-unreachable

iptables -w -I f2b-sshd 1 -s 10.0.0.1 --modprobe=/home/kev/pwn.sh -j REJECT --reject-with icmp-port-unreachable

I did not succeed in escalating privileges via those command line arguments, but it’s a bit close for comfort.

Fortunately, Fail2ban is still safe due to the first layer of validation, which is done by the regexes in the config files. The standard regexes use a macro named HOST to match the IP address. The HOST macro is quite restrictive, so there’s only a risk of command injection if you write a regex using the lower level F-IP4 tag, which would be a non-standard thing to do.

Conclusion

Fail2ban protects against brute force password-guessing attacks. In its default configuration, it protects OpenSSH, but it includes configurations for other applications such as asterisk, dropbear, and mysql, that are very easy to enable. I have tested and audited the source code of Fail2ban for security vulnerabilities and did not find any serious issues. Fail2ban has a known problem that an unprivileged local user can lock other users out of the system, which may make Fail2ban unsuitable for use on some shared servers. I also found that Fail2ban’s defenses against command injection attacks from a local attacker are not as good as they could be, but I did not find anything that is exploitable in practice.