How to ban iThemes Security lockout IPs via your system firewall automatically

iThemes Security, and other WordPress-based protection systems, have intelligent blocking systems that block IPs that attack your site.  Unfortunately, these blocks usually occur at the .htaccess level, and while Wordfence uses some advanced suPHP tricks to ensure that the block happens as early as possible in the PHP processing, most simply use .htaccess – and it is my preference that apache never even gets touched at all when these blocked IPs hit the server to reduce the overall load. After all, during particularly crazy times, these attacks can sometimes come in at a rate of thousands per hour. I’d rather the attackers get a timeout and can never reach the server again – nor any site on it – rather than being continuously fed HTTP 500 responses.

I use APF and BFD on my servers for firewalling and brute force detection.  When and IP is banned there, it is stopped cold in iptables before the request even reaches apache.  While awesome, writing new rules for BFD can be very difficult.  Doing so to stop WordPress based attacks is nearly impossible, since the BFD rules would somehow need to match the intelligent attack detection rules built into the WordPress-based protection systems.

I set out to solve this problem, and here is my solution.  Rather than trying to teach BFD what the WordPress detection systems already know, I figured I would just feed the lockouts that occur in those systems into APF.  The script below connects to the database as root, enumerates every database, finds a lockout table if iThemes Security is present, gets every IP from the table, and bans it via APF.  It should be fairly easy to adapt this to, say, Wordfence and CSF if you prefer.  Once you’ve tested it, just load it into a cron job to run every hour or so.  It could be further improved by caching the deny_hosts.rules APF file and checking to see if the IP is already banned to avoid unnecessary APF system calls, which I shall call v1.1 when I get around to it. At the moment that’s not necessary on my box, as my deny_hosts.rules file is only about 50k – it was over 300k and I reduce it by stripping out all the old comments.

Be sure to clear your /var/log/killer.log file once in a while, or it’ll eventually fill your drive.

#!/usr/bin/perl

use strict;
use warnings;
use DBI;

my @whitelist = ("1.2.3.4","2.3.4.5"); # replace this with a list of IPs you definitely never want to get banned - like your own

my $dbh = DBI->connect("dbi:mysql:information_schema","root", "YOUR_ROOT_PASSWORD",{'RaiseError' => 1}); # connect to mysql without specifying a specific database

my $databases = $dbh->;selectcol_arrayref('SHOW DATABASES'); # Get all the databases

for my $db (@$databases) { # We're going to process every database in the system
   $dbh->do("USE $db"); # Activate the current database
   #d("Connected to database $db"); # uncomment this to show names of databases being processed as it happens.  See sub d{} at the bottom, it's just a simple print statement
   my $tables = $dbh->selectcol_arrayref('SHOW TABLES'); # Get all the tables
   for my $t (@$tables) { # enumerate every table
      #d("Found table $t"); # uncomment this to show names of tables being processed as it happens
      if ($t =~ /\_itsec\_lockouts/i) { # We need to find tables that include _itsec_lockouts for iThemes Security.  Table prefix may vary, hence excluding wp_ from it
         #d("Found lockout table $db.$t"); # uncomment this to show matched lockout tables as it happens
         my $sth = $dbh->prepare("SELECT DISTINCT(lockout_host) FROM $t"); # Many IPs appear more than once, so distinct() helps cut down on system calls
         $sth->execute(); # Execute the query
         my $skip = 0; # set to 1 if we encounter a whitelisted IP so we can exit the loop
         while (my $ref = $sth->fetchrow_hashref()) { # Store the query results for retreival
            my $ip = $ref->{'lockout_host'}; # Get the IP from the table
            next if $ip eq ''; # iThemes Security tends to log a lot of blank IPs for some reason.
            for my $w (@whitelist) { $skip = 1 if $ip eq $w; } # Mark this IP for skipping due to being on whitelist
            next if $skip; # Move on to next IP if whitelisted
            print `apf -d $ip killer >> /var/log/killer.log`; # At this point the IP should be good for blocking.  Ban it!
         }
      }
   }
}

sub d { $_ = shift; print $_ . "\n"; }  # Debug outputter

UPDATE:

Apparently the wp_itsec_blocks entries are somewhat conservative. So I created another script which uses the wp_itsec_logs table and bans anyone who appears more than once in certain categories (‘brute_force’,’lockout’, and ‘ipcheck’).

#!/usr/bin/perl

use strict;
use warnings;
use DBI;

my @whitelist = ("1.2.3.4","2.3.4.5"); # replace this with a list of IPs you definitely never want to get banned - like your ow

my $dbh = DBI->connect("dbi:mysql:information_schema","root", "YOUR_ROOT_PASSWORD",{'RaiseError' => 1}); # connect to mysql without specifying a specific database

my $databases = $dbh->selectcol_arrayref('SHOW DATABASES'); # Get all the databases

my %ips;

for my $db (@$databases) { # We're going to process every database in the system
   $dbh->do("USE $db"); # Activate the current database
   #d("Connected to database $db"); # uncomment this to show names of databases being processed as it happens.  See sub d{} at the bottom, it's just a simple print statement
   my $tables = $dbh->selectcol_arrayref('SHOW TABLES'); # Get all the tables
   for my $t (@$tables) { # enumerate every table
      #d("Found table $t"); # uncomment this to show names of tables being processed as it happens
      if ($t =~ /\_itsec\_log/i) { # We need to find tables that include _itsec_lockouts for iThemes Security.  Table prefix may vary, hence excluding wp_ from it
         #d("Found lockout table $db.$t"); # uncomment this to show matched lockout tables as it happens
         my $sth = $dbh->prepare("SELECT DISTINCT(log_host) FROM $t WHERE log_type IN ('brute_force','lockout','ipcheck')"); # Many IPs appear more than once, so distinct() helps cut down on system calls
         $sth->execute(); # Execute the query
         my $skip = 0; # set to 1 if we encounter a whitelisted IP so we can exit the loop
         while (my $ref = $sth->fetchrow_hashref()) { # Store the query results for retreival
            my $ip = $ref->{'log_host'}; # Get the IP from the table
            next if $ip eq ''; # iThemes Security tends to log a lot of blank IPs for some reason.
            for my $w (@whitelist) { $skip = 1 if $ip eq $w; } # Mark this IP for skipping due to being on whitelist
            next if $skip; # Move on to next IP if whitelisted
            $ips{$ip}++; # Store this IP for blocking later, and count the occurrences
         }
      }
   }
}

for (keys(%ips)) { # For every IP we stored...
   if ($ips{$_} > 1) { # If it occurs more than once...
      print `/etc/apf/apf -d $_ killer2 >> /var/log/killer.log`; # Ban it
   }
}

sub d { $_ = shift; print $_ . "\n"; } # Debug outputter