home Mail List
Info
Info
Meetings
Goals
Upcoming
Projects
FAQ
Security
Links

[Date Prev][Date Next] [Chronological] [Thread] [Top]

[NMLUG] An antispam script


  • Subject: [NMLUG] An antispam script
  • From: kevin at rosenberg.net (Kevin Rosenberg)
  • Date: Mon Nov 8 18:45:16 2004

As seen with the recent the delay in NMLUG posts, I've been working on
slowing down the spam attacks on my mail server. To offset the
disconcertment experienced by some NMLUG posters by the posting delay,
I offer my latest antispam script attached to this message.

This script monitors so-called attacks known as equivalently as
Rumplestiltskin, dictionary, email address harvesting, and RCPT
probes. When it detects such an attack from an IP address, it blocks
that IP address from sending further email.

Since starting that script 3 days ago, I've blocked 28,000 IP
addresses which were using dictionary attacks on my mail server. I run
the script out of a cron job once a minute. On my mailserver the
script only takes 0.2sec to execute.

Enjoy!

-- 
Kevin Rosenberg
kevin@rosenberg.net
-------------- next part --------------
#!/usr/bin/perl

=head1 NAME

blockrumple - Monitors sendmail logs and blocks spamming IP addresses

=head1 DESCRIPTION

This program parses the recent sendmail logs and identifies IP addresses
attempting to send e-mail to unknown local usernames. This spam technique
is also called email harvesting or RCPT (recipient) dictionary attacks.

This program uses a SQL database to keep track of ip addresses sending
spam. IP addresses which reach a threshold for unknown RCPT probes
will be blocked. Three different methods of blocking are supported:
kernel iptables, routing to a non-existant IP address, and using
sendmail's access file.

=head1 AUTHOR

Copyright (c) Kevin Rosenberg.
All rights reserved.

=head1 LICENSE

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.

3. The names of the authors and contributors may not be used to endorse
or promote products derived from this software without specific prior
written permission.
 
THIS SOFTWARE IS PROVIDED BY THE AUTHORS 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 AUTHORS 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.

=cut

package BlockRumple;
use strict;
use Data::Dumper;
use Getopt::Long;
use File::Basename;
use DBI;
use Fcntl ':flock';

use constant METHOD_ROUTE => 1;
use constant METHOD_IPTABLES => 2;
use constant METHOD_ACCESS => 3;

# The values of these variables can be overridden in the configuration file

our $method = METHOD_ROUTE;
our $dbname="smtpblock";                      # name of mysql database
our $dbhost="localhost";                      # database host
our $dbuser="db_user";                        # database username
our $dbpass="db_passwd";                      # database password
our @my_ips = ('192.168.1.1');                # replace with your ip addresses
our $unblock_time = 60 * 60 * 24 * 30;        # thirty days
our $sendmail_log="/var/log/mail/mail.info";  # location on Debian system
our $access_file = "/etc/mail/access";        # location on Debian system
our $rcpt_threshold = 4;  # number of unknown rcpt per process before blacklist
our $log_lines = 2000;    # number of tail lines of sendmail log to examine
our $unblock_per_run = 3; # Only unblock this many per run    
our $blackhole_addr = '192.168.1.254';   # a non-existant address on your local network
our @base_access_file = ("127.0.0.1 RELAY","localhost RELAY","localhost.localdomain RELAY");
our $config_file = "$ENV{'HOME'}/etc/blockrumple.conf";  

sub usage
{
    print STDERR "Usage: " . basename($0) . " [OPTIONS]\n";
    print STDERR "OPTIONS\n";
    print STDERR "  --help           print this help\n";
    print STDERR "  --debug          debugging messages\n";
    print STDERR "  --verbose        verbose messages\n";
    print STDERR "  --quiet          minimal output\n";
    print STDERR "  --create         Create SQL tables\n";
    print STDERR "  --clear          Clear SQL tables and firewall\n";
    print STDERR "  --add-missing    update blocking method with missing addresses. Useful at startup.\n";
    print STDERR "  --method <meth>  blocking method. Valid: route, iptables, access\n";
    print STDERR "  --config <file>  Use specified file as configuration file\n";
    print STDERR "  --threshold <n>  this many or more unknown RCPT will block address\n";
    print STDERR "  --lines <n>      number of lines back in log file to process\n";
    exit(1); 
}

my ($opt_help,$opt_create,$opt_clear,$opt_debug,$opt_threshold,
    $opt_add_missing,$opt_unblock_per_run,
    $opt_lines,$opt_config,$opt_verbose,$opt_quiet,$opt_method);

usage() unless GetOptions ('help' => \$opt_help,
			   'debug' => \$opt_debug,
			   'verbose' => \$opt_verbose,
			   'quiet' => \$opt_quiet,
			   'create' => \$opt_create,
			   'clear' => \$opt_clear,
			   'add-missing' => \$opt_add_missing,
			   'method=s' => \$opt_method,
			   'config=s' => \$opt_config,
			   'lines=i' => \$opt_lines,
			   'unblock-per-run' => \$opt_unblock_per_run,
			   'threshold=i' => \$opt_threshold);

usage() if $opt_help;

$config_file = $opt_config if $opt_config;

do ($config_file) if -e $config_file;

$rcpt_threshold = $opt_threshold if $opt_threshold;
$log_lines = $opt_lines if $opt_lines;
$unblock_per_run = $opt_unblock_per_run if $opt_unblock_per_run;

if ($opt_method =~ /^route$/i) {
    $method = METHOD_ROUTE;
} elsif ($opt_method =~ /^iptables$/i) {
    $method = METHOD_IPTABLES;
} elsif ($opt_method =~ /^access$/i) {
    $method = METHOD_ACCESS;
} elsif ($opt_method) {
    usage();
}

my @recent=`tail -$log_lines $sendmail_log`;

our $dbh = DBI->connect("DBI:mysql:$dbname;host=$dbhost",
			$dbuser, $dbpass, {AutoCommit => 1});
    die "Can't get dbh" unless $dbh;

if ($opt_clear) {
    clear_sql_and_fw();
    exit(0);
}

create_tables() if $opt_create;

my ($blocked,$unblocked) = load_sql_state();
print "Blocked\n",Dumper($blocked),"Unblocked\n",Dumper($unblocked)
    if $opt_debug;

my $pids = get_pids_unknown_rcpt();
print "PID's\n",Dumper($pids) if $opt_debug;

my $added = add_to_blocked($pids,$blocked);
insert_new_blocked($added,$blocked,$unblocked);
unblock_old_entries();
display_blocked() unless $opt_quiet;
add_missing() if $opt_add_missing;
update_access_file() if ($method == METHOD_ACCESS);

exit(0);

sub get_pids_unknown_rcpt
{
    my %pids = ();
    foreach my $e (@recent) {
	if ($e =~ m!^.+sm-mta\[(\d+)\]: (\S+): .* User unknown$!) {
	    my ($pid,$id) = ($1,$2);
	    next unless $pid && $id;
	    my $key = "$pid:$id";
	    if ($pids{$key}) {
		$pids{$key}++;
	    } else {
		$pids{$key} = 1;
	    }
	}
    }
    return \%pids;
}

sub insert_new_blocked
{
    my ($added,$blocked,$unblocked) = @_;
    my @ip_a = keys(%$added);

    unless ($opt_quiet) {
	print scalar(@ip_a) ? scalar(@ip_a) : "No";
	print " new addresses blocked.\n";
    }

    foreach my $ip (@ip_a) {
	next if ($ip eq '127.0.0.1') || grep { $_ eq $ip } @my_ips;
	print "  $ip\n" unless $opt_quiet;
	block_addr($ip);
	if ($unblocked->{$ip}) {
	    $dbh->do("UPDATE blocked SET timechanged=UNIX_TIMESTAMP(),rcptcount=rcptcount+$added->{$ip},blockedcount=blockedcount+1 WHERE address='$ip'");
	} else {
	    $dbh->do("INSERT INTO blocked (address,timechanged,rcptcount,blocked,blockedcount,timecreated) VALUES ('$ip',UNIX_TIMESTAMP(),$added->{$ip},1,1,UNIX_TIMESTAMP())");
	}
    }
}

sub display_blocked
{
    my $sth = $dbh->prepare("SELECT count(address) FROM blocked WHERE blocked='1'");
    $sth->execute();
    my ($count) = $sth->fetchrow_array();
    print "$count addresses blocked.\n";
}

sub add_missing
{
    my %blocked=();
    my $sth=$dbh->prepare("SELECT address FROM blocked WHERE blocked='1'");
    $sth->execute();
    while (my ($address) = $sth->fetchrow_array) {
	$blocked{$address} = 1;
    }

    my %already_blocked;
    if ($method == METHOD_IPTABLES) {
	my %iptables = ();
	foreach my $blocked (`iptables -L INPUT -n`) {
	    next unless $blocked =~ m/^DROP\s+tcp\s+\S+\s+([\d\.]+)\s+.*/;
	    $already_blocked{$1} = 1;
	}
    } elsif ($method == METHOD_ROUTE) {
	my %route = ();
	foreach my $blocked (`netstat -rn`) {
	    next unless $blocked =~ m/^([\d\.]+)\s+$blackhole_addr\s+/;
	    my $dest = $1;
	    $already_blocked{$1} = 1;
	}
    }

    foreach my $ip (keys %blocked) {
	unless ($already_blocked{$ip}) {
	    block_addr($ip);
	    print "add missing: $ip\n" if $opt_debug;
	}
    }
}

sub create_tables 
{
    $dbh->do("DROP TABLE IF EXISTS blocked");
    $dbh->do("CREATE TABLE blocked (address CHAR(16),timechanged INT UNSIGNED,rcptcount MEDIUMINT UNSIGNED,blocked TINYINT,blockedcount MEDIUMINT UNSIGNED,timecreated INT UNSIGNED,PRIMARY KEY (address), INDEX (timechanged),INDEX (blocked))");
}

sub clear_sql_and_fw
{
    $dbh->do("DROP TABLE IF EXISTS blocked");
}

sub unblock_old_entries
{
    my $sth = $dbh->prepare("SELECT address,blockedcount FROM blocked WHERE timechanged<UNIX_TIMESTAMP()-($unblock_time*blockedcount) LIMIT $unblock_per_run");
    $sth->execute();

    my @addlist = ();
    while (my ($address,$blockcount) = $sth->fetchrow_array) {
	unshift @addlist,$address;
	unblock_addr($address);
    }

    if (@addlist) {
	my $sth = $dbh->prepare("UPDATE blocked SET blocked=0,timechanged=UNIX_TIMESTAMP() WHERE address IN ('" . join("','",@addlist) . "')");
	$sth->execute();
    }

    unless ($opt_quiet) {
	print scalar(@addlist) ? scalar(@addlist) : "No";
	print " old addresses unblocked";
	print scalar(@addlist) ? " (older than $unblock_time seconds).\n" : ".\n";
    }
}
sub load_sql_state
{
    my %blocked=();
    my %unblocked=();

    my $sth=$dbh->prepare("SELECT address FROM blocked WHERE blocked='1'");
    $sth->execute();
    while (my ($address) = $sth->fetchrow_array) {
	$blocked{$address} = 1;
    }
    
    my $sth=$dbh->prepare("SELECT address FROM blocked WHERE blocked='0'");
    $sth->execute();
    while (my ($address) = $sth->fetchrow_array) {
	$unblocked{$address} = 1;
    }
    
    return (\%blocked,\%unblocked);
}

sub add_to_blocked
{
    my ($pids,$blocked) = @_;
    my %added = ();
    foreach my $e (@recent) {
	if ($e =~ m!^.+sm-mta\[(\d+)\]: (\S+): from=.*\[([\d\.]+)\].*$!) {
	    my ($pid,$id,$ip)=($1,$2,$3);
	    my $key = "$pid:$id";
	    next unless $pid && $ip && $pids->{$key} >= $rcpt_threshold;
	    print "$pid | $ip | $pids->{$pid} | $e\n" if $opt_debug;
	    if (($opt_debug || $opt_verbose) && $blocked->{$ip}) {
		print "received $pids->{$key} messages from blocked address $ip\n";
	    } elsif (! $blocked->{$ip}) {
		$added{$ip} = $pids->{$key};
	    }
	    $pids->{$key} = undef;
	} elsif ($e =~ m!^.+sm-mta\[(\d+)\]: (\S+): lost input channel from \[([\d\.]+)\] to MTA after rcpt!) {
	    my ($pid,$id,$ip) = ($1,$2,$3);
	    my $key = "$pid:$id";
	    next unless $pid && $ip && $pids->{$key} >= $rcpt_threshold;
	    if ($opt_debug && $blocked->{$ip}) {
		print "received $pids->{$key} messages from blocked address $ip\n";
	    } elsif (! $blocked->{$ip}) {
		$added{$ip} = $pids->{$key};
	    }
	print "$ip - $e\n" if $opt_debug;
	    $pids->{$key} = undef;
	}
    }

    return \%added;
}

sub block_addr
{
    my $ip = shift;
    if ($method == METHOD_IPTABLES) {
	`iptables -A INPUT -p tcp --syn --dport 25 --source $ip -j DROP`;
    } elsif ($method == METHOD_ROUTE) {
	`route add -host $ip gw $blackhole_addr`;
    }
}

sub unblock_addr
{
    my $ip = shift;
    if ($method == METHOD_IPTABLES) {
	`iptables -D INPUT -p tcp --syn --dport 25 --source $ip -j DROP`;
    } elsif ($method == METHOD_ROUTE) {
	`route del -host $ip gw $blackhole_addr`;
    }
}

sub update_access_file
{
    open (ACCESS,">$access_file") || die "Can't open $access_file for writing";
    flock(ACCESS,LOCK_EX);

    foreach my $l (@base_access_file) {
	print ACCESS "$l\n";
    }

    foreach my $ip (@my_ips) {
	print ACCESS "$ip RELAY\n";
    }

    my $sth=$dbh->prepare("SELECT address FROM blocked WHERE blocked='1' " .
			  "ORDER BY address ASC");
    $sth->execute();
    while (my ($address) = $sth->fetchrow_array) {
	printf ACCESS "%-20s REJECT\n",$address;
    }
    flock(ACCESS,LOCK_UN);
    close(ACCESS);
    
    chdir("/etc/mail");
    `make > /dev/null 2>&1`;
    `/etc/init.d/sendmail reload`;
}




Please send sugestions and comments to webmaster@nmlug.org.
Valid XHTML 1.1! Valid CSS! Powered by Debian Powered by Apache