#!/usr/local/bin/perl -w # maxlogins.pl my $VERSION = '2.0'; # See copyright info and instructions at end of script, or "maxlogins.pl -help" use strict; use Getopt::Long; my %option = ( badipfile => '/var/log/maxlogins', expire => '12h', kill => 1, loglevel => 1, maxattempts => 3, maxsuspects => 3, ); GetOptions( \%option, 'badipfile|b=s', 'expire|e=s', 'help|h', 'kill|k=i', 'loglevel|l=i', 'maxattempts|a=i', 'maxsuspects|s=i', 'version|v', ); die "Maxlogins version $VERSION\n\nRun \"perldoc $0\"\nor \"$0 -help\" for instructions\n\n" if $option{version}; if ($option{help}) { use Pod::Usage; die pod2usage(-verbose => 2); } if ($option{maxsuspects} > 50) { $option{maxsuspects} = 50; } if ($option{maxsuspects} < 1) { $option{maxsuspects} = 1; } if ($option{maxattempts} > 50) { $option{maxattempts} = 50; } if ($option{maxattempts} < 1) { $option{maxattempts} = 1; } my $SEMAPHORE = undef; my $time = time; my $suspectexpiration = $time + 24*60*60; #24h my @blacklist = (); my $LOCK_EX = 2; my $LOG_INFO = 1; my $LOG_EXP = 2; my $LOG_VERBOSE = 9; my @suspects = (); my $num_expired = 0; my ($rin,$rout,$nfound,$sshdpid,$logline,$bytes,$buf,$bol,$eol,$halfline); $rin = $halfline = ''; vec($rin,fileno(STDIN),1) = 1; $nfound = select($rout=$rin, undef, undef, 3); GET_LOCK: { if( defined $SEMAPHORE ) { close $SEMAPHORE; undef $SEMAPHORE; } unless( open $SEMAPHORE, '> '.$option{badipfile}.'.lock' ) { warn "Could not get open semaphore ($!) possible loop\n"; sleep 1; redo GET_LOCK; } while( ! flock $SEMAPHORE, $LOCK_EX ) { } last GET_LOCK; } read_badip_file(); while ($nfound>0) { $bytes = sysread(STDIN,$buf,1024); last unless (defined($bytes) && ($bytes>0)); $bol=$eol=0; $buf = $halfline.$buf; while ($bol < length($buf)) { $eol = index($buf,"\n",$bol); if ($eol==-1) { $halfline = substr($buf,$bol); last; } else { $logline = substr($buf,$bol,($eol+1-$bol)); $bol = $eol+1; $halfline = ""; if ($logline =~ /sshd\[\d*\]: Failed password/){ my $ip; ($sshdpid, $ip) = $logline =~ (/sshd\[(\d*)\].*from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}) port/); write_log($LOG_VERBOSE, "Bad login attempt from: $ip (PID $sshdpid)"); if ($option{loglevel}==$LOG_VERBOSE) { my $suspectlist = ''; for my $aref (@suspects) { $suspectlist .= "@$aref[0] "; } write_log($LOG_VERBOSE, "Suspect IPs=$suspectlist"); } my $ipfound = 0; my $newcount = 1; for (my $i=0;$i<=$#suspects;$i++) { if ($ip eq $suspects[$i][0]) { $newcount = ++$suspects[$i][1]; if ($newcount >= $option{maxattempts}) { # block IP push @blacklist, $ip." ##expires: ".($time + expiration())."\n"; write_log($LOG_INFO, "Blocking $ip"); if ($option{kill} && (defined $sshdpid) && ($sshdpid != '00000')) { kill ('TERM',$sshdpid); write_log($LOG_VERBOSE, "Killing process $sshdpid"); } } remove_suspect($i); if ($newcount < $option{maxattempts}) { add_suspect($ip, $newcount); } $ipfound = 1; last; } } if (!$ipfound) { if ($#suspects >= ($option{maxsuspects}-1)) { remove_suspect($#suspects); } add_suspect($ip, 1); } write_log($LOG_VERBOSE, "$ip failed login ".$newcount." time(s)"); write_badip_file(); $num_expired = 0; #file already written } } } $nfound = select($rout=$rin, undef, undef, 3); } if ($num_expired) { write_badip_file(); } close $SEMAPHORE; exit; ## ## Subroutines ## sub read_badip_file { if (!(-e $option{badipfile})) { open (BADIP,'>',$option{badipfile}) or die "Could not create ".$option{badipfile}.":$!\n"; close (BADIP); chmod 0640, $option{badipfile}; } open (BADIP,'<',$option{badipfile}) or die $!; my @badip_contents = ; if (@badip_contents) { my $badip_str = $badip_contents[0]; if ((substr($badip_str, 0, 1) eq "#") && (length($badip_str)>3)) { my @suspects_array = split(/:/,substr($badip_str, 1)); write_log($LOG_VERBOSE, "# suspects=".@suspects_array); for (my $i=0;$i<=$#suspects_array;$i++) { my @tmparr = split(/-/,$suspects_array[$i]); if ($tmparr[2] > $time) { push(@suspects, [@tmparr]); } else { write_log($LOG_EXP, "Suspect expired: ".$tmparr[0]); $num_expired++; } } } # read remainder of list for (my $i=1;$i<=$#badip_contents;$i++) { if( my ($expiration) = $badip_contents[$i] =~ /\#\#expires: (\d+)/ ) { if ($time < $expiration) { push @blacklist, $badip_contents[$i]; } else { write_log($LOG_EXP, "Block expired: ".$badip_contents[$i]); $num_expired++; } } } write_log($LOG_VERBOSE, "IPs being blocked=".scalar(@blacklist)); } close(BADIP); } sub write_badip_file { my $badip_str = '#'; my $filenew = $option{badipfile}.'.new'.int(rand(999999)); if (scalar(@suspects) > $option{maxsuspects}) { $#suspects = $option{maxsuspects}-1; } open (BADIPNEW,'>',$filenew) or die "Could not create $filenew:$!\n"; for (my $i=0;$i<=$#suspects;$i++) { $badip_str .= $suspects[$i][0].'-'.$suspects[$i][1].'-'.$suspects[$i][2]; $badip_str .= ':' unless ($i==$#suspects); } print (BADIPNEW "$badip_str\n"); print (BADIPNEW @blacklist); close (BADIPNEW); rename $filenew, $option{badipfile}; } sub write_log { my ($minlevel, $message) = @_; if ($minlevel <= $option{loglevel}) { system('logger','-p','auth.info',"maxlogins - $message"); } } sub remove_suspect { my ($i) = @_; splice(@suspects, $i, 1); } sub add_suspect { my ($anip, $acount) = @_; unshift(@suspects, [$anip,$acount,$suspectexpiration]); } sub expiration { my ($n,$u) = $option{expire} =~ /^(\d+)([dhms]?)/; if ($u eq 'd') { return ($n*60*60*24); } elsif ($u eq 'h') { return ($n*60*60); } elsif ($u eq 'm') { return ($n*60); } else { return $n; } } __END__ #----------------------------DOCUMENTATION------------------------------ =head1 NAME maxlogins.pl - block ssh break-in attempts. =head1 DESCRIPTION B tracks (in real time) repeated bad ssh login attempts and adds offending IPs to a blacklist used by F (tcp wrappers) to block further access. It is not designed to be run from a shell prompt and does not permanently occupy a process. =head1 COPYRIGHT AND VERSION HISTORY Copyright (c) 2005,2006 ITS, Inc. Written by Steve Yates. Latest version at www.teamITS.com/resources. Originally based upon a concept from a script by Matt Smith and David Godsey from CodeNameHosting.com, posted to the vps2@providertalk.com mailing list 2005/03/22 as maxsec.pl. Thanks also for ideas to Scott's Rascals program and blog (scott.wiersdorf.org/blog), and a tip from Phil. =over 7 =item B Major rewrite. Add tracking of multiple IPs, self-expiring blocks, process killing, command line options, and improved log entry processing and file locking. =item B =item B =item B =back =head1 LICENSE This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. For a copy of the GNU General Public License visit http://www.gnu.org or write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. =head1 INSTRUCTIONS =over 7 =item B<1)> Copy this file to F or your favorite location. If you paste it into a text editor, use one that won't wrap long lines like "ee" or "pico -w". =item B<2)> Run "chown root:wheel /usr/local/bin/maxlogins.pl". =item B<3)> Run "chmod 750 /usr/local/bin/maxlogins.pl". =item B<4)> Add to F a I entry for auth logs like so: =back auth.info;authpriv.info /var/log/auth.log # original line auth.info;authpriv.info |exec /usr/local/bin/maxlogins.pl # added =over 7 =item B<5)> Optional: add command line options to the line in F to change settings. =item B<6)> Back up F, and add the following lines to the top of the file (I "ALL : ALL : allow"). =back sshd : 127.0.0.1 : allow sshd : /var/log/maxlogins : deny This will effectively whitelist any "allow"ed IPs (127.0.0.1, above) that appear before the "deny" entry. =over 7 =item B<7)> Restart syslogd ("killall -HUP syslogd"). =back =head1 COMMAND LINE OPTIONS =over 2 =item B Sets location of the list of blocked IP addresses. Should be located in a directory writeable only by root, for security purposes. Default: F. Note an associated lock file will be created (e.g., F) as well as a temporary file (e.g., /var/log/maxlogins.new.*). The temp file will be removed rather quickly after creation. =item B Sets the time before a blocked IP address "expires" and the block is removed. Accepts day/hour/minute/second notation, e.g. 1d, 24h, 1440m, and 86400s are all equivalent. Default: 12h. Note: blocks expire and are removed when B runs, i.e., when something writes to F. You can also run maxlogins from cron at any desired interval (> 5 seconds) to clean out expired IPs on a regular basis. =item B Set to 1 to kill the sshd process being used by the cracker, to prevent additional login attempts on the already-open connection. Set to 0 if you do not want to do this. Default: 1. =item B Sets logging level for entries in F. Default: 1. 1: informational (e.g., new blocks) 2: above, plus IP expirations 9: verbose logging =item B Sets number of failed login attempts before an IP address is blocked. As you lower the number of failed login attempts you may want to decrease the expiration (e.g., 1 failed login, expires in 1 minute). The author has found that most "bots" move on after the first failed connection attempt, and do not try again. If you set this to 1, beware of a password typo triggering the block! Max: 50. Default: 3. =item B Set to the number of IP addresses for which B will track the number of failed logins. To protect against a widely distributed attack, set this number higher. Suspects expire after 24 hours or when a new suspect is added and B is exceeded. Max: 50. Default: 3. =item B Displays version number and exits. =item B (at end of line in F; see INSTRUCTIONS...in particular, restart syslogd after modifying F): ... |exec /usr/local/bin/maxlogins.pl -a=2 -l=9 -e=1d ... |exec /usr/local/bin/maxlogins.pl --loglevel=2 --expire=6h =back =head1 TESTING B lock yourself out! B lock yourself out! Either add your IP to hosts.allow (see above), keep a second SSH session open during testing, test by connecting from another host (besides your computer), or test via the command line using one of the following: (1) After modifying F and restarting syslogd (enter all on one line): B (note: change something ("test2", "test3", "test4") each time (or wait 1 second between tries) when using logger, otherwise subsequent attempts will just generate a "last message repeated _ times" entry and not trigger a block) (2) Or, this also works before modifying F (enter all on one line): B If logging is enabled, "grep maxlogins /var/log/auth.log" will show log entries. Use "--loglevel=9" to show detailed logging. B when testing. B will not attempt to kill PID 00000, but by default will kill other PIDs, unless "--kill=0" is specified. If you use a random number you will kill a random process! =head1 RECOMMENDATIONS - Consider running sshd from inetd, to throttle connection attempts. B runs for each log entry, however, it will remain running until log activity ceases. An inetd entry like so: ssh stream tcp nowait/30/5/15 root /usr/sbin/sshd sshd -i -4 ...will limit sshd to 30 child processes, 5 connections per IP per minute, and 15 simultaneous invocations per IP address. This is the default setup for ViaVerio VPS/MPS servers. - Create a backup of /etc/hosts.allow before starting. Backups never hurt. - Strongly consider whitelisting your IP (see INSTRUCTIONS above) or leaving a second ssh session open during testing, in case B works and you lock yourself out. An open SSH session will not be cut off via adding a block to hosts.allow. - Do not pick a very large expiration time. Most bots do not continue trying to connect to a denied connection, and the bigger the blacklist gets the (slightly) slower B may become. =head1 IDEAS =head2 F examples sshd : /var/log/maxlogins : spawn /bin/sleep 30 : twist /bin/echo "go away" sshd : /var/log/maxlogins : twist /bin/echo "Access denied." Too long a sleep period may use up many processes if the attacker is using multiple connections. =head2 Crontab example @hourly /usr/local/bin/maxlogins.pl Runs F every hour to remove expired IP addresses from the list. Not required, but guarantees F is run on a regular basis, as opposed to waiting until the next line is written to F. F will run and exit on its own after a few seconds. =head1 COMPATIBILITY Tested under Perl 5.8.4 and 5.6.1, and FreeBSD 4.7. Should work with later syslogd-based systems and Perl versions. =cut