OpenSMTPd log scraping with AWK

Giving spammers a timeout

In my previous post I covered how I set up my own mail server on OpenBSD using OpenSMTPd. Promptly after setting up the server, I started noticing spammers connecting via SMTP to try and abuse the server for relaying their spam messages using presumably stolen or fake credentials.

The entries I found in /var/log/maillog all looked something like this:

Mar  9 08:55:42 mail smtpd[60467]: 9a2bd95a086bc4b0 smtp connected address=143.110.219.209 host=<unknown>
Mar  9 08:55:42 mail smtpd[60467]: 9a2bd95a086bc4b0 smtp tls ciphers=TLSv1.2:ECDHE-RSA-AES256-GCM-SHA384:256
Mar  9 08:55:42 mail smtpd[60467]: 9a2bd95a086bc4b0 smtp authentication [email protected] result=permfail
Mar  9 08:55:42 mail smtpd[60467]: 9a2bd95a086bc4b0 smtp failed-command command="AUTH LOGIN (password)" result="535 Authentication failed"
Mar  9 08:55:43 mail smtpd[60467]: 9a2bd95a086bc4b0 smtp disconnected reason=quit

So the machine with IP address 143.110.219.209 connected via SMTPS and tried to authenticate using the user name [email protected] and a password. Given that my mail server doesn’t have the user name [email protected] in its credentials database, OpenSMTPd correctly refuses the authentication attempt.

I wrote the following AWK script to process the logfile and tally up the number of failed authentication attempts for a given IP address:

#!/bin/awk -f
/smtp connected address=.*/ {
        # get offending ip address
        n = split($9, offender, "=");
        # save the IP address in a list indexed by record ID
        addresses[$6] = offender[2];
}
# this matches stuff like [email protected] --> not our domain, hard strike them
/smtp authentication user=.*@.* result=permfail/ {
        # split the user name into user and domain
        n = split($9, email, "@");
        # extract the domain
        domainpart = email[2];
        if ((domainpart != "domain1.example") || (domainpart != "domain2.example")) {
                hard_strikes[addresses[$6]]++;
        }
}
# this matches any single user name like we're also using --> only apply soft strike
# it might be my own account testing something
/smtp authentication user=[[:alnum:]\.]* result=permfail/ {
        soft_strikes[addresses[$6]]++;
}
END {
        # hard strikes are always blocked
        for (ip in hard_strikes) print ip;
        # soft strikes have a threshold applied
        for (ip in soft_strikes) if (soft_strikes[ip] > 5) print ip;
}

The first block matches any connection entry and stores the IP address in the addresses array, where the array key is the log ID like 9a2bd95a086bc4b0 in the example log above. It’s important to keep track of the ID because we can’t rely on the log output being nicely in order like the example above. Otherwise we might end up associating the wrong log messages to an IP and blocking the wrong IP. The addresses array here functions as a mapping from log ID to IP address.

The second block matches any authentication attempt of the form [email protected]. AWK splits the username field into the user and example.com parts and checks wether the domain name matches on of the configured domain names (domain1.example and domain2.example). If it doesn’t match, we know whoever tries to connect is connecting to the wrong server. The hard_strikes array is used to store the number of occurences of authentication failures, this time the array key is the actual IP address.

The third block matches any single user record, i.e. authentication without the domain part. I have to handle this case seperately as my own users authenticate in the same way and I don’t want to get blocked immediately if I enter a wrong password when setting up a new client. For these type of records there is a separate array soft_strikes.

When the script is done processing the log file I print out all the IP addresses that have collected strikes. In the case of the soft_strikes array I apply a threshold of 5 failed attempts to prevent myself from getting locked out when I’m testing something and accumulate a couple of authentication failures.

Another type of entry I saw in the logfile was this:

Mar  9 08:57:41 mail smtpd[60467]: 9a2bd96031c80f43 smtp connected address=137.116.168.210 host=<unknown>
Mar  9 08:58:02 mail smtpd[60467]: 9a2bd96031c80f43 smtp disconnected reason="io-error: No TLS error"
Mar  9 08:58:02 mail smtpd[60467]: 9a2bd9613a6ac8cf smtp connected address=137.116.168.210 host=<unknown>
Mar  9 08:58:23 mail smtpd[60467]: 9a2bd9613a6ac8cf smtp disconnected reason="io-error: No TLS error"
Mar  9 08:58:23 mail smtpd[60467]: 9a2bd96200d7af07 smtp connected address=137.116.168.210 host=<unknown>
Mar  9 08:58:43 mail smtpd[60467]: 9a2bd96200d7af07 smtp disconnected reason="io-error: No TLS error"
Mar  9 08:58:43 mail smtpd[60467]: 9a2bd9631e6274bc smtp connected address=137.116.168.210 host=<unknown>
Mar  9 08:59:04 mail smtpd[60467]: 9a2bd9631e6274bc smtp disconnected reason="io-error: No TLS error"
Mar  9 08:59:04 mail smtpd[60467]: 9a2bd96433ed7e88 smtp connected address=137.116.168.210 host=<unknown>
Mar  9 08:59:25 mail smtpd[60467]: 9a2bd96433ed7e88 smtp disconnected reason="io-error: No TLS error"
Mar  9 08:59:25 mail smtpd[60467]: 9a2bd965e9c64dea smtp connected address=137.116.168.210 host=<unknown>
Mar  9 08:59:45 mail smtpd[60467]: 9a2bd965e9c64dea smtp disconnected reason="io-error: No TLS error"
Mar  9 08:59:46 mail smtpd[60467]: 9a2bd96636f64f0a smtp connected address=137.116.168.210 host=<unknown>
Mar  9 09:00:06 mail smtpd[60467]: 9a2bd96636f64f0a smtp disconnected reason="io-error: No TLS error"
Mar  9 09:00:06 mail smtpd[60467]: 9a2bd9673587584d smtp connected address=137.116.168.210 host=<unknown>
Mar  9 09:00:27 mail smtpd[60467]: 9a2bd9673587584d smtp disconnected reason="io-error: No TLS error"
Mar  9 09:00:27 mail smtpd[60467]: 9a2bd968df190e9b smtp connected address=137.116.168.210 host=<unknown>
Mar  9 09:00:47 mail smtpd[60467]: 9a2bd968df190e9b smtp disconnected reason="io-error: No TLS error"
Mar  9 09:00:48 mail smtpd[60467]: 9a2bd969ff622399 smtp connected address=137.116.168.210 host=<unknown>

This seems like some type of fire-and-forget strategy being used by a spammer to try and send as many mails through an open SMTP relay as possible. Given that I have configured OpenSMTPd to only accept authenticated SMTP over TLS connections and the spammer doesn’t even try to do a TLS handshake, it again correclty refuses the connection.

Because the attempts occur fairly rapidly, I could simply use the rate-limiting facilities of the pf firewall. I do actually use firewall rate limting, however I’ve also seen these fire-and-forget attempts at much lower cadence with several minutes in between each attempt. At some point the metering intervall for rate-limiting would become so long that I may block legitimate attempts to send mail.

I decided to write another AWK script to parse the logs for this kind of behaviour:

#!/bin/awk -f
/smtp connected address=.*/ {
        # get the IP address for this ID
        n = split($9, offender, "=");
        # add the IP address to a list
        # indexed by record ID
        addresses[$6] = offender[2];
}
/smtp disconnected reason="io-error: No TLS error"/ {
        # retrieve address by record ID and tally up the
        # number of failures
        strikes[addresses[$6]]++;
}
END {
        for (ip in strikes) if (strikes[ip] > 5) print ip;
}

The first block is essentially identical to the one in the first script and constructs an array mapping the log ID to an IP address. The second block matches those connections that error out because of missing TLS and the number of failed attempts is tallied up per IP. Finally, all IP addresses that failed more than 5 times are printed out.

The final piece of the puzzle is instructing the firewall to block connection attempts from the offending IP addresses. I simply wrote a shell script that pipes the output of the scripts into xargs and adds them to the spammers table using pfctl:

#!/bin/sh
for file in /etc/mail/scripts/*.awk; do
        awk -f $file /var/log/maillog | xargs pfctl -t spammers -T add
done

This script is periodically run using a cron job:

~       *       *       *       *       sh /etc/mail/scripts/run.sh 2> /dev/null

The matching lines in /etc/pf.conf are:

table <spammers> persist
[...]
block in quick from <spammers>

In order to prevent blocking an IP address forever (users of IP addresses do occasionally change), I’m also expiring table entries that are more than one week (604800 seconds) old:

~       *       *       *       *       pfctl -t spammers -T expire 604800 2> /dev/null

By using awk in combination with pfctl it’s possible to block some of these abusive hosts using only built-in tools of OpenBSD.