Replacing SSHGuard with 20 Lines of Perl Code

· klm's blog

SSHGuard blocks unwanted ssh traffic. SSHGuard is more than 100 kLines of C and shell code. It can be replaced with 20 lines of Perl code to do the...

Original post is here: eklausmeier.goip.de

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:

 1     17      42     438 ./Makefile.am
 2    123     403    3037 ./sshguard.in
 3    708    2765   22860 ./Makefile.in
 4 130646  622873 7655483 ./parser/attack_scanner.c
 5    401    1202   11249 ./parser/attack_parser.y
 6    584    2862   22115 ./parser/tests.txt
 7   2061    9087   79252 ./parser/attack_parser.c
 8      2       5      47 ./parser/test-sshg-parser
 9     36     201    1219 ./parser/parser.h
10     56     178    1965 ./parser/attack.c
11   1035    4286   36122 ./parser/Makefile.in
12    146     366    3757 ./parser/parser.c
13     21      42     418 ./parser/Makefile.am
14    392    1959   22246 ./parser/attack_scanner.l
15    284    1276   11808 ./parser/attack_parser.h
16     25      62     425 ./fw/sshg-fw-ipset.sh
17    688    2706   23910 ./fw/Makefile.in
18     51     202    1296 ./fw/fw.h
19     38      88     574 ./fw/sshg-fw-iptables.sh
20     30      70    1089 ./fw/Makefile.am
21     36      88     586 ./fw/sshg-fw-ipfilter.sh
22     23      56     302 ./fw/sshg-fw-pf.sh
23     27      70     486 ./fw/sshg-fw-ipfw.sh
24     32      72     612 ./fw/sshg-fw.in
25     23      52     363 ./fw/sshg-fw-null.sh
26    384    1162   10702 ./fw/hosts.c
27     33      80     920 ./fw/sshg-fw-firewalld.sh
28     56     175    1147 ./fw/sshg-fw-nft-sets.sh
29     19      51     353 ./sshg-logtail
30    132     465    4383 ./blocker/sshguard_options.c
31     47     272    1673 ./blocker/sshguard_blacklist.h
32    137     605    3830 ./blocker/sshguard_whitelist.h
33     26     146     924 ./blocker/sshguard_log.h
34    129     607    3823 ./blocker/fnv.h
35    137     354    3929 ./blocker/blocklist.c
36    415    1580   14356 ./blocker/sshguard_whitelist.c
37     31     116    1097 ./blocker/attack.c
38    152     502    5019 ./blocker/sshguard_blacklist.c
39    145     670    3972 ./blocker/hash_32a.c
40    678    2598   25839 ./blocker/Makefile.in
41     45     285    1906 ./blocker/sshguard_options.h
42    302    1199   10354 ./blocker/blocker.c
43     13      19     236 ./blocker/blocklist.h
44     21      39     432 ./blocker/Makefile.am
45     30      73     646 ./common/sandbox.c
46     51     237    2163 ./common/address.h
47     41     104    1242 ./common/service_names.c
48    996    4757   31742 ./common/simclist.h
49    167     698    4909 ./common/config.h.in
50     16      31     310 ./common/sandbox.h
51   1512    5612   47636 ./common/simclist.c
52     77     441    3410 ./common/attack.h
53 143277  673891 8088612 total

The binary size is ca. 4MB for sshg-parser:

 1$ ls -l /usr/lib/sshguard
 2total 4928
 3-rwxr-xr-x   1 root root   34912 Jul 29 16:06 sshg-blocker*
 4-rwxr-xr-x   1 root root    1532 Jul 29 16:06 sshg-fw-firewalld*
 5-rwxr-xr-x   1 root root   18448 Jul 29 16:06 sshg-fw-hosts*
 6-rwxr-xr-x   1 root root    1198 Jul 29 16:06 sshg-fw-ipfilter*
 7-rwxr-xr-x   1 root root    1098 Jul 29 16:06 sshg-fw-ipfw*
 8-rwxr-xr-x   1 root root    1181 Apr 17  2022 sshg-fw-ipset*
 9-rwxr-xr-x   1 root root    1186 Jul 29 16:06 sshg-fw-iptables*
10-rwxr-xr-x   1 root root    1759 Jul 29 16:06 sshg-fw-nft-sets*
11-rwxr-xr-x   1 root root     975 Jul 29 16:06 sshg-fw-null*
12-rwxr-xr-x   1 root root     914 Jul 29 16:06 sshg-fw-pf*
13-rwxr-xr-x   1 root root     353 Jul 29 16:06 sshg-logtail*
14-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.

 1# Generated by iptables-save v1.8.6 on Sun Dec 20 13:29:18 2020
 2*raw
 3:PREROUTING ACCEPT [207:14278]
 4:OUTPUT ACCEPT [180:113502]
 5COMMIT
 6
 7# Empty iptables rule file
 8*filter
 9:INPUT ACCEPT [0:0]
10:FORWARD ACCEPT [0:0]
11:OUTPUT ACCEPT [0:0]
12-A INPUT -i eth0 -p tcp --dport 22 -m set --match-set reisbauerHigh src -j DROP
13-A INPUT -i eth0 -p tcp --dport 22 -m set --match-set reisbauerLow src -j DROP
14COMMIT

The sets in ipset are defined in file /etc/ipset.conf and are:

1create -exist reisbauerHigh hash:net family inet hashsize 65536 maxelem 65536 counters
2create -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:

1ipset 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:

 1$ ipset save reisbauerLow | tail -n +2 | sort -rnk7 | cut -d' ' -f1-3 | head
 2add reisbauerLow 180.101.88.244
 3add reisbauerLow 170.64.232.196
 4add reisbauerLow 170.64.204.232
 5add reisbauerLow 170.64.202.190
 6add reisbauerLow 5.135.90.165
 7add reisbauerLow 170.64.133.48
 8add reisbauerLow 61.177.172.136
 9add reisbauerLow 139.59.4.108
10add reisbauerLow 159.223.225.209
11add 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:

  1. You reboot, because ipset's are then reset
  2. You specifically unblock via ipset del reisbauerHigh <IP-address>
  3. You flush ipset: ipset flush reisbauerHigh

All triggering keywords or phrases are highlighted below.

 1#!/bin/perl -W
 2# Simplified version of SSHGuard with just Perl and ipset
 3
 4use strict;
 5my ($ip, %B);
 6my %whiteList = ( '192.168.0' => 1 );
 7
 8open(F,'-|','/usr/bin/journalctl -afb -p info -n1 -t sshd -t sshd-session -o cat') || die("Cannot read from journalctl");
 9
10while (<F>) {
11	if (/Failed password for (|invalid user )(\s*\w*) from (\d+\.\d+\.\d+\.\d+)/) { $ip = $3; }
12	elsif (/authentication failure; .+rhost=(\d+\.\d+\.\d+\.\d+)/) { $ip = $1; }
13	elsif (/Disconnected from (\d+\.\d+\.\d+\.\d+) port \d+ \[preauth\]/) { $ip = $1; }
14	elsif (/Unable to negotiate with (\d+\.\d+\.\d+\.\d+)/) { $ip = $1; }
15	elsif (/(Connection closed by|Disconnected from) (\d+\.\d+\.\d+\.\d+) port \d+ \[preauth\]/) { $ip = $2; }
16	elsif (/Unable to negotiate with (\d+\.\d+\.\d+\.\d+) port \d+/) { $ip = $1; }
17	else { next; }
18
19	#print "Blocking $ip\n";
20	next if (defined($B{$ip}));	# already blocked
21	next if (defined($whiteList{ substr($ip,0,rindex($ip,'.')) }));	# in white-list
22
23	$B{$ip} = 1;
24	`ipset -quiet add -exist reisbauerHigh $ip/32 `;
25}
26
27close(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:

1my %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:

 1[Unit]
 2Description=Simplified SSHGuard - blocks brute-force login attempts
 3After=iptables.service
 4After=ip6tables.service
 5After=libvirtd.service
 6After=firewalld.service
 7After=nftables.service
 8
 9[Service]
10ExecStart=/usr/sbin/ssshguard
11Restart=always
12
13[Install]
14WantedBy=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.

 1#!/bin/perl -W
 2# The top most IP addresses from reisbauerLow+High are retained in reisbauerLow,
 3# or more exact, every ipset which blocked more than 99 packets.
 4# This program must be run as root: ipset command needs this privilege
 5#
 6# Command line argument:
 7# 	-m	minimum number of packets blocked so far, default is 100
 8
 9
10use strict;
11
12use Getopt::Std;
13my %opts = ('m' => 100);
14getopts('m:',\%opts);
15my $minBlock = defined($opts{'m'}) ? $opts{'m'} : 100;
16my @F;
17
18open(F,'-|','/bin/ipset save -sorted') || die("Cannot read from ipset");
19
20print "create -exist reisbauerHigh hash:net family inet hashsize 65536 maxelem 65536 counters\n";
21print "create -exist reisbauerLow  hash:net family inet hashsize 65536 maxelem 65536 counters\n";
22
23while (<F>) {
24	next if (! /^add reisbauer/);
25	chomp;
26
27	@F = split(/ /);
28	if ($minBlock > 0) {
29		next if ($#F < 6);
30		next if ($F[4] < $minBlock);
31	}
32	printf("add -exist reisbauerLow %s\n",$F[2]);
33}
34
35close(F) || die("Cannot close pipe to ipset");