#!/usr/local/bin/perl ############################################################################# # A script to monitor /var/log/messages for sendmail logs looking for # "User unknown" logs, and then using data from Linux's /proc/net/tcp # and /proc//fd/* tie those logs to the remote IP address that is # responsible for them, so we can firewall it, etc. at certain thresholds. # # This code works only on Linux kernels version 2.4.x and 2.6.x. # # Authored 2005-02-23 by Lester Hightower # * 20050308: added kernel 2.6.x support # added iptables rules to firewall offenders # * 20050331: some cleanup in preparation for submitting to CPAN # added DoGetOpts() for some command-line changeable globals # added POD ############################################################################# require 5.000; use strict; use File::Tail; use FileHandle; use Time::HiRes; use Net::Subnets; use Getopt::Long; use constant CVS_VERSION => scalar '$Revision: 1.11 $'; # Command-line switchable globals our $DEBUG=3; our $DEBUG_TO_SYSLOG=0; our $WHITE_LIST = ""; our $SYSLOG_MSGS_FILE = "/var/log/messages"; our $MAILER_IP = ""; our $MAILER_PORT = "25"; our $MAILER_SYSLOG_HOST="mail1"; our $MAILER_SYSLOG_NAME="sm-mta"; our $IPTABLES_CHAIN_NAME="SPAM_UNAME_GUESSERS"; # Global variables that are static my $MY_MAX_RUNTIME = 3 * 24 * 60 * 60; # Maximum num of seconds before exit my $MAX_RUNTIME_SENDMAIL_CMD_WAIT = 15*60; # Max # of secs my $MAX_OFFENSES_BEFORE_FIREWALLED = 3; # Number of offenses before firewall my $MAX_BLOCKED_TIME = 60 * 60 * 3; # Amount of time to firewall offenders my $MAX_SYSLOG_READ_DELAY = 10; # If you have lots of servers with different binary paths, you might # consider changing these _EXE vars to "/usr/bin/env " my $FIND_EXE="/usr/bin/find"; my $IPTABLES_EXE="/usr/sbin/iptables"; my $LOGGER_EXE="/usr/bin/logger"; # my $IDLE_SLEEP_INTERVAL=0; # secs, can be a float; 0 to use File::Tail # This data structure manages routines that we run occassionally to # perform cleanup work for us. my %PERIODIC_CLEANSERS = ( 'UnblockOldOffenders' => { 'frequency' => 10 * 60, # 10 minutes }, 'KillSendmailsInCmdWait' => { 'frequency' => 10 * 60, # 10 minutes }, ); # Log a "hello world" message... &Dbg(0, "Starting software, " . CVS_VERSION); my $MY_START_TIME = time; $|=1; # Parse the command line options &DoGetOpts; # Initialize the iptables rules/chain that we need &InitIPTablesRules(); # Load the white-list; we get back a Net::Subnets object. my $white_list = &LoadWhiteList($WHITE_LIST); # Open the syslog messages file with the very cool File::Tail module my $syslog = File::Tail->new(name => $SYSLOG_MSGS_FILE, nowait => 1, maxinterval => 90, adjustafter => 7, tail => 100); $syslog->nowait(1); my $line=''; my %OffendersPID=(); my %OffendersIP=(); MAIN_LOOP: while (1) { my $new_lines_matched = 0; while (defined($line=$syslog->read) && length($line)) { $syslog->nowait(1); my $pattern = " [0-9]{2}:[0-9]{2}:[0-9]{2} " . # Time "$MAILER_SYSLOG_HOST " . "$MAILER_SYSLOG_NAME\Q[\E([0-9]+)\Q]\E: " . ".+\.\.\. User unknown" . ""; if ($line =~ m/$pattern/) { $new_lines_matched++; my $pid=$1; $OffendersPID{$pid}{count}++; $OffendersPID{$pid}{last_offense}=time; } } if ($new_lines_matched < 1) { if ($IDLE_SLEEP_INTERVAL > 0) { &Dbg(6,"Sleeping."); sleep $IDLE_SLEEP_INTERVAL; &Time::HiRes::sleep($IDLE_SLEEP_INTERVAL); } else { $syslog->nowait(0); &Dbg(6,"Going idle with \$syslog->nowait(0)"); } next MAIN_LOOP; } my @offending_pids = sort keys %OffendersPID; my $rSocketInodesPerPID = &get_socket_inodes(); my $rProcNetTCP = &get_proc_net_tcp(); OFFENDING_PID: foreach my $pid (@offending_pids) { # Now, use the %OffendersPID data to make %OffendersIP data. # Note, if you use something greater than zero in the following # if statement, then only PIDs that offend more then N times get # pushed into %OffendersIP data. my $offense_count = $OffendersPID{$pid}{count}; if ($offense_count > 0) { &Dbg(4,"PID $pid has $offense_count offenses"); #warn "LHHS: " . ref($rSocketInodesPerPID->{$pid}) . "\n"; if (ref($rSocketInodesPerPID->{$pid}) eq 'ARRAY') { my @socket_inodes = @{$rSocketInodesPerPID->{$pid}}; foreach my $inode (@socket_inodes) { if (! defined($rProcNetTCP->{$inode})) { next; } my $loc_ip = $rProcNetTCP->{$inode}->{loc_ip}; my $loc_port = $rProcNetTCP->{$inode}->{loc_port}; my $rem_ip = $rProcNetTCP->{$inode}->{rem_ip}; # If the remote IP is hooked to $MAILER_IP:$MAILER_PORT it offends if ($loc_ip eq $MAILER_IP && $loc_port == $MAILER_PORT) { $OffendersIP{$rem_ip}{PIDs}{$pid}=$offense_count; # Debugging message my $loc_ip_port = $rProcNetTCP->{$inode}->{loc_ip_port}; my $rem_ip_port = $rProcNetTCP->{$inode}->{rem_ip_port}; &Dbg(8,"Hooked up: me=$loc_ip_port -> them=$rem_ip_port"); } else { &Dbg(8,"$loc_ip eq $MAILER_IP && $loc_port == $MAILER_PORT"); } } } else { # Remove the PID from %OffendersPID if it has no open sockets if (defined($OffendersPID{$pid})) { delete $OffendersPID{$pid}; } # If we have no socket inodes for this PID, then it really # can't still be running, but we'll still be paranoid and warn. if ( -d "/proc/$pid" ) { &Dbg(1,"It appears that PID $pid is still running, but I see " . "no get_socket_inodes() data for it!"); } } } } my @current_offenders = sort keys %OffendersPID; &Dbg(6,"Currently offending PIDs: " . join(", ", @current_offenders)); # Go through the %OffendersIP data and update data like the # last_offense and total number of offenses. foreach my $rem_ip (sort keys %OffendersIP) { my $total_offenses=0; foreach my $pid (sort keys %{$OffendersIP{$rem_ip}{PIDs}}) { my $rPIDS=$OffendersIP{$rem_ip}{PIDs}; $total_offenses += $rPIDS->{$pid}; # If the PID is still an active offender, consider his data if (defined($OffendersPID{$pid})) { my $pid_last_off = $OffendersPID{$pid}{last_offense}; my $ip_last_off = $OffendersIP{$rem_ip}{last_offense}; if ($pid_last_off > $ip_last_off) { $OffendersIP{$rem_ip}{last_offense} = $pid_last_off; } } } $OffendersIP{$rem_ip}{offenses} = $total_offenses; } # Report on the %OffendersIP. Here is where we might install # firewall rules, etc. -- TODO foreach my $rem_ip (sort { $OffendersIP{$a}{offenses} <=> $OffendersIP{$b}{offenses} } keys %OffendersIP) { my $total_offenses = $OffendersIP{$rem_ip}{offenses}; my $ip_last_off = $OffendersIP{$rem_ip}{last_offense}; &Dbg(5,"IP=$rem_ip has $total_offenses offenses, the " . "latest at " . &GetYYYYMMDDHHMMSS_Pretty($ip_last_off)); my $subnetref = $white_list->check(\$rem_ip); if ($total_offenses > $MAX_OFFENSES_BEFORE_FIREWALLED && (ref($subnetref) ne 'SCALAR') && (! ($OffendersIP{$rem_ip}{firewalled})) ) { $OffendersIP{$rem_ip}{firewalled} = &GetYYYYMMDDHHMMSS(); #for my $jt (qw(LOG DROP)) { for my $jt (qw(DROP)) { my $cmd="$IPTABLES_EXE -A '$IPTABLES_CHAIN_NAME' -p tcp -s '$rem_ip' " . "--destination-port 25 -j '$jt'"; $OffendersIP{$rem_ip}{firewall_cmd} = $cmd; &Dbg(1,"FIREWALLING: $cmd"); my $exit_code = &SystemWithWarnOnNonZero($cmd); } # Kill and/all sendmail children that were talking to this offender foreach my $pid (sort keys %{$OffendersIP{$rem_ip}{PIDs}}) { # If the PID is still an active offender, consider his data if (defined($OffendersPID{$pid}) && -e "/proc/$pid") { &Dbg(1, "KILL -15ing SENDMAIL PID=$pid, related to IP=$rem_ip"); kill 15, $pid; # Now, sleep half a second, and it PID still exists, -9 it &Time::HiRes::sleep(0.5); if (-e "/proc/$pid") { &Dbg(1, "KILL -9ing SENDMAIL PID=$pid, related to IP=$rem_ip"); kill 9, $pid; } } } } elsif ($subnetref) { &Dbg(1,"IGNORING WHITE-LISTED OFFENDER $rem_ip from $$subnetref"); # Remove the white-listed offender from %OffendersIP delete($OffendersIP{$rem_ip}); } } # Unblock any offenders that have been blocked longer than $MAX_BLOCKED_TIME my $last_run = $PERIODIC_CLEANSERS{'UnblockOldOffenders'}->{'lastrun'}; my $frequency = $PERIODIC_CLEANSERS{'UnblockOldOffenders'}->{'frequency'}; if ( $last_run < (time - $frequency) ) { $PERIODIC_CLEANSERS{'UnblockOldOffenders'}->{'lastrun'} = time; &UnblockOldOffenders(\%OffendersIP); } # Kill any sendmail children stuck in "cmd wait" and which have been # running longer then $MAX_RUNTIME_SENDMAIL_CMD_WAIT seconds. my $last_run = $PERIODIC_CLEANSERS{'KillSendmailsInCmdWait'}->{'lastrun'}; my $frequency = $PERIODIC_CLEANSERS{'KillSendmailsInCmdWait'}->{'frequency'}; if ( $last_run < (time - $frequency) ) { $PERIODIC_CLEANSERS{'KillSendmailsInCmdWait'}->{'lastrun'} = time; &KillSendmailsInCmdWait($MAX_RUNTIME_SENDMAIL_CMD_WAIT); } # If I have a $MY_MAX_RUNTIME and have surpassed it, exit. if ($MY_MAX_RUNTIME > 0 && ((time-$MY_MAX_RUNTIME) > $MY_START_TIME) ) { &Dbg(0, "Exiting: I exceeded my MAX_RUNTIME of $MY_MAX_RUNTIME secs"); exit; } } # End MAIN_LOOP: sub UnblockOldOffenders { my $rOffendersIP = shift @_; my $newest_to_free = &GetYYYYMMDDHHMMSS(time - $MAX_BLOCKED_TIME); FREEDOM_LOOP: foreach my $rem_ip (sort { $rOffendersIP->{$a}{firewalled} <=> $rOffendersIP->{$b}{firewalled} } keys %{$rOffendersIP}) { # Store the time this $rem_ip was firewalled in a convenience variable. my $firewalled=$rOffendersIP->{$rem_ip}{firewalled}; # We are looping over all offenders here, not just firewalled ones, # so we need to skip right past the ones not firewalled. if (! $firewalled) { next FREEDOM_LOOP; } # These are sorted, so as soon as we don't match one, we can # last this loop... if ($firewalled < $newest_to_free) { my $cmd = $rOffendersIP->{$rem_ip}{firewall_cmd}; # Convert the add command to a delete command if ($cmd =~ s/\s+-A\s+/ -D /) { &Dbg(1,"Unblocking offender $rem_ip, which was blocked $firewalled"); my $exit_code = &SystemWithWarnOnNonZero($cmd); if ($exit_code) { &Dbg(0, "ERROR: UnblockOldOffenders(): my attempt to unblock " . "$rem_ip with $IPTABLES_EXE failed to return zero. The " . "command that I ran was: $cmd"); } else { # If we unblocked this IP, get it out of %OffendersIP delete($rOffendersIP->{$rem_ip}); } } else { &Dbg(0,"ERROR: UnblockOldOffenders(): can't derive the firewall_cmd " . "needed to unblock $rem_ip!"); } } else { last FREEDOM_LOOP; } } } # This function pulls all of the "sockets" from /proc//dev/null'; # We need to know the major kernel version, as the /proc format differs # per kernel. We assume 2.4 if we fail to ID the kernel. my $mjr_krnl_ver='2.4'; my $fh_ver = new FileHandle; if (open($fh_ver, "< /proc/version")) { my $ver_line = <$fh_ver>; close $fh_ver; if ($ver_line =~ m/^Linux version ([0-9]+\.[0-9]+)\./i) { $mjr_krnl_ver = $1; } } # Regex patterns to find socket IDs per major Linux kernel version my %ptrn = ( '2.4' => "/proc/([0-9]+)/fd/[0-9]+\tsocket:\Q[\E([0-9]+)\Q]\E", '2.6' => "/proc/([0-9]+)/task/[0-9]+/fd/[0-9]+\tsocket:\Q[\E([0-9]+)\Q]\E" ); my $pipe=new FileHandle; if (open($pipe, "$cmd |")) { my @lines = <$pipe>; close($pipe); my %results = (); foreach my $line (@lines) { if ($line =~ m%$ptrn{$mjr_krnl_ver}%) { my $pid=$1; my $inode=$2; push (@{$results{$pid}}, $inode); } else { &Dbg(0, "get_socket_inodes(): pattern match failed."); } } return \%results; } else { &Dbg(0, "Error in get_socket_inodes(): command failed: $cmd"); } return undef; } # This function converts the IP:PORT encoding, as found in Linux's # /proc/net/tcp, into x.x.x.x:port notation. sub convert_kernel_ip_port_to_dot_notation { my $addr=shift @_; my $pattern = "([0-9A-Z]{2})([0-9A-Z]{2})([0-9A-Z]{2})([0-9A-Z]{2})" . ":([0-9A-Z]{2})([0-9A-Z]{2})"; if ($addr =~ m/^$pattern$/) { my @octets_hex=($4,$3,$2,$1); my @port_hex=($5,$6); my @octets=map { hex($_) } @octets_hex; my $dot_ip=join('.', @octets); my $port = hex(join('', @port_hex)); return "$dot_ip:$port"; } return undef; } # This function pulls the data from /proc/net/tcp and returns the # reference to a hash that is keyed on the inode field, which is # unique to each socket, and holds a hash with the local IP:port # and remote IP:port of the connection. sub get_proc_net_tcp { my $fh=new FileHandle; if (open($fh, "< /proc/net/tcp")) { my @lines=<$fh>; close $fh; my %result=(); my $hdr_line = shift @lines; my @hdrs=split(/\s+/, trim($hdr_line)); DATA_LINE: foreach my $line (@lines) { my @data = split(/\s+/, trim($line)); my $inode=$data[9]; my $loc_ip_port=convert_kernel_ip_port_to_dot_notation($data[1]); my $rem_ip_port=convert_kernel_ip_port_to_dot_notation($data[2]); if ($inode > 0 && length($rem_ip_port) && $rem_ip_port !~ m/^127.0.0.1/) { #warn "LHHD: $inode: $loc_ip_port\t$rem_ip_port\n"; my ($loc_ip, $loc_port) = split(/:/, $loc_ip_port, 2); my ($rem_ip, $rem_port) = split(/:/, $rem_ip_port, 2); %{$result{$inode}} = ( loc_ip_port => $loc_ip_port, loc_ip => $loc_ip, loc_port => $loc_port, rem_ip_port => $rem_ip_port, rem_ip => $rem_ip, rem_port => $rem_port ); } } return \%result; } return undef; } # Trim whitespace off the head and tail of a string sub trim { my $str=shift @_; $str =~ s/^[\s\r\n]+//; $str =~ s/[\s\r\n]+$//; return $str; } # This returns a raw integer sub GetYYYYMMDDHHMMSS { my $unix_time=shift @_ || time; my ($sec,$min,$hour,$mday,$mon,$yr,$wday,$yday,$isdst)=localtime($unix_time); my $formatted=sprintf("%04d%02d%02d%02d%02d%02d", 1900 + $yr, $mon + 1, $mday, $hour, $min, $sec); return($formatted); } # This returns a Mysql-ish format sub GetYYYYMMDDHHMMSS_Pretty { my $unix_time=shift @_ || time; my ($sec,$min,$hour,$mday,$mon,$yr,$wday,$yday,$isdst)=localtime($unix_time); my $formatted=sprintf("%04d-%02d-%02d %02d:%02d:%02d", 1900 + $yr, $mon + 1, $mday, $hour, $min, $sec); return($formatted); } sub InitIPTablesRules { # See if chain $IPTABLES_CHAIN_NAME exists, and if not create it, and # if so flush the existing one. my $cmd = "$IPTABLES_EXE -n -L '$IPTABLES_CHAIN_NAME'"; my $exit_code = &SystemWithWarnOnNonZero($cmd); if ($exit_code) { &Dbg(1, "The $IPTABLES_CHAIN_NAME chain needs to be created."); my $cmd = "$IPTABLES_EXE -N '$IPTABLES_CHAIN_NAME'"; my $retval = system("$cmd 1>/dev/null 2>/dev/null"); my $exit_code = &SystemWithWarnOnNonZero($cmd); } else { &Dbg(1, "The $IPTABLES_CHAIN_NAME chain needs to be flushed."); my $cmd = "$IPTABLES_EXE -F '$IPTABLES_CHAIN_NAME'"; my $exit_code = &SystemWithWarnOnNonZero($cmd); } my $RULE_SPEC = "INPUT -p tcp --destination-port 25 " . "-j '$IPTABLES_CHAIN_NAME'"; # Purge any/all jump rules to $IPTABLES_CHAIN_NAME my $MAX_LOOPS=10; my $exit_code=0; do { my $cmd="$IPTABLES_EXE -D $RULE_SPEC"; $exit_code = &SystemWithWarnOnNonZero($cmd); $MAX_LOOPS--; } until ($exit_code || $MAX_LOOPS < 1); if ($MAX_LOOPS < 1) { die "Bad shit happened. I couldn't purge rulespec: $RULE_SPEC\n"; } # Add a jump rule to the chain $IPTABLES_CHAIN_NAME my $cmd="$IPTABLES_EXE -A $RULE_SPEC"; my $exit_code = &SystemWithWarnOnNonZero($cmd); } sub SystemWithWarnOnNonZero { my $cmd=shift @_; my $retval = system("$cmd 1>/dev/null 2>/dev/null"); my $exit_code = $retval / 256; if ($exit_code) { &Dbg(1, "$cmd exited with $exit_code"); } return $exit_code; } sub LoadWhiteList { my $while_list_file = shift @_; my $fh=new FileHandle; if (open($fh, "< $while_list_file")) { my @subnets = <$fh>; close($fh); @subnets = map { chomp $_; &trim($_); } @subnets; @subnets = grep(!/^#|^$/, @subnets); my $p_sn='^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\/[0-9]{1,2}$'; my @bad_subnets = grep(!/$p_sn/, @subnets); if (scalar(@bad_subnets) > 0) { &Dbg(0, "ERROR: The whilelist file, $while_list_file, holds subnet " . "entries that are not valid: " . join(', ', @bad_subnets)); } @subnets = grep(/$p_sn/, @subnets); my $sn = Net::Subnets->new; $sn->subnets( \@subnets ); &Dbg(1,"Whitelist: " . join(',', @subnets)); return $sn; } else { die "Failed to load white list file: $while_list_file\n"; } } ############################################################################ # KillSendmailsInCmdWait() kills any sendmail children that are in the # "cmd read" status that have been running longer than $max_runtime secs. ############################################################################ sub KillSendmailsInCmdWait { my $max_runtime = shift @_; my $cmd="/usr/bin/env ps f -w --no-headers -C sendmail O+T " . "--format '%p ~ %t ~ %a'"; my @ps_lines = `$cmd 2>/dev/null`; my %RunningSendmailChildren = (); my %PIDsToKill = (); foreach my $line (@ps_lines) { my ($pid, $run_time, $cmdline) = split(/\s+~\s+/, $line, 3); $pid = &trim($pid); $cmdline = &trim($cmdline); my $ip_addr=''; # We only want sendmail children, not top-level sendmail processes, # this the " \_" part, and then we only want ones in "cmd read" status if ($cmdline=~m/^\\_ sendmail: server (([^[]+) )?\[([0-9.]+)\] cmd read/) { $ip_addr=$3; my $run_time_secs=0; if ($run_time =~ m/^([0-9]{2})?:?([0-9]{2}):([0-9]{2})/) { my $hrs=$1 || 0; my $min=$2 || 0; my $sec=$3 || 0; $run_time_secs = $hrs*60*60 + $min*60 + $sec; } #warn "I have a match on pid=$pid/$ip_addr, run_time=$run_time_secs\n"; if ($run_time_secs > $max_runtime) { $PIDsToKill{$pid}{ip_addr} = $ip_addr; $PIDsToKill{$pid}{cmdline} = $cmdline; $PIDsToKill{$pid}{run_time} = $run_time; } } } foreach my $pid (sort keys %PIDsToKill) { my $rt=$PIDsToKill{$pid}{run_time}; my $cl=$PIDsToKill{$pid}{cmdline}; my $ip=$PIDsToKill{$pid}{ip_addr}; &Dbg(1,"KillSendmailsInCmdWait(): killing $pid, runtime=$rt, " . "ip_addr=$ip, cmdline=$cl"); if (-e "/proc/$pid") { &Dbg(1, "KILL -15ing SENDMAIL PID=$pid, related to IP=$ip"); kill 15, $pid; # Now, sleep half a second, and it PID still exists, -9 it &Time::HiRes::sleep(0.5); if (-e "/proc/$pid") { &Dbg(1, "KILL -9ing SENDMAIL PID=$pid, related to IP=$ip"); kill 9, $pid; } } } my $kill_count = scalar(keys %PIDsToKill); return $kill_count; } # Subroutine to handle debug/syslog-ing sub Dbg { my $level=shift @_; my $msg = shift @_; # If the $DEBUG level exceeds the level at which we log this message, then... if ($DEBUG > $level) { $msg =~ s/[\r\n]+$//; if ($DEBUG_TO_SYSLOG) { my $Facility='user'; my $Priority='info'; my $Tag="SPAM-FW[$$]"; $msg =~ s/[\r\n]+/ /g; my @args=($LOGGER_EXE,'-p',"$Facility.$Priority",'-t',$Tag,$msg); system @args; } else { print "$msg\n"; } } } # # This function parses the command-line options. sub DoGetOpts { my %opts; my $result = &GetOptions(\%opts, 'debug=i', 'debug-to-syslog', 'white-list=s', 'syslog-msgs-file=s', 'mailer-ip=s', 'mailer-port=s', 'mailer-syslog-host=s', 'mailer-syslog-name=s', 'iptables-chain-name=s', ); # If GetOptions() ain't happy, we ain't happy if (! $result) { exit; } # Loop over global config options, assigning them using perl # ref-by-name magic. foreach my $opt (keys %opts) { no strict 'refs'; my $glbl_var=uc($opt); $glbl_var =~ s/-/_/g; if (! defined(${$glbl_var})) { die "DoGetOpts() cannot set non-existant global variable \$$glbl_var!\n"; } else { ${$glbl_var} = $opts{$opt}; } } # Validate values if ($DEBUG !~ m/^[0-9]$/) { die "--debug must be between 0 and 9.\n"; } if ($DEBUG_TO_SYSLOG !~ m/^[01]$/) { die "--debug-to-syslog must be 0 or 1.\n"; } if (! -r $WHITE_LIST) { die "--white-list must refer to a readable text file.\n"; } if (! -r $SYSLOG_MSGS_FILE) { die "--syslog-msgs-file must refer to a readable text file.\n" . "\t(normally /var/log/messages, or similar)\n"; } if ($MAILER_IP !~ m/^(\d{1,3}\.){3}\d{1,3}$/) { die "--mailer-ip must be a valid IP address.\n"; } if ($MAILER_PORT !~ m/^[0-9]+$/ || $MAILER_PORT > 65536) { die "--mailer-port must be a valid IP port.\n"; } if (! length($MAILER_SYSLOG_HOST)) { die "--mailer-syslog-host needs the hostname your mailer sylogs as.\n"; } if (! length($MAILER_SYSLOG_NAME)) { die "--mailer-syslog-name needs the tag that your mailer sylogs with.\n"; } if ($IPTABLES_CHAIN_NAME !~ m/^[0-9a-z_]+$/i) { die "--iptables-chain-name needs the name of the iptables chain that\n" . "\tyou would like this program to manage.\n"; } } __END__ # Man Page Stuff ############################################################## =head1 NAME spam.kill_uname_guessers.pl =head1 SYNOPSIS A daemon to detect spammers trying to harvest email addresses by username guessing and temporarily DROP them with iptables firewall rules. =head1 DESCRIPTION OF USAGE Author yourself a script like this, substituting values that make sense for your network: #!/bin/bash # Mainly because init restricts to short command lines. EXE="/etc/mail/spam.kill_uname_guessers.pl" if [ -x "$EXE" ]; then exec "$EXE" --debug=3 --debug-to-syslog \ --white-list=/etc/mail/spam.kill_uname_guessers.whitelist \ --syslog-msgs-file=/var/log/messages \ --mailer-ip=10.100.10.200 --mailer-port=25 \ --mailer-syslog-host=mail1 --mailer-syslog-name=sm-mta \ --iptables-chain-name=SPAM_UNAME_GUESSERS fi The white-list file holds one-entry-per line with comments marked by a pound sign (#) in column zero, where each entry is a network address in the format 10.10.10.10/24. The mailer-ip and mailer-port options represent the IP address and port (should always be 25) that *external* hosts connect to in order to deliver email to this server (for your network). The mailer-syslog-host and mailer-syslog-name options, respectively, are the hostname and tag that sendmail logs its messages with on this server. The iptables-chain-name option is what this program is to name the iptables table in which this script will place its DROP rules. And then add a line to your /etc/inittab file like this: # A program working to reduce spam by firewalling username guessers SMFW:3:respawn:/etc/mail/spam.kill_uname_guessers.10east.sh And then execute "init q" ... =head1 OTHER FEATURES This program also looks for and kills sendmail processes that have been stuck in "cmd read" mode for a long time. The definition of a "long time" is controlled by the global variable $MAX_RUNTIME_SENDMAIL_CMD_WAIT which represents the number of seconds since a given sendmail process, that is stuck on "cmd read" mode, started running. =head1 CAVEATS This program has only been tested on Linux 2.4 and 2.6 kernels and sendmail 8.12.x and 8.13.x. It may work with other platforms, but none have been tested by the author. =head1 ADDENDUM Just as information, this is what the iptables rules that this script produces and manages will look like: root@mail1:# iptables -L -n | egrep ^SPAM_UNAME_GUESSERS SPAM_UNAME_GUESSERS tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:25 root@mail1:# iptables -L SPAM_UNAME_GUESSERS -n Chain SPAM_UNAME_GUESSERS (1 references) target prot opt source destination DROP tcp -- 61.173.40.71 0.0.0.0/0 tcp dpt:25 DROP tcp -- 211.162.182.2 0.0.0.0/0 tcp dpt:25 DROP tcp -- 61.33.194.207 0.0.0.0/0 tcp dpt:25 DROP rules for specific hosts are removed from the SPAM_UNAME_GUESSERS table after the "UnblockOldOffenders" timeout has past, as specified in the %PERIODIC_CLEANSERS global data structure. =head1 AUTHOR Lester H. Hightower, Jr. =head1 COPYRIGHT Copyright (c) 2005 Lester Hightower. All rights reserved. This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =begin comment =pod SCRIPT CATEGORIES UNIX/System_administration Mail =pod OSNAMES Linux =pod README A daemon to detect spammers trying to harvest email addresses by username guessing and temporarily DROP them with iptables firewall rules. =end comment =cut