, 5 min read
Replacing SSHGuard with 20 Lines of Perl Code
SSHGuard is a software to block unwanted SSH login attempts. SSHGuard has a very remarkable architecture: it has a set of independent programs doing parsing, block-indication, and actual blocking. I wrote about this in Analysis And Usage of SSHGuard. SSHGuard 2.4.3 is about 100kLines of C and shell code:
17 42 438 ./Makefile.am
123 403 3037 ./sshguard.in
708 2765 22860 ./Makefile.in
130646 622873 7655483 ./parser/attack_scanner.c
401 1202 11249 ./parser/attack_parser.y
584 2862 22115 ./parser/tests.txt
2061 9087 79252 ./parser/attack_parser.c
2 5 47 ./parser/test-sshg-parser
36 201 1219 ./parser/parser.h
56 178 1965 ./parser/attack.c
1035 4286 36122 ./parser/Makefile.in
146 366 3757 ./parser/parser.c
21 42 418 ./parser/Makefile.am
392 1959 22246 ./parser/attack_scanner.l
284 1276 11808 ./parser/attack_parser.h
25 62 425 ./fw/sshg-fw-ipset.sh
688 2706 23910 ./fw/Makefile.in
51 202 1296 ./fw/fw.h
38 88 574 ./fw/sshg-fw-iptables.sh
30 70 1089 ./fw/Makefile.am
36 88 586 ./fw/sshg-fw-ipfilter.sh
23 56 302 ./fw/sshg-fw-pf.sh
27 70 486 ./fw/sshg-fw-ipfw.sh
32 72 612 ./fw/sshg-fw.in
23 52 363 ./fw/sshg-fw-null.sh
384 1162 10702 ./fw/hosts.c
33 80 920 ./fw/sshg-fw-firewalld.sh
56 175 1147 ./fw/sshg-fw-nft-sets.sh
19 51 353 ./sshg-logtail
132 465 4383 ./blocker/sshguard_options.c
47 272 1673 ./blocker/sshguard_blacklist.h
137 605 3830 ./blocker/sshguard_whitelist.h
26 146 924 ./blocker/sshguard_log.h
129 607 3823 ./blocker/fnv.h
137 354 3929 ./blocker/blocklist.c
415 1580 14356 ./blocker/sshguard_whitelist.c
31 116 1097 ./blocker/attack.c
152 502 5019 ./blocker/sshguard_blacklist.c
145 670 3972 ./blocker/hash_32a.c
678 2598 25839 ./blocker/Makefile.in
45 285 1906 ./blocker/sshguard_options.h
302 1199 10354 ./blocker/blocker.c
13 19 236 ./blocker/blocklist.h
21 39 432 ./blocker/Makefile.am
30 73 646 ./common/sandbox.c
51 237 2163 ./common/address.h
41 104 1242 ./common/service_names.c
996 4757 31742 ./common/simclist.h
167 698 4909 ./common/config.h.in
16 31 310 ./common/sandbox.h
1512 5612 47636 ./common/simclist.c
77 441 3410 ./common/attack.h
143277 673891 8088612 total
The binary size is ca. 4MB for sshg-parser
:
$ ls -l /usr/lib/sshguard
total 4928
-rwxr-xr-x 1 root root 34912 Jul 29 16:06 sshg-blocker*
-rwxr-xr-x 1 root root 1532 Jul 29 16:06 sshg-fw-firewalld*
-rwxr-xr-x 1 root root 18448 Jul 29 16:06 sshg-fw-hosts*
-rwxr-xr-x 1 root root 1198 Jul 29 16:06 sshg-fw-ipfilter*
-rwxr-xr-x 1 root root 1098 Jul 29 16:06 sshg-fw-ipfw*
-rwxr-xr-x 1 root root 1181 Apr 17 2022 sshg-fw-ipset*
-rwxr-xr-x 1 root root 1186 Jul 29 16:06 sshg-fw-iptables*
-rwxr-xr-x 1 root root 1759 Jul 29 16:06 sshg-fw-nft-sets*
-rwxr-xr-x 1 root root 975 Jul 29 16:06 sshg-fw-null*
-rwxr-xr-x 1 root root 914 Jul 29 16:06 sshg-fw-pf*
-rwxr-xr-x 1 root root 353 Jul 29 16:06 sshg-logtail*
-rwxr-xr-x 1 root root 4630632 Jul 29 16:06 sshg-parser*
The main annoyance with SSHGuard was that sometimes it did not stop properly when stopped via systemd. During powering down one machine this is especially enerving as this increases overall downtime.
As SSHGuard has this quite clean architecture where parsing and block-indication are so clearly separated, it is easy to find out what it actually tries to block.
In addition at looking at the source code of the Flex rules in parser/attack_scanner.l
I now wrote Simplified SSHGuard in less than 20 lines of Perl.
In Arch Linux you can use ssshguard.
1. Firewall preparations
Firewall setup is similar to SSHGuard.
# Generated by iptables-save v1.8.6 on Sun Dec 20 13:29:18 2020
*raw
:PREROUTING ACCEPT [207:14278]
:OUTPUT ACCEPT [180:113502]
COMMIT
# Empty iptables rule file
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i eth0 -p tcp --dport 22 -m set --match-set reisbauerHigh src -j DROP
-A INPUT -i eth0 -p tcp --dport 22 -m set --match-set reisbauerLow src -j DROP
COMMIT
The sets in ipset
are defined in file /etc/ipset.conf
and are:
create -exist reisbauerHigh hash:net family inet hashsize 65536 maxelem 65536 counters
create -exist reisbauerLow hash:net family inet hashsize 65536 maxelem 65536 counters
The set reisbauerLow
is not needed.
However, sometimes it is convenient to have an already defined set, to which you can swap:
ipset swap reisbauerHigh reisbauerLow
Once you power down your machine all firewall rules and all ipset sets are lost.
On rebooting the machine you initialize iptables and ipset again.
Though, the actual set content is forgotten.
Therefore you might run a cron-job to periodically save the reisbauerHigh set to reisbauerLow and filter for the ten most used IP addresses and store them in /etc/ipset.conf
.
For example:
$ ipset save reisbauerLow | tail -n +2 | sort -rnk7 | cut -d' ' -f1-3 | head
add reisbauerLow 180.101.88.244
add reisbauerLow 170.64.232.196
add reisbauerLow 170.64.204.232
add reisbauerLow 170.64.202.190
add reisbauerLow 5.135.90.165
add reisbauerLow 170.64.133.48
add reisbauerLow 61.177.172.136
add reisbauerLow 139.59.4.108
add reisbauerLow 159.223.225.209
add reisbauerLow 103.164.8.158
Above command prints the ten most offending IP addresses which can be appended to /etc/ipset.conf
via cron.
See Periodic seeding.
2. Perl code
Similar to SSHGuard, the simplified version reads its input from journalctl
.
Certain output lines of journalctl
then trigger the blocking via ipset
.
The logic is that all unsuccessful login attempts result in an entry in an ipset, which then permanently bans that IP address from any further login attempts.
I.e., the ssh daemon no longer even sees that.
You are blocked "forever", unless:
- You reboot, because ipset's are then reset
- You specifically unblock via
ipset del reisbauerHigh <IP-address>
- You flush ipset:
ipset flush reisbauerHigh
All triggering keywords or phrases are highlighted below.
#!/bin/perl -W
# Simplified version of SSHGuard with just Perl and ipset
use strict;
my ($ip, %B);
my %whiteList = ( '192.168.0' => 1 );
open(F,'-|','/usr/bin/journalctl -afb -p info -n1 -t sshd -t sshd-session -o cat') || die("Cannot read from journalctl");
while (<F>) {
if (/Failed password for (|invalid user )(\s*\w*) from (\d+\.\d+\.\d+\.\d+)/) { $ip = $3; }
elsif (/authentication failure; .+rhost=(\d+\.\d+\.\d+\.\d+)/) { $ip = $1; }
elsif (/Disconnected from (\d+\.\d+\.\d+\.\d+) port \d+ \[preauth\]/) { $ip = $1; }
elsif (/Unable to negotiate with (\d+\.\d+\.\d+\.\d+)/) { $ip = $1; }
elsif (/(Connection closed by|Disconnected from) (\d+\.\d+\.\d+\.\d+) port \d+ \[preauth\]/) { $ip = $2; }
elsif (/Unable to negotiate with (\d+\.\d+\.\d+\.\d+) port \d+/) { $ip = $1; }
else { next; }
#print "Blocking $ip\n";
next if (defined($B{$ip})); # already blocked
next if (defined($whiteList{ substr($ip,0,rindex($ip,'.')) })); # in white-list
$B{$ip} = 1;
`ipset -quiet add -exist reisbauerHigh $ip/32 `;
}
close(F) || die("Cannot close pipe to journalctl");
Wait, isn't that more than 20 lines of code?
Yes, but if you remove comments and empty lines, drop the close()
, which is not strictly needed, then you come out at below 20 lines of source code, including configuration.
The whiteList
hash variable contains all those class C networks, which you do not want to block, even if the passwords are given wrong multiple times.
Adding class C addresses to %whitelist
should be obvious.
For example:
my %whiteList = ( '10.0.0' => 1, '192.168.0' => 1, '192.168.178' => 1 );
3. Starting and stopping
Starting and stopping via systemd is 100% same as SSHGuard. systemd script is stored here:
/etc/systemd/system/multi-user.target.wants/sshguard.service
The systemd script is as below:
[Unit]
Description=Simplified SSHGuard - blocks brute-force login attempts
After=iptables.service
After=ip6tables.service
After=libvirtd.service
After=firewalld.service
After=nftables.service
[Service]
ExecStart=/usr/sbin/ssshguard
Restart=always
[Install]
WantedBy=multi-user.target
4. Periodic seeding
Below Perl script can be run every few hours to save the current set of IP addresses and store them in /etc/ipset.conf
.
#!/bin/perl -W
# The top most IP addresses from reisbauerLow+High are retained in reisbauerLow,
# or more exact, every ipset which blocked more than 99 packets.
# This program must be run as root: ipset command needs this privilege
#
# Command line argument:
# -m minimum number of packets blocked so far, default is 100
use strict;
use Getopt::Std;
my %opts = ('m' => 100);
getopts('m:',\%opts);
my $minBlock = defined($opts{'m'}) ? $opts{'m'} : 100;
my @F;
open(F,'-|','/bin/ipset save -sorted') || die("Cannot read from ipset");
print "create -exist reisbauerHigh hash:net family inet hashsize 65536 maxelem 65536 counters\n";
print "create -exist reisbauerLow hash:net family inet hashsize 65536 maxelem 65536 counters\n";
while (<F>) {
next if (! /^add reisbauer/);
chomp;
@F = split(/ /);
if ($minBlock > 0) {
next if ($#F < 6);
next if ($F[4] < $minBlock);
}
printf("add -exist reisbauerLow %s\n",$F[2]);
}
close(F) || die("Cannot close pipe to ipset");