#!/usr/bin/perl -w # # Stateless brute-force SSH attack blocker. # # Unlike other blocker programs, this one doesn't need external help # to keep state of attempted breakin attempts. No file, no databases, # no shared memory. It simply relies on IPFW2's table functionality: # The trick is that we want to block IP addresses just after very few # attempts (typically 3). So we can simply use an IPFW2 table for each # number of attempts, and record the time of the attempt in the table # entry tag field. For each attempt we promote the IP entry from table # n to table (n+1), until it reaches the table that does the actual # blocking. # # Of course this only works for IPFW2 (but conversion to PF is # straightforward). # # To use this program, place a line into /etc/syslogd.conf as follows: # # auth.info;authpriv.info |exec /path/to/program --options # # The process will fork itself into the background. The child process # will stay resident and is responsible for cleaning out old entries. # The foreground process will receive data from syslogd and enter new # IP addresses into the IPFW2 tables as appropriate. Note that it is # safe if the foreground process dies away (for instance, when syslogd # receives a HUP signal as a result of logfile rotation). A fresh # foreground process will be started by syslogd when appropriate. Also, # a fresh daemon will be started if the background process has dies away # for whatever reason. # # Options are as follows: # # --table specifies the starting IPFW2 table number. Default is 0. # # --count is the number of attempts that we want to track (including the # attempt that caused the blocking). Default is 3. # # With this default setting, we will use IPFW2 tables 0, 1, and 2, where # table 2 is the "hot" table responsible for the actual blocking. Be # aware to set up your IPFW2 rules accordingly, e.g. # # deny ip from table(2) to any # # --grace specifies the grace period (time required for succeeding # attempts). After the grace period, attempts that did not reach the # blocking table are wiped out. Default is 60 (seconds). # # --unblock is the unblock period, i.e. the time after which the blocked # entries are cleared. Default is 300 (seconds). # # --alarm is the run interval for the cleanup task. Default is 10 # (seconds). This should not be set too low. Be aware that this # introduces some artificial granularity to the --grace and --unblock # values. # # --debug will shout louder. # # Requires Perl >5.0 or above. # # Bugs: # # This is hard-core perl code. Self-explanatory for the knowledgable, # eye powder for the rest. Comments are for bloggers. # # We should check the return status of system() calls. We don't do that # here since we are ignoring SIGCHLD to avoid zombies. # # We should use an IPFW2 API instead of calling /sbin/ipfw ever. # # # Copyright © 2006 Helge Oldach # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. use strict; use Getopt::Long qw(:config bundling); use Sys::Syslog qw(:DEFAULT setlogsock); use Proc::PID::File; my $alarm = 10; my $count = 3; my $debug = 0; my $grace = 60; my $table = 0; my $unblock = 300; setlogsock "unix"; ($_ = $0) =~ s#^.*/([^/\.]+)[^/]*$#$1#; openlog $_, "ndelay,pid", "LOG_AUTH"; $SIG{'__DIE__'} = sub { syslog "LOG_ERR", @_; die @_ }; $SIG{'__WARN__'} = sub { syslog "LOG_ERR", @_ }; $SIG{'CHLD'} = "IGNORE"; die "option error" unless GetOptions( "alarm|a=i" => \$alarm, "count|c=i" => \$count, "debug|d+" => \$debug, "grace|g=i" => \$grace, "table|t=i"=> \$table, "unblock|u=i" => \$unblock); my $IPFW = "/sbin/ipfw"; die "aborting: invalid grace period $grace" unless $grace > 0; die "aborting: invalid unblock period $unblock" unless $unblock > 0; die "aborting: invalid table number $table" unless $table >= 0 && $table <= 127 - $count + 1; die "aborting: IPFW2 not configured $? $!" if qx($IPFW table $table list 2>&1 1>/dev/null); syslog "LOG_ERR", "starting: tables=$table.." . ($table + $count - 1) . " grace=$grace unblock=$unblock alarm=$alarm debug=$debug" if $debug; unless (my $pid = fork) { die "cannot fork: $!" unless defined $pid; chdir "/"; open STDIN, '/dev/null' or die "cannot read /dev/null: $!"; open STDOUT, '>/dev/null' or die "cannot write to /dev/null: $!"; defined(my $pid = fork) or die "cannot fork: $!"; exit if $pid; POSIX::setsid or die "cannot start a new session: $!"; open STDERR, '>&STDOUT' or die "cannot dup stdout: $!"; exit if Proc::PID::File->running; while (1) { sleep $alarm; foreach my $tab ($table .. $table + $count - 1) { map { syslog "LOG_ERR", "" . ($tab == $table + $count - 1 ? "unblocking" : "cleaning") . " $_"; syslog "LOG_ERR", "$IPFW table $tab delete $_" if $debug; system "$IPFW table $tab delete $_"; syslog "LOG_ERR", "done execute ($?)" if $debug > 1; } grep { # catch case of value converted to address $_ =~ /^(.*)\/32 (\d+)(\.(\d+)\.(\d+)\.(\d+))?$/; $_ = $3 ? (($2*256+$4)*256+$5)*256+$6 : $2; $_ = time > $_ + ($tab < $table + $count - 1 ? $grace : $unblock) ? $1 : undef } qx/$IPFW table $tab list/; } } syslog "LOG_ERR", "terminating daemon" if $debug; exit; } while (<>) { chomp; syslog "LOG_ERR", "got a line" if $debug > 2; next unless /(failed|invalid user|authentication error) .*from (\d+\.\d+\.\d+\.\d+|[\da-fA-F:]+)/i; my $ip = $2; syslog "LOG_ERR", "ip=$ip" if $debug > 2; @_ = grep { grep { $_ =~ /^$ip\// } qx/$IPFW table $_ list/ } ($table .. $table + $count - 1); syslog "LOG_ERR", "ip already present=" . scalar @_ if $debug > 2; if (! scalar @_) { syslog "LOG_ERR", "$IPFW table $table add $ip " . time if $debug; system "$IPFW table $table add $ip " . time; syslog "LOG_ERR", "done execute ($?)" if $debug > 1; } elsif ($_[0] < $table + $count - 1) { syslog "LOG_ERR", "$IPFW table $_[0] delete $ip" if $debug; system "$IPFW table $_[0] delete $ip"; syslog "LOG_ERR", "done execute ($?)" if $debug > 1; syslog "LOG_ERR", "blocking $ip" if $_[0] + 1 == $table + $count - 1; syslog "LOG_ERR", "$IPFW table " . ($_[0] + 1) . " add $ip " . time if $debug; system "$IPFW table " . ($_[0] + 1) . " add $ip " . time; syslog "LOG_ERR", "done execute ($?)" if $debug > 1; } else { syslog "LOG_ERR", "$ip already present in block table " . ($table + $count - 1); } syslog "LOG_ERR", "finished line" if $debug > 2; } syslog "LOG_ERR", "terminating" if $debug;