If you run CSF (ConfigServer Security & Firewall) on a host with OpenSSH 9.8 or newer, lfd has probably stopped blocking SSH brute-force attempts while every setting still says it should. On the RHEL family this arrives with the distro, not with you: RHEL 9, AlmaLinux 9 and Rocky Linux 9 shipped OpenSSH 8.7 until minor release 9.8, which rebased to OpenSSH 9.9 — so the breakage lands the day you update to 9.8, and RHEL 10 has it out of the box. Debian 13, Ubuntu 24.10+ and Fedora 41+ are affected too. The box below ran CSF v15.00 on AlmaLinux 9.
The symptom: thousands of "Failed password" lines, zero SSH brute force bans
On one of my boxes /var/log/secure had 13,412 Failed password lines and not a single attacker IP had ever been blocked. TESTING was 0, LF_SSHD was 5, LF_SSHD_PERM was 1, /var/log/secure was in the watched log list. Everything else in lfd — Suspicious Process Reporting, System Integrity and my own custom regex rules — worked fine, which is exactly what makes this so easy to miss.
Why it happens: OpenSSH 9.8 logs auth failures as sshd-session[PID]
OpenSSH 9.8 split the daemon into two binaries: sshd, the listener, and sshd-session, a separate per-connection process. Authentication now happens in the child, so the log line changed from this:
Sep 7 14:02:11 host sshd[31641]: Failed password for root from 203.0.113.44 port 51234 ssh2
to this:
Sep 7 14:02:11 host sshd-session[59698]: Failed password for root from 203.0.113.44 port 51234 ssh2
CSF's built-in rules live in /usr/local/csf/lib/ConfigServer/RegexMain.pm and they match sshd\[\d+\] — the process name followed immediately by a bracket. The new sshd-session[ never matches, so lfd counts zero failures and therefore never reaches the LF_SSHD threshold.
fail2ban had the same blind spot before version 1.1.0, which added sshd-session support. If you are on fail2ban, check your version first — EPEL 9 already ships 1.1.0.
Check whether lfd is blocking your SSH brute force
Three commands, no changes:
# 1. Is sshd logging failures as sshd-session?
grep -c 'sshd-session\[.*Failed password' /var/log/secure
# 2. Does CSF know about it? (0 = your lfd is blind)
grep -c 'sshd-session' /usr/local/csf/lib/ConfigServer/RegexMain.pm
# 3. How many built-in rules are affected?
grep -c 'sshd.\[' /usr/local/csf/lib/ConfigServer/RegexMain.pm
On my box the answers were 13412, 0 and 12.
That third number matters. It is not one rule that breaks — it is twelve: eleven LF_SSHD brute-force patterns (Failed password, Invalid user, pam_unix(sshd:auth) authentication failure, Failed none, Failed keyboard-interactive, not listed in AllowUsers, Did not receive identification string, refused connect from, maximum authentication attempts exceeded, Illegal user, error: PAM: Authentication failure) plus the LF_SSH_EMAIL_ALERT rule that sends you the "SSH login" notification mail.
Note the small trap in command 3: write the pattern as sshd.\[ and not as sshd\[. In a basic regular expression \[ is a literal [, so sshd\[ searches for the four characters sshd[ — which never appear in the Perl source, because the source contains sshd, a backslash, then the bracket. It returns 0 on a broken box and on a fixed one, and proves nothing either way. The . matches the backslash.
Fix 1 – one edit that repairs all twelve rules
Look at what you are about to change, then make the process name suffix-aware everywhere in the file:
# always keep a copy of a CSF core file before touching it
cp /usr/local/csf/lib/ConfigServer/RegexMain.pm /root/RegexMain.pm.bak
# see the 12 lines in question
grep -n 'sshd.\[' /usr/local/csf/lib/ConfigServer/RegexMain.pm
# preview the change - this must print 12
sed 's/sshd.\[/sshd(?:-session)?\\[/g' \
/usr/local/csf/lib/ConfigServer/RegexMain.pm | grep -c 'sshd(?:-session)?'
# then apply
sed -i 's/sshd.\[/sshd(?:-session)?\\[/g' /usr/local/csf/lib/ConfigServer/RegexMain.pm
# confirm: 12 before, 0 after
grep -c 'sshd.\[' /usr/local/csf/lib/ConfigServer/RegexMain.pm
The . in sshd.\[ is deliberate, for the reason above: it matches the backslash already in the Perl source, so you never have to escape a backslash through your shell. If you run this over SSH from another machine, the fully-escaped version silently matches nothing — always preview to stdout and count the hits before using -i.
One thing to know about this fix: RegexMain.pm is a CSF core file, so normally an update would revert it. On stock CSF that no longer happens, and the reason is worth a section of its own.
Stock CSF will never fix this, so check what you are running
ConfigServer (Way to the Web Ltd) shut down permanently on 31 August 2025. download.configserver.com no longer resolves, so csf -u and AUTO_UPDATES have nothing left to pull — v15.00, released under the GPLv3 just before the shutdown, is the last upstream version, and this bug will never be fixed in it.
That leaves two paths:
- Staying on stock v15.00. Fix 1 survives by accident, because no update can overwrite it any more. You own that file now.
- Moving to a maintained community fork, such as Aetherinox/csf-firewall, which already carries an OpenSSH 9.8+ regex update. If you go this way you do not need Fix 1 at all — but you do need to re-run the three checks above after every update, because there the file can be replaced.
Either way, run the check. A firewall that silently stopped protecting the one service everyone attacks is worth ten seconds of grep.
Fix 2 – a custom rule that survives updates
/etc/csf/regex.custom.pm (a symlink to /usr/local/csf/bin/regex.custom.pm) is evaluated before every built-in regex, and it is not touched by updates. If its custom_line sub returns a true value, CSF uses it and stops.
The useful detail — which is not obvious from the file's own comments — is that returning the three-value built-in shape makes CSF apply your existing LF_SSHD / LF_SSHD_PERM settings, so you do not have to invent thresholds or port lists, and the bans show up as ordinary SSH bans:
sub custom_line {
my $line = shift;
my $lgfile = shift;
# OpenSSH >= 9.8 logs auth failures from sshd-session[PID]
if ($lgfile eq "/var/log/secure") {
if ($line =~ /sshd-session\[\d+\]: Failed password for (?:invalid user )?(\S+) from (\S+) port \d+/) {
my $acc = $1;
my $ip = $2;
return ("Failed SSH login from", "$ip|$acc", "sshd");
}
}
return 0;
}
Compare the argument to $lgfile as a literal path rather than reaching for CSF's package globals or helper functions: $lgfile is a sub parameter, so it is always in scope, and the rule keeps working regardless of how CSF's internals change.
On Debian and Ubuntu the path is /var/log/auth.log, so change the comparison to match — or use if ($lgfile =~ m{^/var/log/(secure|auth\.log)$}) if you deploy the same file to both. Confirm which files lfd is actually watching with grep -A5 sshd /etc/csf/csf.syslogs.
Before shipping any custom regex, pull the real message shapes out of your own log and test against them:
grep 'sshd-session\[' /var/log/secure \
| sed 's/.*sshd-session\[[0-9]*\]: //' \
| sed 's/[0-9][0-9.]*/N/g' | sort | uniq -c | sort -rn
Then test the candidate pattern in a small local Perl harness, including negative cases: an Accepted password line from your own IP, an old-style sshd[ line that the built-in already handles, and a line from a different log file.
Fix 2 only covers the patterns you write. Fix 1 covers all twelve. Doing both is fine — custom_line simply runs first.
csf -r does not reload lfd (and csf -ra does)
lfd compiles the regex files with Perl require when the daemon starts, and csf -r restarts the firewall only — lfd keeps running the old code in memory, so your new rule does nothing and it looks like the fix failed.
csf -ra # restarts csf AND lfd
# or
systemctl restart lfd
Verify that the reload actually happened instead of trusting the exit code:
systemctl show lfd -p MainPID -p ActiveEnterTimestamp
If the PID and the timestamp are the same as before, nothing was reloaded.
Then make sure lfd is actually alive. A Perl syntax error in either file makes lfd die on startup, which leaves the box with no login-failure protection at all — strictly worse than the bug you just fixed:
systemctl is-active lfd
tail -30 /var/log/lfd.log # a Perl compile error here means lfd is not running
If it will not start, put the file back and restart again:
cp /root/RegexMain.pm.bak /usr/local/csf/lib/ConfigServer/RegexMain.pm
systemctl restart lfd
Verifying the fix — and two traps that fake a failure
Within about eight minutes of the reload, lfd wrote the first SSH bans that box had ever produced:
203.0.113.44 # lfd: (sshd) Failed SSH login from 203.0.113.44 (XX/Country/-): 5 in the last 3600 secs
Two reasons you may think it is still broken:
csf -tproves nothing whenLF_SSHD_PERM = "1". Permanent bans are written to/etc/csf/csf.deny, not to the temporary list. Check withgrep 'lfd: (sshd)' /etc/csf/csf.denyandcsf -g <ip>— the IP should appear in theDENYINchain with packets already counted against it.- lfd's per-IP counters restart from zero when lfd restarts. After the reload you need
LF_SSHDfresh failures from the same IP. Give it ten minutes or more before concluding anything; two minutes is not a test.
And if lfd still counts nothing after all that, check the clock on the log itself: lfd's time-window logic reads the timestamps in /var/log/secure, and those are wrong on any host where rsyslog cached the timezone at startup.
The login alert mail is broken too
LF_SSH_EMAIL_ALERT — the "SSH login … into the root account" mail — is handled by a separate subroutine in RegexMain.pm (processsshline), not by the custom_line path. A custom regex cannot fix it; only Fix 1 does.
It is easy to confirm on a box that has been up for a while:
grep -c "SSH login" /var/log/lfd.log
If that number is near zero while there are successful logins every day, the alerts were never generated. Worth checking before you go hunting for SMTP or SPF problems — and worth re-checking after the fix, because generating the alert and delivering it are two separate problems.
While you are in csf.conf
The same file holds a second setting that quietly costs you a control: IPV6. If it is "0" — which is the default — CSF builds no IPv6 rules at all, so a host with a global IPv6 address is reachable on every port no matter what TCP_IN says. That one is covered in why IPV6 = 0 leaves ip6tables completely unfiltered.