HardStacked
Share:

Attack surface · fail2ban

The ban that bans nothing

Published · HardStacked · 5 min

You moved SSH off port 22, you installed fail2ban, and it looks like it is working. The jail is active, the counter goes up, the attacker’s address is right there in the banned list. Then you watch the log and the same address keeps coming back.

Nothing is broken. fail2ban is doing exactly half of its job, and the half it skips is the one you installed it for.

Everything below was measured on a fresh Ubuntu 24.04 cloud image with SSH moved to 2222, on 2026-08-03, while auditing our own hardening tool. The tool shipped this bug. That is how we found it.

01. The two line verdict

fail2ban detects, counts and displays the ban correctly. The ban action closes port 22 while your SSH listens somewhere else.

The ban is real. It just applies to the wrong door.

02. The fix

These commands are provided as is, without warranty. Test on a non critical machine or a snapshot first.

Open /etc/fail2ban/jail.local and make sure your [sshd] section carries your real port:

bash
$ grep -A5 '^\[sshd\]' /etc/fail2ban/jail.local

If there is no port = line under [sshd], add one with your actual port, then restart:

bash
$ printf 'port = 2222\n' >> /etc/fail2ban/jail.local && systemctl restart fail2ban

Replace 2222 with your port. If SSH answers on several ports, separate them with commas.

03. Why it happens

The stock jail.conf that ships with fail2ban declares this inside its [sshd] section:

jail.conf
port = ssh

ssh resolves through /etc/services to 22. A jail declared in jail.local without its own port = line inherits that value. Your firewall rule is then written for port 22 no matter where sshd actually listens.

Detection does not have that problem. On a machine with journald, fail2ban reads the journal directly:

fail2ban-client status sshd
Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd

The journal does not care which port the connection arrived on. So the counter is right, the ban list is right, and the rule that comes out of it is wrong. That split is the whole bug.

04. What it looks like when you measure it

Seven attempts against a nonexistent user, SSH on 2222:

fail2ban-client status sshd
|- Total failed: 7 |- Currently banned: 1 `- Banned IP list: 10.0.2.2

That reads like a win. Now look at the rule fail2ban actually installed:

nft list ruleset
chain f2b-chain { type filter hook input priority filter - 1; policy accept; tcp dport 22 ip saddr @addr-set-sshd reject with icmp port-unreachable }

tcp dport 22, while sshd is on 2222. The control test settles it. Connecting from the address that is listed as banned, on the real port:

control test
JE PASSE MALGRE LE BAN `- Banned IP list: 10.0.2.2

The address is banned and it walks straight in. The ban closes a door that was already closed. A brute force attempt on 2222 is never interrupted. It is only counted.

05. The test that will lie to you

The first time we tried to reproduce this, we authenticated with a wrong key and got:

fail2ban-client status sshd
|- Total failed: 0

The obvious conclusion was that fail2ban was not detecting anything at all. It was wrong. The journal did hold all seven failures, in this form:

journalctl -u ssh
Connection closed by authenticating user ... [preauth]

The stock sshd filter does not count that line in normal mode. A nonexistent user produces Invalid user, which it does count.

So if you want to test your own jail, do not use a bad key. Use a username that does not exist. Otherwise you are measuring your test setup, not your firewall.

06. If the port line is there and it still does not ban

Three real causes, in the order worth checking.

Your filter is not matching your log lines. Same family as the trap above. Run fail2ban-regex against a real sample of your journal instead of guessing.

Your backend is not reading what you think it is. On a journald machine the effective backend can be systemd without a single line saying so in your config, and the filter then never opens the log file you have been staring at. Worth knowing before you rewrite a working regex. One note on sourcing: several guides state that release 1.0 changed the default backend from auto to systemd. The official changelog does not say that. It introduces the auto backend in 0.10.0, and it is 1.1.0 that implements the automatic switch from auto to systemd. Check your own version rather than a blog post, this one included.

Something else is writing rules. Docker publishes ports straight into netfilter, and netfilter-persistent or a second frontend can load its own ruleset at boot. If two systems write to the same table, the one that wins is not always the one you configured.

If none of those three fit, the problem is usually not fail2ban. That is the point where an audit of the whole chain costs less than another evening of guessing.

07. Check that it is actually fixed

Do not trust the status output. Trust the rule:

bash
$ nft list ruleset | grep dport

You want to see your port in there, not 22. The status screen tells you what fail2ban believes. The ruleset tells you what the kernel will enforce. Those are two different questions, and only the second one stops anybody.

The same gap, between what a tool reports and what the machine does, shows up in a Lynis audit that checks the wrong algorithm, and again in a ufw firewall that reports active with no rule loaded.

Share: