, 5 min read

Replacing SSHGuard with 20 Lines of Perl Code

Original post is here eklausmeier.goip.de/blog/2024/08-25-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:

  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.

#!/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");