#!/usr/local/bin/perl -w # maxbadrelays.pl based on maxlogins.pl ver2.0 my $VERSION = '1.0'; # See copyright info and instructions at end of script, or "maxbadrelays.pl -help" #use strict; use Getopt::Long; my %option = ( badipfile => '/var/log/spammers.ip', listfile => '/var/log/spammers', pidfile => '/var/spool/postfix/pid/master.pid', expire => '12h', kill => 0, loglevel => 1, maxattempts => 3, maxsuspects => 3, ); GetOptions( \%option, 'badipfile|b=s', 'listfile|f=s', 'pidfile|p=s', 'expire|e=s', 'help|h', 'kill|k=i', 'loglevel|l=i', 'maxattempts|a=i', 'maxsuspects|s=i', 'version|v', ); die "Maxbadrelays 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,$pid,$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 = ""; my $sw = 0; if ($logline =~ /postfix\/smtpd.+reject: RCPT from/) { my $ip; ($pid, $ip) = $logline =~ (/.*postfix\/smtpd\[(.+?)\]: .+?\[([0-9\.]+?)\]/); write_log($LOG_VERBOSE, "Bad relay attempt from: $ip (PID $pid)"); 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())." ". &GetDateStr(localtime($time+expiration()))." ". &GetTimeStr(localtime($time+expiration()))."\n"; write_log($LOG_INFO, "Blocking $ip"); if ($option{kill} && (defined $pid) && ($pid != '00000')) { kill ('TERM',$pid); kill ('HUP',`cat $option{pidfile}`); write_log($LOG_VERBOSE, "Killing process $pid"); } } 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 relay ".$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(); } system("tail +2 $option{badipfile} | perl -pe 's/\#\#expires.*/\t\tREJECT/' > $option{listfile}"); 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','mail.info',"Maxbadrelays - $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; } } sub GetDateStr { my(@GTS_date_array) = @_; if ($GTS_date_array[1] eq '') { @GTS_date_array = localtime(time); } my($dum); $dum=($GTS_date_array[5]+1900)."/".&ZeroPadding($GTS_date_array[4]+1)."/". &ZeroPadding($GTS_date_array[3]); return $dum; } sub GetTimeStr { my(@GTS_date_array) = @_; if ($GTS_date_array[1] eq '') { @GTS_date_array = localtime(time); } my($dum); $dum=&ZeroPadding($GTS_date_array[2]).":".&ZeroPadding($GTS_date_array[1]).":". &ZeroPadding($GTS_date_array[0]); return $dum; } sub ZeroPadding { my($dum)=sprintf("%d",@_); $dum="00".$dum; $dum=substr($dum,(length($dum)-2)); return $dum; } __END__ #----------------------------DOCUMENTATION------------------------------ =head1 NAME maxbadrelays.pl - block MTA(Mail Transfer Agent) break-in attempts. =head1 DESCRIPTION B tracks (in real time) repeated bad MTA access attempts and adds offending IPs to a blacklist used by F (Postfix configuration) to block further access. It is not designed to be run from a shell prompt and does not permanently occupy a process. This script is based on maxlogins.pl(v2.0). =head1 COPYRIGHT AND VERSION HISTORY Copyright (c) 2014 KASUGASOFT. Written by Suzuki. Arranged Script Name is maxbadrelays.pl Original Script Name is Maxlogins. 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 Customize for main.cf(Postfix). =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/maxbadrelays.pl". =item B<3)> Run "chmod 750 /usr/local/bin/maxbadrelays.pl". =item B<4)> Add to F a I entry for auth logs like so: =back mail.info /var/log/maillog # original line mail.info |exec /usr/local/bin/maxbadrelays.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 first row of F. =back check_client_access /var/log/spammers =over 7 =item B<7)> Reload main.cf ("/usr/sbin/postfix reload"). =item B<8)> Restart syslogd ("killall -HUP syslogd"). =back =head1 COMMAND LINE OPTIONS =over 2 =item B Sets location of the list of blocked IP addresses, but B for F. 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/spammers.new.*). The temp file will be removed rather quickly after creation. This file is temporary or used F. =item B Sets location of the list of blocked IP addresses for used by F. Should be located in a directory writeable only by root, for security purposes. Default: F. =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 maxbadrelays 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 postfix/smtpd process being used by the spammer, to prevent continue to send on the already-open connection. Set to 0 if you do not want to do this. Default: 0. =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 reject attempts before an IP address is blocked. As you lower the number of reject attempts you may want to decrease the expiration (e.g., 1 reject, expires in 1 minute). The author has found that most "bots" move on after the first reject attempt, and may not try again. If you set this to 1, beware of a address 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 rejects. 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/maxbadrelays.pl -a=2 -l=9 -e=1d ... |exec /usr/local/bin/maxbadrelays.pl --loglevel=2 --expire=6h =back =head1 TESTING Test via the command line using one of the following: (1) After modifying F and restarting syslogd (enter all on one line): Btest@experimental.comE: Relay access denied;"> (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): Btest@experimental.comE: Relay access denied;" |/usr/local/bin/maxbadrelays.pl> If logging is enabled, "grep maxbadrelays /var/log/maillog" 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 None. =head1 IDEAS =head2 F example smtpd_client_restrictions = permit_mynetworks, reject_unauth_pipelining, check_client_access cidr:/var/log/spammers, permit =head2 Crontab example @hourly /usr/local/bin/maxbadrelays.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