#!/usr/bin/perl ######################################################################################### # popauth # Version 3.0.1 # # Copyright 1997, 1998, 2004, 2005 Neil Harkins, William R. Thomas, Harlann Stenn, # William W. Kimball Jr. # # This file is part of popauth. # # popauth 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. # # popauth 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. # # You should have received a copy of the GNU General Public License # along with popauth; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # This program provides several automated services for use with postfix and your choice # of a POP daemon. This is accomplished by reading real-time entries from a maillog- # duplicate FIFO provided by syslog. These services are defined in several major # categories as: # I. POP-before-SMTP: # A. Tracks authorized E/SMTP relay IPs based on completed POP authorizations. # B. Removes "expired" relay IPs based on your timeout preference. # C. Provides a list of authorized relay IPs to postfix, enabling your users to # relay mail through your host from any location in/outside your LAN. # D. Tracks how long a user keeps a relay open and how many times they have # refreshed that connection (keeping it alive within your timeout period). # II. Local RBL cache: # A. Stores RBL hits in a local datafile. Also enables this program to automatically # black-list IPs based on the violations listed below. # B. This datafile is processed as-needed to provide a local list of black-listed # IPs to postfix so future connection attempts by these IPs are headed off at # the CONNECT stage without having to wait for further external RBL checks. This # saves time and bandwidth per connection. # C. Entries in this datafile will expire and be removed due to inactivity based on # your preferences. # III. Automated null-routing for excessive connection attempts from black-listed IPs: # A. You define 'excessive' with two threshold preferences: # 1. 1 attempt every X seconds. # 2. Maximum allowed attempts. # B. You can specify how long IPs are null-routed differently for each threshold. # After these timeout periods, the null-routes are lifted. # C. Null-routing is accomplished by utilizing iptables (note: you must enable # iptables on your machine for this service to function). # D. The network administrator can be notified by e-mail whenever a black-listed IP # becomes a nuisance (excessive attempts in defiance of black-listing) so more # aggressive actions can be taken outside this program (such as null-routing them # at your perimiter router or notifying their ISP). # IV. Local black-list for other offenses: # A. When used in conjunction with freemail_checks and sender_checks lists to block # forged e-mail addresses from select domains, this service can automatically # black-list offenders. # B. When used in conjunction with helo_checks to block forged HELO/EHLO identities, # this service can automatically black-list offenders. # C. When repeated attempts are made to contact unknown local addresses, the # offender can be automatically black-listed based on customizable count and # frequency limits (to stop e-mail address farming/guessing). Note that this is # very effective at stopping viruses/worms that guess e-mail addresses through # mass address book permutation (combining every user part in the address book, # regardless of domain, with a single target domain). # D. Offenders are added to the local RBL cache and are subject to automated null- # routing for excessive unwanted traffic. # V. Other miscellaneous logging: # A. Because maillog can be cumbersome to read, this program offers summary logging # of potentially interesting events such as (you can disable these if they are # not interesting to you, or expand on them to handle further automated black- # listing): # 1. Unauthorized relay attempts. # 2. DNS lookup errors (which may indicate forged domains, but... may not). # 3. HEAD and BODY rejections. # B. Beyond useful logging, these features are undefined and may be expanded on # in later versions. # # Concept by: John Levine and Scott Hazen Mueller # Original source by: Neil Harkins and John Levine # Adapted (uses FIFO rather than tail -f) by: William R. Thomas # Adapted (version 2.0.0) by: Harlann Stenn # Supplied by: Stephen McHenry # Rewritten (version 3.0.0, anti-UCE) by: William W. Kimball Jr. # Updated (version 3.0.1) by: William W. Kimball Jr. # Added some very minor convenience tweaks and changed some handle variable names that # "may clash with future reserved word[s]" as per the Perl warning mechanism. ######################################################################################### # vi: tabstop=3 # Pragmas use warnings; use strict; use diagnostics; # Global Declarations our $log_debug_msgs = 0; # Indicate whether to log debugging messages into $popauthlog (leave at 0 unless you are reprogramming this code). our $log_warn_msgs = 1; # Indicate whether to log warning messages into $popauthlog (leave at 1 unless you don't care when operations cannot complete). our $log_error_msgs = 1; # Indicate whether to log error messages into $popauthlog (leave at 1 unless you don't care when potentially fatal conditions occur). our $localhost_dns_name = "mail.domain1.tld"; # Fully-qualified DNS name of this machine. our $path_postfix = "/etc/postfix/"; # Path to your installation of the postfix config files (indicate where main.cf is located). our $path_postfix_data = $path_postfix . "data/"; # Path to your postfix data files (indicate where you want $popauth_checks to be located -- must be the same location as you indicate within main.cf). our $path_spoolroot = "/var/spool/popauth/"; our $path_popauthspool = $path_spoolroot . "authenticated/"; our $path_fishingspool = $path_spoolroot . "fishers/"; our $path_fifo = "/var/adm/popauth.fifo"; our $popauthlog = "/var/log/popauth.log"; our $popauthpidfile = "/var/run/popauth.pid"; our $popauth_checks = $path_postfix_data . "popauth_checks"; our $popauth_db = $popauth_checks . ".db"; our $rbl_cache = $path_postfix_data . "rbl_cache"; our $rbl_cache_buffer = $rbl_cache . ".buffer"; our $rbl_checks = $path_postfix_data . "rbl_checks"; our $rbl_checks_db = $rbl_checks . ".db"; our $iptables_script = $path_postfix . "iptables.sh"; our $iptables_rbl_line = 1; # The line number where you want the rbl_ban list to be inserted into your iptables INPUT chain. our $iptables_refresh_secs = 60 * 10; # 10 Minutes between automatic iptables refreshes (to lift automatic null-routes as appropriate). our $expire_popauth_in_secs = 60 * 30; # 30 minutes before permission expires (and the iptables null-route list is rebuilt). our $expire_rbl_cache_secs = 60 * 60 * 24 * 90; # Auto-expire RBL cache records after 90 days (spammers might move on to other Addresses and be replace by legitimate users). our $expire_fish_spool_secs = 60 * 60 * 24 * 3; # Auto-expire fishing attempt spool files after 3 days of inactivity. our $minimum_expire_secs = 60 * 5; # 5 minute minimum pop authorization expire time. our $nullroute_freq_thresh = 0.03333333; # Traps on 1 attempt every 30 seconds (Calculate this by finding #Attempts / #Seconds and round to 6 decimal places). our $nullroute_msg_thresh = 50; # Forces a null-route when this many messages (or more) are received, regardless of frequency. our $nullroute_fish_thresh = 5; # Traps an attempt to fish for e-mail addresses when this many unique bad e-mail address are attempted from a single IP Address. our $nullroute_freq_bansecs = 60 * 30; # A 30 minute ban for sending too many messages at too high a frequency. our $nullroute_msg_bansecs = 60 * 60 * 24 * 7; # A 1 week ban for sending too many total messages. our $nullroute_fish_bansecs = 60 * 60 * 24 * 7; # A 1 week ban for attempting to fish for e-mail addresses. our $nullroute_alert_thresh = 25; # Sends an e-mail notification to the system administrator if any spammer makes this many total attempts, or more (each time). our $sysadmin_email_address = "admin\@domain.tld"; our $sysadmin_email_name = "popauth3 Nullroute Alert from " . $localhost_dns_name; our ($hndLOG, $hndFIFO); ######################################################################################### # main (essentially) # # The main body of this program loops indefinately to regularly perform the core services # of this program. It can be interrupted cleanly by issuing appropriate external # SIGnals. ######################################################################################### { # Declare loop variables. my $nfound = 0; my $timeout_secs = 0; my $now = time(); my $refresh_popauths_at = $now; my $refresh_fishers_at = $now; my $refresh_iptables_at = $now; my $rin = ""; my $rout = ""; my $event = ""; # This outer loop is used to ensure this program stays running if the inner loop ever exits (unlikely, but just-in-case). # Should the inner loop ever exit, this program re-initializes. while (1) { # Initialize reload_init(); # Set up interrupts for external exit/reload signals. $SIG{'INT'} = 'exithandler'; $SIG{'QUIT'} = 'exithandler'; $SIG{'KILL'} = 'exithandler'; $SIG{'HUP'} = 'reload_init'; # Force an immediate rebuild of the POP users list and the iptables ban_list. $now = time(); $refresh_popauths_at = $now; $refresh_iptables_at = $now; # Inner loop runs until exited by an external SIG or an error. while (1) { $now = time(); $rin = ""; vec($rin, fileno($hndFIFO), 1) = 1; # Sleep until there is FIFO activity, or a timeout occurs. $timeout_secs = (($refresh_popauths_at <= $now) ? $now : $refresh_popauths_at) - $now; $nfound = select($rout=$rin, undef, undef, $timeout_secs); # Check for FIFO activity. if ($nfound) { # Pass the FIFO event to the processing triggers. $event = <$hndFIFO>; chomp($event); check_triggers($event); } # Expire authentications, as appropriate. if ($refresh_popauths_at <= time()) { # Debug: log_message("# main() calling expire_pop_sessions() on interval.") if $log_debug_msgs; $refresh_popauths_at = expire_pop_sessions(); } # Rebuild the iptables null-route list at regular intervals to honor expirations. if ($refresh_iptables_at <= time()) { # Debug: log_message("# main() calling rebuild_iptables() on interval.") if $log_debug_msgs; rebuild_iptables(); $refresh_iptables_at = time() + $iptables_refresh_secs; } # Expire fisher data files, as appropriate. if ($refresh_fishers_at <= time()) { # Debug: log_message("# main() calling expire_fisher_sessions() on interval.") if $log_debug_msgs; $refresh_fishers_at = expire_fisher_sessions(); } } } exithandler(); } ######################################################################################### # check_triggers # # Takes a line from the FIFO and compares it against a series of rules to see whether it # matches a trigger. If it does, the trigger is activated. # # Receives: # 1. A line from the syslog FIFO. # Returns: # 0/1 to indicate whether the line tripped a trigger. ######################################################################################### sub check_triggers { my $check_line = $_[0]; my $trapped = 0; # Debug: log_message("# check_triggers(" . $check_line . ")") if $log_debug_msgs; # Look for a POP authentication (or comment out this block to use popauth3 for anti-UCE, only). # You can find alternate regular expressions at http://popauth3.kimballstuff.com/ # Jan 18 04:16:20 mailhost vm-pop3d[28236]: User 'user' of 'domain.tld' from '123.123.123.123' logged in if ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\svm-pop3d\[\d+\]\:\sUser\s\'([a-zA-Z0-9_\.]+)\'\sof\s\'([a-zA-Z0-9_\.]+)\'\sfrom\s\'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\'\slogged\sin/) { # Passing regexp memories: tstamp, user, vhost, ip trigger_pop_authorization($1, $2, $3, $4); ++$trapped; } # Look for an RBL rejection. # Jan 22 10:43:53 mailhost postfix/smtpd[26215]: NOQUEUE: reject: CONNECT from p508EFADB.dip.t-dialin.net[80.142.250.219]: 554 Service unavailable; Client host [80.142.250.219] blocked using list.dsbl.org; http://dsbl.org/listing?ip=80.142.250.219; proto=SMTP elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s554\sService\sunavailable;\sClient\shost\s\[[^\]]+\]\sblocked\susing\s([^;]+);\s([^;]+);/) { # Passing regexp memories: tstamp, host, ip, rbl-host, rbl-claim trigger_rbl_reject($1, $2, $3, $4, $5); ++$trapped; } # Look for repeat blacklist offenders. # Feb 25 17:38:06 mailhost postfix/smtpd[15290]: NOQUEUE: reject: CONNECT from unknown[64.35.6.153]: 554 : Client host rejected: Your IP address has been blacklisted by sbl.spamhaus.org. Visit their website or write to argue. http://www.spamhaus.org/SBL/sbl.lasso?query=SBL12915; proto=SMTP elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s554\s<[^\[]+\[[^\]]+\]>:\sClient\shost\srejected:\sYour\sIP\saddress\shas\sbeen\sblacklisted/) { # Passing regexp memories: tstamp, host, ip trigger_rbl_repeat($1, $2, $3); ++$trapped; } # Look for sender email address forgery. # Jan 22 10:42:10 mailhost postfix/smtpd[26215]: NOQUEUE: reject: MAIL from sellingwtech-28-137.htg.net[209.136.28.137]: 554 : Sender address rejected: Access denied; from= proto=SMTP helo= elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s554\s<([^>]+)>:\sSender\saddress\srejected:\sAccess\sdenied;\sfrom=<[^>]+>\sproto=\w+\shelo=<([^>]+)>/) { # Passing regexp memories: tstamp, host, ip, fake-addy, fake-helo trigger_addy_forgery($1, $2, $3, $4, $5); ++$trapped; } # Look for recipient email address forgery (based on known -- used only by spammers -- black-listed e-mail addresses in recipient_checks.db). # Mar 5 09:48:30 mailhost postfix/smtpd[24142]: 7A8F7C8976: reject: RCPT from ATuileries-152-1-8-105.w82-123.abo.wanadoo.fr[82.123.162.105]: 554 : Recipient address rejected: Access denied; from= to= proto=SMTP helo= elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s554\s<[^>]+>:\sRecipient\saddress\srejected:\sAccess\sdenied;\sfrom=<([^>]+)>\sto=<([^>]+)>\sproto=\w+\shelo=<([^>]+)>/) { # Passing regexp memories: tstamp, host, ip, fake-sender, fake-recipient, fake-helo trigger_recipient_forgery($1, $2, $3, $4, $5, $6); ++$trapped; } # Look for helo forgery. # Jan 29 13:12:08 mailhost postfix/smtpd[29473]: NOQUEUE: reject: HELO from 214.69.30.61.isp.tfn.net.tw[61.30.69.214]: 554 <123.123.123.123>: Helo command rejected: Your machine is not part of this network. Forgery is not tolerated here.; proto=SMTP helo=<123.123.123.123> elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s554\s<([^>]+)>:\sHelo\scommand\srejected:\sYour\smachine\sis\snot\spart\sof/) { # Passing regexp memories: tstamp, host, ip, fake-helo trigger_helo_forgery($1, $2, $3, $4); ++$trapped; } # Look for a bad local recipient. # Feb 24 08:40:38 mailhost postfix/smtpd[9482]: 39E3CC83C7: reject: RCPT from omr-m03.mx.aol.com[64.12.138.3]: 550 : User unknown in local recipient table; from=<> to= proto=ESMTP helo= elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s550\s<([^>]+)>:\sUser\sunknown\sin\slocal\srecipient\stable;\sfrom=<([^>]+)>\sto=<[^>]+>\sproto=\w+\shelo=<([^>]+)>/) { # Passing regexp memories: tstamp, host, ip, to-addy, from-addy, helo trigger_bad_local_recipient($1, $2, $3, $4, $5, $6); ++$trapped; } # Look for an unauthorized relay attempt. # Feb 26 12:15:19 mailhost postfix/smtpd[18762]: A31DDC83C7: reject: RCPT from unknown[164.47.74.57]: 554 : Relay access denied; from= to= proto=SMTP helo= elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s554\s<[^>]+>:\sRelay\saccess\sdenied;\sfrom=<([^>]+)>\sto=<([^>]+)>\sproto=\w+\shelo=<([^>]+)>/) { # Passing regexp memories: tstamp, host, ip, from-addy, to-addy, helo trigger_bad_relay($1, $2, $3, $4, $5, $6); ++$trapped; } # Look for false domains (which MAY be DNS errors -- but, not likely). # Feb 28 09:09:01 mailhost postfix/smtpd[25408]: NOQUEUE: reject: MAIL from 97.97-200-80.adsl.skynet.be[80.200.97.97]: 450 : Sender address rejected: Domain not found; from= proto=SMTP helo= elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/smtpd\[\d+\]:\s\w+:\sreject:\s\w+\sfrom\s([^\[]+)\[([^\]]+)\]:\s450\s<[^>]+>:\sSender\saddress\srejected:\sDomain\snot\sfound;\sfrom=<([^>]+)>\sproto=\w+\shelo=<([^>]+)>/) { # Passing regexp memories: tstamp, host, ip, from-addy, helo trigger_bad_domain($1, $2, $3, $4, $5); ++$trapped; } # Look for body_checks filter rejections. # Mar 5 17:49:10 mailhost postfix/cleanup[25250]: 6E7A4C8976: reject: body Prxice
from unknown[218.81.42.142]; from= to= proto=SMTP helo=: HTML is prohibited. SEND PLAIN TEXT ONLY! BxHTML23 elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/cleanup\[\d+\]:\s\w+:\sreject:\sbody\s(.*)\sfrom\s([^\[]+)\[([^\]]+)\];\sfrom=<([^>]+)>\sto=<([^>]+)>\sproto=\w+\shelo=<([^>]+)>:\s(.*)/) { # Passing regexp memories: tstamp, detail, host, ip, from-addy, to-addy, helo, response, data-portion trigger_data_check($1, $2, $3, $4, $5, $6, $7, $8, "BODY"); ++$trapped; } # Look for header_checks filter rejections. # Mar 4 15:14:01 mailhost postfix/cleanup[21291]: A7C6DC8976: reject: header Subject: -883- Stop walking today 5 whwzbgd from adsl-68-79-18-99.dsl.emhril.ameritech.net[68.79.18.99]; from= to= proto=SMTP helo=: Potential UCE detected. HxSubject2 elsif ($check_line =~ /^([A-Za-z]+\s+\d+\s\d+\:\d+\:\d+)\s\w+\spostfix\/cleanup\[\d+\]:\s\w+:\sreject:\sheader\s(.*)\sfrom\s([^\[]+)\[([^\]]+)\];\sfrom=<([^>]+)>\sto=<([^>]+)>\sproto=\w+\shelo=<([^>]+)>:\s(.*)/) { # Passing regexp memories: tstamp, detail, host, ip, from-addy, to-addy, helo, response, data-portion trigger_data_check($1, $2, $3, $4, $5, $6, $7, $8, "HEADER"); ++$trapped; } # Debug: log_message("## check_triggers() returns " . $trapped . ".") if $log_debug_msgs; return($trapped); } ######################################################################################### # trigger_pop_authorization # # Writes an authorized IP address to the spool directory where IP addresses are collected # and rebuilds the postfix authorization file as-needed. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. POP username # 3. POP virtual-host name # 4. POP session IP Address # Returns: # Nothing ######################################################################################### sub trigger_pop_authorization { my $now = time(); my $rebuild = 0; my $tstamp = $_[0]; my $user = $_[1]; my $vhost = $_[2]; my $ip = $_[3]; my $useremail = $user . "\@" . $vhost; my $spoolfile = $path_popauthspool . $ip; my $bufferfile = $spoolfile . ".buffer"; my $bufferline = ""; my $outputline = ""; my $founduser = 0; my $firstlogon = $now; # Tracks the first time a user has logged in for a running session. my $lastlogon = $now; # Tracks the most recent time a user has logged in for a running session. my $auths = 1; # Tracks the number of times a user has logged in during a running session. my $logonsecs = 0; # Tracks how long the user has been logged into their running session. my @fields; # Debug: log_message("# trigger_pop_authorization(" . $tstamp . ", " . $user . ", " . $vhost . ", " . $ip . ")") if $log_debug_msgs; # Open the spool file, if it exists, to track how long this user has been logged in. if (-e $spoolfile) { # Open the existing (running session) spool file and bump the $lastlogon and $auths fields # for the appropriate user (don't forget that multiple users CAN connect from the SAME IP). # Note: No need to call for another rebuild because, clearly, the spoolfile already exists. if (open(SPOOLFILE, "< " . $spoolfile)) { # Trigger a database rebuild if the output can be created. if (open(BUFFERFILE, "> " . $bufferfile)) { # Look for an entry for the current user. while ($bufferline = ) { chomp($bufferline); # Check for an e-mail address match. if ($bufferline =~ /^$useremail\t/) { # Bump the $lastlogon and $auths fields for this user. ++$founduser; @fields = split(/\t/, $bufferline); $firstlogon = $fields[1]; $lastlogon = $now; $auths = $fields[3] + 1; $logonsecs = $lastlogon - $firstlogon; $outputline = "$useremail\t$firstlogon\t$lastlogon\t$auths\n" } else { # Not a match, just output the line as-is. $outputline = $bufferline . "\n"; } # Write the resulting line to the buffer file. print(BUFFERFILE $outputline); } # If the user is new to this spool file, add them. if (!$founduser) { print(BUFFERFILE "$useremail\t$firstlogon\t$lastlogon\t$auths\n"); } # Close the buffer stream and use it to replace the original authentication file. close(BUFFERFILE); rename($bufferfile, $spoolfile); # Log this event, based on whether the user has their own running session. if ($founduser) { log_message("$tstamp $user\@$vhost has refreshed authentication for a " . pretty_seconds($logonsecs) . " old relay session from $ip x $auths."); } else { log_message("$tstamp $user\@$vhost has refreshed authentication for an existing relay session from $ip."); } } else { # Log this error. log_message("$tstamp ERROR: (check_popauth) Unable to open $bufferfile to update running session data for $user\@$vhost from $ip!") if $log_error_msgs; } } else { # Log this error. log_message("$tstamp ERROR: (check_popauth) Unable to open $spoolfile to update running session data for $user\@$vhost from $ip!") if $log_error_msgs; } close(SPOOLFILE); } else { if (open(SPOOLFILE, "> " . $spoolfile)) { print(SPOOLFILE "$useremail\t$firstlogon\t$lastlogon\t$auths\n"); close(SPOOLFILE); ++$rebuild; # Log this event. log_message("$tstamp $user\@$vhost has authenticated a new relay session from $ip."); } else { # Log this error. log_message("$tstamp ERROR: (check_popauth) Unable to open $spoolfile to grant $user\@$vhost relay authorization from $ip!") if $log_error_msgs; } } # If requested, rebuild the list of authentications. rebuild_popauth_checks() if $rebuild; } ######################################################################################### # trigger_rbl_reject # # Fires when someone attempts to connect, but their connection is denied due to an RBL # hit. This condition is generated by postfix during smtpd_client_restrictions (via # reject_rbl_client) validation. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been rejected # 3. IP Address that has been rejected # 4. RBL Authority that has black-listed this entry # 5. RBL Authority's reason for this black-listing # Returns: # Nothing ######################################################################################### sub trigger_rbl_reject { # Note: While this SHOULD be a NEW entry, timing experiments have proven that rbl_checks CAN be triggered twice # for the same IP, even if it has been logged here. Therefore, it is necessary to seek this IP before adding it # to the data file. If it is found, abort and call the proper trigger: trigger_rbl_repeat(). my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $rblhost = $_[3]; my $rblclaim = $_[4]; my $now = time(); my $firstdt = $now; my $lastdt = $now; my $unbandt = -1; my $hits = 1; my $totalhits = 1; my $frequency = 0.0; my $readline = ""; my $foundip = 0; # Debug: log_message("# trigger_rbl_reject(" . $tstamp . ", " . $host . ", " . $ip . ", " . $rblhost . ", " . $rblclaim . ")") if $log_debug_msgs; # Check to see if the datafile has been initialized. If so, try to prevent adding duplicate records. if (-e $rbl_cache) { # Open the datafile and seek the $ip. if (open(RBLCACHE, "< " . $rbl_cache)) { while ($readline = ) { chomp($readline); # Check for an IP Address match. if ($readline =~ /^$ip\t/) { ++$foundip; } } close(RBLCACHE); } else { log_message("$tstamp ERROR: (trigger_rbl_reject) Unable to open $rbl_cache for reading! A duplicate record may be introduced!") if $log_error_msgs; } } if ($foundip) { # Uh oh. Found a match, but this is supposed to be a NEW entry. Send this event off to trigger_rbl_repeat(). log_message("$tstamp WARNING: (trigger_rbl_reject) $ip [$host] has already been blacklist by RBL [$rblhost] for reason [$rblclaim]. Passing off to trigger_rbl_repeat().") if $log_warn_msgs; trigger_rbl_repeat($tstamp, $host, $ip); } else { # Oh good; still on track. Add a new record to the cache file. if (open(RBLCACHE, ">> " . $rbl_cache)) { print(RBLCACHE "$ip\t$firstdt\t$lastdt\t$unbandt\t$hits\t$totalhits\t$frequency\t$host\t$rblhost\t$rblclaim\n"); close(RBLCACHE); # Sort and ensure key field uniqueness of the modified cache file. system("sort -n --key=1,2 $rbl_cache | uniq --check-chars=15 > $rbl_cache_buffer"); rename($rbl_cache_buffer, $rbl_cache); # Rebuild the rbl_checks data file because a new record has been added. rebuild_rbl_checks(); # Log this action. log_message("$tstamp $ip [$host] has been blacklisted by RBL [$rblhost] for reason [$rblclaim]."); } else { log_message("$tstamp ERROR: (trigger_rbl_reject) Unable to open $rbl_cache for appending! Failing to record blacklist event for: $ip [$host] blacklist by RBL [$rblhost] for reason [$rblclaim].") if $log_error_msgs; } } } ######################################################################################### # trigger_rbl_repeat # # Fires when someone attempts to connect after they have already been locally blacklisted. # This condition is generated by postfix during smtpd_client_restrictions (via rbl_checks) # validation. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been black-listed # 3. IP Address that has been rejected # Returns: # Nothing ######################################################################################### sub trigger_rbl_repeat { # Notes for handling repeat offenders: # ------------------------------------ # Most importantly, we don't want their traffic. They have already been # identified as spammers. The first thing we did was blacklist them (via # our local RBL cache: $rbl_cache) with the help of the RBL providers and our # own means, so they cannot complete an SMTP connection. However, if they # become persistant -- or try to storm our mail host (like stupid spammer # software that ignores 5xx messages) -- we will have to null-route them # so we can dedicate valuable server resources to other legitimate mail # users. This technique requires the help of iptables. We will track the # frequency, in decimal form, of their attempts by evaluating a # running-average of attempts-per-second between the most-recent-seen-datetime # and the newest-attempt-datetime. Any value over $nullroute_freq_thresh # per second will result in a TEMPORARY null-route on their IP address # (limited by $nullroute_msg_bansecs). We will lift the null-route (unban) # the IP after this specified period because there is no telling when a # spammer will give up an IP addy and move on. Because administrators are # perpetually curious about their system performance, we will notify them # via e-mail when we ban abusers. This is especially important for # alerting admins to perpetual, repeat offenders. A pattern will develop # in the admin's e-mail to this effect, enabling them to take more drastic # action (such as null-routing the abuser at the perimiter router and/or # notifying the abuser's ISP and/or upstream provider(s). # # Data source file format for $rbl_cache: # Host-IP-Address First-DT Last-DT Unban-DT Hits Total Frequency Host-Name RBL-Name RBL-Reason # 123.123.123.123 101705283 102345832 102525832 1 124 0.000492374 host.spamdomain.biz list.dsbl.org http://dsbl.org/listing?ip=123.123.123.123 my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $now = time(); my $input = ""; my $output = ""; my $found = 0; my $firsthit_dt = $now; # First-DT this spammer was encountered. my $priorhit_dt = $now; # Previous value of Last-DT in the source file. my $lasthit_dt = $now; # What will become the new value of Last-DT in the source file. my $unban_dt = -1; # -1 means this entry has never been banned. Any other value is the time() when this entry will be, or has been, unbanned. my $total_hits = 1; # Number of times this spammer has attempted to connect since $firsthit_dt. my $period_hits = 1; # Number of times this spammer has attempted to connect within a period of 1 / $nullroute_freq_thresh (# Seconds permitted to lapse between attempts). my $frequency = 0.0; # $frequency = $total_hits / $hitspread; $hitspread != 0; my $rbl_host = ""; # Used to preserve the $cache data format. my $rbl_claim = ""; # Used to preserve the $cache data format. my $call_rebuild = 0; # Trigger to detect when to call rebuild_iptables(). my $hitspread = 0; # Number of seconds between $priorhit_dt and $lasthit_dt, used to determine a running-average for $frequency. my @fields; # Debug: log_message("# trigger_rbl_repeat(" . $tstamp . ", " . $host . ", " . $ip . ")") if $log_debug_msgs; # Open the rbl_cache file. First, make sure such a file exists... if (-e $rbl_cache) { if (open(RBLCACHE, "< " . $rbl_cache)) { # Look for any previous occurance of the current offender's IP address. Output all lines to a new buffer. if (open(REPEATBUFFER, "> " . $rbl_cache_buffer)) { while ($input = ) { chomp($input); # Check for an IP Address match. if ($input =~ /^$ip\t/) { # If found, read and incriment the incidence count. # Then, replace the line with an updated incidence count and last-occurred date. # Then, calculate the incidence/attempt frequency and update accordingly. # Watch for triggering events and act accordingly (null-route abusers). $found = 1; @fields = split(/\t/, $input); $firsthit_dt = $fields[1]; $priorhit_dt = $fields[2]; $lasthit_dt = $now; $period_hits = $fields[4] + 1; $total_hits = $fields[5] + 1; $hitspread = $lasthit_dt - $priorhit_dt; $rbl_host = $fields[8]; $rbl_claim = $fields[9]; # Reset $period_hits if there has been no activity from this IP in the last (1 / $nullroute_freq_thresh) seconds. # While this isn't as perfect as individually logging EVERY attempt from EVERY IP (eg: in a MySQL relational table) # and counting ONLY those that occured in the last (1 / $nullroute_freq_thresh) seconds, this is all that can be # done with a single-record data file. The reason this may seem unfair is because $period_hits will only continue # to get larger and larger as long as the spammer makes attempts within every MOVING (1 / $nullroute_freq_thresh) # seconds. The result is a potential for an unfairly large $period_hits value (couting hits truly older than the # recorded period, because that period is a moving target). if ($now - (1 / $nullroute_freq_thresh) > $priorhit_dt) { log_message("#: trigger_rbl_repeat Resetting priorhits from $period_hits to 1 because $now > " . ($priorhit_dt + (1 / $nullroute_freq_thresh))) if $log_debug_msgs; $period_hits = 1; } # Calculate the frequency based on $hitspread if it is not zero, or set hitspread to 1/1000th of a second (to avoid a division by zero error). if (0 < $hitspread) { $frequency = $period_hits / $hitspread; } else { $frequency = $period_hits / 0.001; } # Check the frequency and total attempts against threshold limits to null-route if appropriate. if ($total_hits > $nullroute_msg_thresh) { $unban_dt = $lasthit_dt + $nullroute_msg_bansecs; } elsif ($frequency > $nullroute_freq_thresh) { $unban_dt = $lasthit_dt + $nullroute_freq_bansecs; } else { $unban_dt = -1; } # Execute the null-route call and notify admin@ if called for. if (-1 < $unban_dt) { $call_rebuild = 1; # Send e-mail notification to the system administrator only if the tolerance threshold has been breeched. if ($total_hits >= $nullroute_alert_thresh) { send_nullroute_mail($ip, $host, $total_hits, $period_hits, $frequency, $unban_dt, $hitspread); } } $output = "$ip\t$firsthit_dt\t$lasthit_dt\t$unban_dt\t$period_hits\t$total_hits\t$frequency\t$host\t$rbl_host\t$rbl_claim\n"; } else { # Non-matching line; just write it as-is to the output buffer. $output = $input . "\n"; } # Write the current (or modified) line to the output buffer. print(REPEATBUFFER $output); } # Close the rbl_cache_buffer file. close(REPEATBUFFER); # If the IP address was not found, we have a problem. if (!$found) { log_message(&tstamp() . " WARNING: (trigger_rbl_repeat) $ip was not found in $rbl_cache! trigger_rbl_reject() must be called before trigger_rbl_repeat().") if $log_warn_msgs; } # Replace the $rbl_cache file with the $rbl_cache_buffer temp file to commit changes. if (-e $rbl_cache) { rename($rbl_cache_buffer, $rbl_cache); } # Log this event. log_message("$tstamp Blacklisted $ip [$host] has repeated an attempt to relay mail to this server, x $period_hits ($total_hits total) hits in " . pretty_seconds($hitspread) . ", rate = " . sprintf("%.6f", $frequency) . "."); } else { log_message(&tstamp() . " ERROR: (trigger_rbl_repeat) Unable to open $rbl_cache_buffer for output!") if $log_error_msgs; } # Close the rbl_repeat_offenders queue file. close(RBLCACHE); } else { log_message(&tstamp() . " ERROR: (trigger_rbl_repeat) Unable to open $rbl_cache for input!") if $log_error_msgs; } } else { log_message(&tstamp() . " ERROR: (trigger_rbl_repeat) $rbl_cache has not been initialized! trigger_rbl_reject() must be called before trigger_rbl_repeat().") if $log_error_msgs; } # Rebuild the iptables firewall, if called. rebuild_iptables() if $call_rebuild; } ######################################################################################### # trigger_addy_forgery # # Fires when someone attempts to forge an e-mail address from a well-known, or free-mail # provider. This condition is generated by postfix during smtpd_sender_restrictions (via # freemail_checks->sender_checks) validation. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been rejected # 3. IP Address that has been rejected # 4. Attempted From: e-mail address # Returns: # Nothing ######################################################################################### sub trigger_addy_forgery { my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $fromaddy = $_[3]; my $fakehelo = $_[4]; # Debug: log_message("# trigger_addy_forgery(" . $tstamp . ", " . $host . ", " . $ip . ", " . $fromaddy . ")") if $log_debug_msgs; # Log this event. log_message("$tstamp $ip [$host] ($fakehelo) tried to claim [$fromaddy] was part of [$host]."); # Just pass this off to trigger_rbl_reject and become the RBL Authority with an appropriate reason. trigger_rbl_reject($tstamp, $host, $ip, $localhost_dns_name, "E-MAIL ADDRESS FORGERY IS NOT TOLERATED HERE! <$fromaddy> is not part of $host!"); } ######################################################################################### # trigger_recipient_forgery # # Fires when someone attempts to send mail to a fictional (or guessed) e-mail address # that has been MANUALLY black-listed in recipient_checks. There is nothing automatic # about this system, except for this POST-CONFIGURATION detection. This condition is # generated by postfix during smtpd_recipient_restrictions (via recipient_checks) # validation. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been rejected # 3. IP Address that has been rejected # 4. Attempted From: e-mail address # 5. Attempted To: e-mail address # 6. Attempted EHLO/HELO name # Returns: # Nothing ######################################################################################### sub trigger_recipient_forgery { my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $fromaddy = $_[3]; my $toaddy = $_[4]; my $helo = $_[5]; # Debug: log_message("# trigger_recipient_forgery(" . $tstamp . ", " . $host . ", " . $ip . ", " . $fromaddy . ", " . $toaddy . ", " . $helo . ")") if $log_debug_msgs; # Log this event. log_message("$tstamp $ip [$host] ($helo) attempted to contact <$toaddy> from <$fromaddy>."); # Just pass this off to trigger_rbl_reject and become the RBL Authority with an appropriate reason. trigger_rbl_reject($tstamp, $host, $ip, $localhost_dns_name, "E-MAIL ADDRESS FORGERY IS NOT TOLERATED HERE! You are not permitted to contact <$toaddy> from <$fromaddy>!"); } ######################################################################################### # trigger_helo_forgery # # Fires when someone attempts to forge an E/SMTP HELO/EHLO response. This condition is # generated by postfix during smtpd_helo_restrictions (via helo_checks) validation. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been rejected # 3. IP Address that has been rejected # 4. Attempted HELO/EHLO name # Returns: # Nothing ######################################################################################### sub trigger_helo_forgery { my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $fakehelo = $_[3]; # Debug: log_message("# trigger_helo_forgery(" . $tstamp . ", " . $host . ", " . $ip . ", " . $fakehelo . ")") if $log_debug_msgs; # Log this event. log_message("$tstamp $ip [$host] tried to claim [$host] was [$fakehelo]."); # Just pass this off to trigger_rbl_reject and become the RBL Authority with an appropriate reason. trigger_rbl_reject($tstamp, $host, $ip, $localhost_dns_name, "E/SMTP HELO/EHLO FORGERY IS NOT TOLERATED HERE! You do not own $fakehelo!"); } ######################################################################################### # trigger_bad_local_recipient # # Fires when someone attempts to send a message to a local address that does not exist. # This is used to identify address fishing (a spammer trying to guess e-mail # addresses of your domain(s)). # This condition is generated by postfix during smtpd_recipient_restrictions (via local # recipient table) validation. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been rejected # 3. IP Address that has been rejected # 4. Attempted To: e-mail address # 5. Attempted From: e-mail adddress # 6. Attempted HELO/EHLO name # Returns: # Nothing ######################################################################################### sub trigger_bad_local_recipient { # Theory: # Each time an IP attempts to send a message to an unknown address, write both the attempted to and from addresses into a file named after the IP address (like trigger_pop_authorization()). # When writing to the data file, check first to see whether the combination of to and from addresses is unique. # If the combination is unique to this data file, add it with a counter of 1 and track it's first attempt and last attempt datetime. # If the combination is NOT unique, incriment the counter of the given e-mail address and update the last attempt datetime. # We really don't care about attempts to contact the same bad address repeatedly from a single address, but we could. If we cared, we could trigger a message to the sender to politely ask them to stop. # What we really care about is an excessive number of unqiue bad address attempts. Let the sysadmin determine "excessive". We will rate exceess in two ways: # 1. The sheer number of addresses logged per data file. For example, more than 5 total unqiue bad address attempts in one data file is probably a fishing attempt. # 2. The rate of bad address attempts. For example, more than 1 unique attempt in 2 seconds is probably a fishing attempt, or a storm. # # Data file format: # attempted-to@email.address attempted-from@email.address firstcontact lastcontact totalhits my $now = time(); my $rebuild = 0; my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $toaddy = $_[3]; my $fromaddy = $_[4]; my $helo = $_[5]; my $addycombo = $toaddy . "\t" . $fromaddy . "\t"; my $spoolfile = $path_fishingspool . $ip; my $bufferfile = $spoolfile . ".buffer"; my $bufferline = ""; my $outputline = ""; my $foundattempt = 0; my $firstattempt = $now; # Tracks the first time this bad e-mail address was attempted. my $lastattempt = $now; # Tracks the most recent time this bad e-mail address was attempted. my $latestattempt = -1; # Used to identify when the last attempt to contact a bad e-mail address was made. my $frequency = 0.0; # $frequency = $total_hits / $hitspread; $hitspread != 0; my $hitspread = 0; # Number of seconds between $priorhit_dt and $lasthit_dt, used to determine a running-average for $frequency. my $hits = 1; # Tracks the number of times this bad e-mail address has been attempted. my $totalrows = 0; # Tracks the number of total UNIQUE attempts in the given spool file (used to detect fishing). my $totalattempts = 0; # Tracks the grand total number of all attempts from this IP to send messages to bad local e-mail addresses. my @fields; # Debug: log_message("# trigger_bad_local_recipient(" . $tstamp . ", " . $host . ", " . $ip . ", " . $toaddy . ", " . $fromaddy . ", " . $helo . ")") if $log_debug_msgs; # Log this event. log_message("$tstamp Local address <$toaddy> not found in message from <$fromaddy> using $ip [$host] (helo=$helo)."); # Open the spool file, if it exists, to track the number of unique bad-address attempts. if (-e $spoolfile) { # Open the existing spool file and bump the $lastattempt and $hits fields # for the appropriate bad e-mail address combination, or add a new bad e-mail address combination. # Keep a tally of the total number of bad e-mail address combinations in this spool file # and check against threshold limits if a new entry is created. if (open(SPOOLFILE, "< " . $spoolfile)) { # Trigger a database rebuild if the output can be created. if (open(BUFFERFILE, "> " . $bufferfile)) { # Look for an entry for the current combination. while ($bufferline = ) { chomp($bufferline); @fields = split(/\t/, $bufferline); $firstattempt = $fields[2]; $lastattempt = $fields[3]; $latestattempt = ($latestattempt < $lastattempt ? $lastattempt : $latestattempt); ++$totalrows; # Check for an e-mail address match. if ($bufferline =~ /^$addycombo/) { # Bump the $lastattempt and $hits fields for this repeat bad e-mail address. ++$foundattempt; $hits = $fields[4] + 1; $lastattempt = $now; $totalattempts += $hits; $outputline = "$toaddy\t$fromaddy\t$firstattempt\t$lastattempt\t$hits\n" } else { # Not a match, just output the line as-is. $outputline = $bufferline . "\n"; } # Write the resulting line to the buffer file. print(BUFFERFILE $outputline); } # If the address combination is new to this spool file, add it. if (!$foundattempt) { ++$totalrows; ++$totalattempts; print(BUFFERFILE "$toaddy\t$fromaddy\t$firstattempt\t$lastattempt\t$hits\n"); } # Close the buffer stream and use it to replace the original authentication file. close(BUFFERFILE); rename($bufferfile, $spoolfile); # Log this event, based on whether the address combination is a repeat. if ($foundattempt) { log_message("$tstamp $ip has repeated an attempt to contact <$toaddy> from <$fromaddy> using $ip [$host] (helo=$helo) x $hits ($totalrows total unique bad address combinations)."); } } else { # Log this error. log_message("$tstamp ERROR: (trigger_bad_local_recipient) Unable to open $bufferfile to update possible fishing data for $ip [$host] (helo=$helo) attempt to contact <$toaddy> from <$fromaddy>!") if $log_error_msgs; } } else { # Log this error. log_message("$tstamp ERROR: (trigger_bad_local_recipient) Unable to open $spoolfile to update possible fishing data for $ip [$host] (helo=$helo) attempt to contact <$toaddy> from <$fromaddy>!") if $log_error_msgs; } close(SPOOLFILE); } else { if (open(SPOOLFILE, "> " . $spoolfile)) { print(SPOOLFILE "$toaddy\t$fromaddy\t$firstattempt\t$lastattempt\t$hits\n"); close(SPOOLFILE); ++$rebuild; ++$totalrows; } else { # Log this error. log_message("$tstamp ERROR: (trigger_bad_local_recipient) Unable to open $spoolfile to identify possible fishing data for $ip [$host] (helo=$helo) attempt to contact <$toaddy> from <$fromaddy>!") if $log_error_msgs; } } # Calculate the hit spread for frequency evaluation. $hitspread = $now - $latestattempt; # Calculate the frequency based on $hitspread if it is not zero, or set hitspread to 1/1000th of a second (to avoid a division by zero error). if (0 < $hitspread) { $frequency = $totalattempts / $hitspread; } else { $frequency = $totalattempts / 0.001; } # Check the frequency and number of attempts against threshold limits and trigger reactive event(s) as appropriate. if (($totalrows > $nullroute_fish_thresh) || ($frequency > $nullroute_freq_thresh)) { # Blacklist this IP for probable e-mail address fishing (too many unique attempts). Let trigger_rbl_reject call on trigger_rbl_repeat as necessary. trigger_rbl_reject($tstamp, $host, $ip, $localhost_dns_name, "E-MAIL ADDRESS FORGERY IS NOT TOLERATED HERE! <$toaddy> does not exist here!"); } } ######################################################################################### # trigger_bad_relay # # Fires when someone attempts to relay mail through this server without authorization. # This can be used to identify repeat offenders, but it may inadvertantly be used to # trap legitimate users who simply forget to POP-before-SMTP. # This method is undefined, at this time, and only logs the event. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been rejected # 3. IP Address that has been rejected # 4. Attempted From: e-mail adddress # 5. Attempted To: e-mail address # 6. Attempted HELO/EHLO name # Returns: # Nothing ######################################################################################### sub trigger_bad_relay { my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $fromaddy = $_[3]; my $toaddy = $_[4]; my $helo = $_[5]; # Debug: log_message("# trigger_bad_relay(" . $tstamp . ", " . $host . ", " . $ip . ", " . $fromaddy . ", " . $toaddy . ", " . $helo . ")") if $log_debug_msgs; # Just log this event, for now (feature to be conceived and developed). log_message("$tstamp Relay access denied From=<$fromaddy> To=<$toaddy> using $ip [$host] (helo=$helo)."); } ######################################################################################### # trigger_bad_domain # # Fires when someone attempts to relay mail through this server from a fictional domain. # Note that the domain may just not be found due to a DNS error, but this is not likely. # This method is undefined, at this time, and only logs the event. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Hostname that has been rejected # 3. IP Address that has been rejected # 4. Attempted From: e-mail adddress # 5. Attempted HELO/EHLO name # Returns: # Nothing ######################################################################################### sub trigger_bad_domain { my $tstamp = $_[0]; my $host = $_[1]; my $ip = $_[2]; my $fromaddy = $_[3]; my $helo = $_[4]; # Debug: log_message("# trigger_bad_domain(" . $tstamp . ", " . $host . ", " . $ip . ", " . $fromaddy . ", " . $helo . ")") if $log_debug_msgs; # Just log this event, for now (feature to be conceived and developed). log_message("$tstamp Possible forged domain detected; From=<$fromaddy> using $ip [$host] (helo=$helo)."); } ######################################################################################### # trigger_data_check # # Fires when a line of the incomming message trips a header_checks or body_checks rule # that leads to a REJECT. WARNs are ignored (and OKs don't generate log events). # Conceptually, you can expand on this funciton to "look for key phrases" in your reject # messages that can result in an appropriate call to trigger_rbl_reject (to locally # black-list this abuser). # Until then, this method is undefined and only logs the event. # # Receives: # 1. Time-date stamp as read from the FIFO # 2. Detail of the line rejected # 3. Hostname that has been rejected # 4. IP Address that has been rejected # 5. Attempted From: e-mail adddress # 6. Attempted To: e-mail address # 7. Attempted HELO/EHLO name # 8. Error response given to the client # 9. DATA part that was intercepted # Returns: # Nothing ######################################################################################### sub trigger_data_check { my $tstamp = $_[0]; my $detail = $_[1]; my $host = $_[2]; my $ip = $_[3]; my $fromaddy = $_[4]; my $toaddy = $_[5]; my $helo = $_[6]; my $response = $_[7]; my $datapart = $_[8]; # Debug: log_message("# trigger_data_check(" . $tstamp . ", " . $detail . ", " . $host . ", " . $ip . ", " . $fromaddy . ", " . $toaddy . ", " . $helo . ", " . $response . ", " . $datapart . ")") if $log_debug_msgs; # Just log this event, for now (feature to be conceived and developed). log_message("$tstamp <$fromaddy> from $ip [$host] (helo=$helo) was blocked from sending to <$toaddy> for reason [$response] on $datapart line [$detail]."); } ######################################################################################### # expire_pop_sessions # # Scans the directory containing IP addresses and removes the ones that have passed # expiration. Also determines, as it scans, when it needs to wake up again to check the # next oldest file. # # Receives: # Nothing # Returns: # Date-time stamp at which the next check should be performed. ######################################################################################### sub expire_pop_sessions { my $now = time(); my $expire_next = $now + $minimum_expire_secs; my $expired = 0; my $mtime = 0; my $exptime = 0; my $file = ""; # Debug: log_message("# expire_pop_sessions()") if $log_debug_msgs; # Get all the files (IP addresses) in the directory. opendir(DIR2, $path_popauthspool); my @dir2 = grep !/^\.\.?$/, readdir(DIR2); closedir(DIR2); foreach $file (@dir2) { # Get the last modified time of the file (which represents an IP address). $mtime = (stat($path_popauthspool . $file))[9]; $exptime = $mtime + $expire_popauth_in_secs; if ($exptime <= $now) { # File is past its expiration - remove it. unlink($path_popauthspool . $file); ++$expired; log_message(&tstamp() . " Relay authorization for $file has expired and has been removed."); } else { # Keep track of the next time we need to wake up - it's when the next one expires. $expire_next = $exptime if ($exptime < $expire_next); } } # Rebuild the $popauth_checks datafile if any authorizations have been removed. rebuild_popauth_checks() if $expired; # Debug: log_message("## expire_pop_sessions() returns " . &tstamp($expire_next) . ".") if $log_debug_msgs; return($expire_next); } ######################################################################################### # expire_fisher_sessions # # Scans the directory containing IP addresses and removes the ones that have passed # expiration. Also determines, as it scans, when it needs to wake up again to check the # next oldest file. # # Receives: # Nothing # Returns: # Date-time stamp at which the next check should be performed. ######################################################################################### sub expire_fisher_sessions { my $now = time(); my $expire_next = $now + $minimum_expire_secs; my $expired = 0; my $mtime = 0; my $exptime = 0; my $file = ""; # Debug: log_message("# expire_fisher_sessions()") if $log_debug_msgs; # Get all the files (IP addresses) in the directory. opendir(DIR2, $path_fishingspool); my @dir2 = grep !/^\.\.?$/, readdir(DIR2); closedir(DIR2); foreach $file (@dir2) { # Get the last modified time of the file (which represents an IP address). $mtime = (stat($path_fishingspool . $file))[9]; $exptime = $mtime + $expire_fish_spool_secs; if ($exptime <= $now) { # File is past its expiration - remove it. unlink($path_fishingspool . $file); ++$expired; log_message(&tstamp() . " E-mail Address fishing data file for $file has expired and has been removed."); } else { # Keep track of the next time we need to wake up - it's when the next one expires. $expire_next = $exptime if ($exptime < $expire_next); } } # Debug: log_message("## expire_fisher_sessions() returns " . &tstamp($expire_next) . ".") if $log_debug_msgs; return($expire_next); } ######################################################################################### # expire_rbl_cache # # Scans through the $rbl_cache file to expire records older than $expire_rbl_cache_secs. # The later of the Last-Hit-DateTime or the Unban-DateTime fields are compared against # time() to determine when to expire a record. # Returns the number of records removed by this operation, or -1 on error. # # Receives: # Nothing # Returns: # Number of records removed from $rbl_cache. ######################################################################################### sub expire_rbl_cache { my $now = time(); my $listline = ""; my $removecount = -1; my $lasthitdt = $now; my $unbandt = $now; my $expiredt = $now; my @fields; # Debug: log_message("# expire_rbl_cache()") if $log_debug_msgs; # Don't do anything of the $rbl_cache doesn't yet exist. if (-e $rbl_cache) { # Try to open the $rbl_cache and $rbl_cache_buffer files. if (open(RBLLIST, "< " . $rbl_cache)) { if (open(BUFFERLIST, "> " . $rbl_cache_buffer)) { $removecount = 0; # Look for old records to expire. Base this on the later of Last-Hit-DT or Unban-DT. while ($listline = ) { chomp($listline); @fields = split(/\t/, $listline); $lasthitdt = $fields[2]; $unbandt = $fields[3]; $expiredt = ($lasthitdt > $unbandt ? $lasthitdt : $unbandt) + $expire_rbl_cache_secs; if ($now < $expiredt) { # Preserve this record; it has not yet expired. print(BUFFERLIST $listline . "\n"); } else { # Track the number of removed records for logging purposes. ++$removecount; } } close(BUFFERLIST); # Replace the cache file with the updated buffer file. rename($rbl_cache_buffer, $rbl_cache); } else { # Log failure. log_message(&tstamp() . " ERROR: (expire_rbl_cache) Unable to open $rbl_cache_buffer for buffer output!.") if $log_error_msgs; } close(RBLLIST); } else { # Log failure. log_message(&tstamp() . " ERROR: (expire_rbl_cache) Unable to open $rbl_cache for input!.") if $log_error_msgs; } } else { # Warn that there is no rbl_cache file. log_message(&tstamp() . " WARNING: (expire_rbl_cache) Cannot complete because $rbl_cache does not exist.") if $log_warn_msgs; } # Log this operation if anything was done. if ($removecount > 0) { log_message(&tstamp() . " $removecount record(s) have been removed from $rbl_cache due to expiration."); } # Debug: log_message("## expire_rbl_cache() returns " . $removecount . ".") if $log_debug_msgs; return($removecount); } ######################################################################################### # rebuild_popauth_checks # # Rebuilds the authorization file used by postfix by gathering all the filenames (which # are IP addresses) from the popauth spool directory and adding a line for each to the # authorization file. # # Receives: # Nothing # Returns: # Number of records granted relay authority and passed to postfix. ######################################################################################### sub rebuild_popauth_checks { # Debug: log_message("# rebuild_popauth_checks()") if $log_debug_msgs; # Get all filenames (IP addresses) to be added to authorization file. my $auth_count = 0; opendir(DIR, $path_popauthspool); my @dir = grep !/^\.\.?$/, readdir(DIR); closedir(DIR); # Add each one to the file with "OK" as the rule. if (open(hndPOPAuth, "> " . $popauth_checks)) { foreach $_ (@dir) { if(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) { print(hndPOPAuth "$_\tOK\n"); ++$auth_count; } else { log_message(&tstamp() . " WARNING: (rebuild_popauth_checks) Unrecognized file: <$path_popauthspool$_>") if $log_warn_msgs; } } close(hndPOPAuth); } else { log_message(&tstamp() . " ERROR: (rebuild_popauth_checks) Unable to open $popauth_checks for output!") if $log_error_msgs; } # Rebuild the hash file (used by postfix). system("cat $popauth_checks | makemap hash $popauth_db"); # Debug: log_message("## rebuild_popauth_checks() returns " . $auth_count . ".") if $log_debug_msgs; return($auth_count); } ######################################################################################### # rebuild_rbl_checks # # Rebuilds the rbl_checks.db cache file used by postfix to head off known spammers during # CONNECT (via smtpd_client_restrictions) while saving bandwidth by avoiding unnecessary # reject_rbl_client calls. # # Receives: # Nothing # Returns: # Number of records passed to postfix for CONNECT level black-listing. ######################################################################################### sub rebuild_rbl_checks { # This is used to (re)generate the old "checks file" used by postfix with format: # Host-IP-Address Code Reject-Message # 123.123.123.123 554 Your IP address has been blacklisted by list.dsbl.org. Visit their website or write to argue. http://dsbl.org/listing?ip=123.123.123.123 my $listline = ""; my $rblrecords = 0; my $ip = ""; my $rblhost = ""; my $rblclaim = ""; my @fields; # Debug: log_message("# rebuild_rbl_checks()") if $log_debug_msgs; # Start by removing all expired records from $rbl_cache. Don't proceed if there is an error. if (-1 < expire_rbl_cache()) { # Then, recreate $rbl_checks for postfix. Open the cache file. if (open(RBLLIST, "< " . $rbl_cache)) { # Open (overwrite) the $rbl_checks file. if (open(RBLCHECKS, "> " . $rbl_checks)) { while ($listline = ) { chomp($listline); ++$rblrecords; @fields = split(/\t/, $listline); $ip = $fields[0]; $rblhost = $fields[8]; $rblclaim = $fields[9]; print(RBLCHECKS "$ip\t554\tYour IP address has been blacklisted by $rblhost. Visit their website or write to argue. $rblclaim\n"); } close(RBLCHECKS); # Make a DB file out of the updated blacklist cache for postfix use. if ($rblrecords > 0) { system("cat $rbl_checks | makemap hash $rbl_checks_db"); } else { # Log a lapse in logic, should this ever occur. log_message(&tstamp() . " WARNING: (rebuild_rbl_checks) No records were copied from $rbl_cache to $rbl_checks for postfix use.") if $log_warn_msgs; } } else { # Log failure. log_message(&tstamp() . " ERROR: (rebuild_rbl_checks) Unable to open $rbl_cache for input!.") if $log_error_msgs; } close(RBLLIST); } else { # Log failure. log_message(&tstamp() . " ERROR: (rebuild_rbl_checks) Unable to open $rbl_cache for input!.") if $log_error_msgs; } } # Debug: log_message("## rebuild_rbl_checks() returns " . $rblrecords . ".") if $log_debug_msgs; return($rblrecords); } ######################################################################################### # rebuild_iptables # # Updates the iptables firewall chains to automatically null-route excessively # repetitive spammers. # # Receives: # Nothing # Returns: # Number of records null-routed via iptables. ######################################################################################### sub rebuild_iptables { my $now = time(); my $list_input = ""; my $ip = ""; my $unban = -1; my $banned_cnt = 0; my @fields; my @iplist; # Debug: log_message("# rebuild_iptables()") if $log_debug_msgs; # Rebuild the datafile before looking for offenders to expire old records. Proceed only if there are no errors. if (-1 < expire_rbl_cache()) { # Open the rbl_cache. if (open(RBLCACHE, "< " . $rbl_cache)) { # Pass through each line of the rbl_cache to find trigger conditions. while ($list_input = ) { chomp($list_input); @fields = split(/\t/, $list_input); $ip = $fields[0]; $unban = $fields[3]; # Find IPs to null-route (will have Unban fields >= date()). if ($now <= $unban) { # Push the banned IP to the iplist. push(@iplist, $ip); } } # Close the rbl_cache. close(RBLCACHE); # Sort the iplist. # TODO: Need to write a sort algorythm that will sort IP Addresses correctly (standard sort() does NOT). @iplist = sort(@iplist); # Open the iptables script file. if (open(IPTABLES, "> " . $iptables_script)) { print(IPTABLES "iptables -D INPUT -j rbl_list\n"); print(IPTABLES "iptables -F rbl_list\n"); print(IPTABLES "iptables -X rbl_list\n"); print(IPTABLES "iptables -N rbl_list\n"); foreach $ip (@iplist) { print(IPTABLES "iptables -A rbl_list -s $ip -j DROP\n"); } print(IPTABLES "iptables -I INPUT $iptables_rbl_line -j rbl_list\n"); # Close the iptables script. close(IPTABLES); # Execute the iptables script. system("sh $iptables_script"); # Log this operation. $banned_cnt = scalar @iplist; if ($banned_cnt > 0) { log_message(&tstamp() . " " . $banned_cnt . " IP Addresses have been null-routed by iptables via script, $iptables_script."); } else { log_message(&tstamp() . " The iptables null-route chain has been cleared via script, $iptables_script."); } } } else { # Log failure. log_message(&tstamp() . " ERROR: (rebuild_iptables) Unable to open $rbl_cache for input!") if $log_error_msgs; } } else { # Warn that the iptables firewall will not be updated. log_message(&tstamp() . " WARNING: (rebuild_iptables) The iptables firewall will not be updated because expire_rbl_cache() cannot complete.") if $log_warn_msgs; } # Debug: log_message("## rebuild_iptables() returns " . $banned_cnt . ".") if $log_debug_msgs; return($banned_cnt); } ######################################################################################### # send_nullroute_mail # # Sends an e-mail message to the network administrator to alert them of a possible # nuisance spammer. # # Receives: # 1. IP Address that has been rejected. # 2. Hostname that has been rejected. # 3. Number of attempts this IP has made since they were first locally black-listed. # 4. Number of attempts this IP has made during the last moving period. # 5. Attempt rate from this IP during the last moving period. # 6. Date-time the null-route on this IP will be lifted. # 7. Number of seconds since the last attempt by this IP. # Returns: # Nothing ######################################################################################### sub send_nullroute_mail { # Local variables. my $ip = $_[0]; my $host = $_[1]; my $attempts = $_[2]; my $period_hits = $_[3]; my $rate = $_[4]; my $unban = $_[5]; my $hitspread = $_[6]; my $nicerate = sprintf("%.4f", 1 / $rate); my $nicelimit = sprintf("%.4f", 1 / $nullroute_freq_thresh); my $unbandate = &tstamp($unban); my $spooldir = "/var/spool/mail/"; my $spoolfile = "nullRouteAlert.mail"; my $spoolpath = $spooldir . $spoolfile; my $addyfrom = $sysadmin_email_address; my $addyto = $sysadmin_email_address; my $msgsubject = "Null-Route Alert For $ip"; my $msgbody = "From: \"$sysadmin_email_name\" <$addyfrom>\n" . "To: \"System Administrator\" <$addyto>\n" . "Subject: $msgsubject\n" . "\n" . "$ip [$host] has been null-routed until $unbandate. " . "This user has made $period_hits attempts in the last $hitspread " . "second(s) for a rate of 1 message every $nicerate seconds (the " . "threshold is set at 1 message every $nicelimit seconds). " . "This user has made $attempts total attempts (the threshold " . "limits users to $nullroute_msg_thresh total messages). " . "Based on your threshold limits, this appears to be excessive. " . "Please consider using the following " . "Cisco IOS or iptables INPUT chain line to null-route this " . "abuser at the perimiter router (to lift this unwanted traffic " . "from your mail host):\n" . "\n" . "access-list 100 deny ip host $ip any\n" . "iptables -A INPUT -s $ip -j DROP\n" . "."; # Debug: log_message("# send_nullroute_mail(" . $ip . ", " . $host . ", " . $attempts . ", " . $rate . ", " . $unban . ")") if $log_debug_msgs; # Write a spool e-mail message file and send it off. if (open(SPOOLFILE, "> " . $spoolpath)) { print(SPOOLFILE "$msgbody"); close(SPOOLFILE); # Use the postfix sendmail utility to send the message. system("sendmail -f $addyfrom $addyto < $spoolpath"); unlink($spoolpath); # Log this event. log_message(&tstamp() . " $addyto has been notified to null-route $ip."); } else { # Log this event. log_message(&tstamp() . " Unable to notify $addyto of null-route need against $ip [$host] for $attempts attempts."); } } ######################################################################################### # open_log # # Opens and initializes the run-time log file. # # Receives: # 1. Line to write to the log file. # Returns: # 0/1 to indicate whether the log file was opened and the line was written. ######################################################################################### sub open_log { my $logline = $_[0]; my $written = 0; chomp($logline); if (open($hndLOG, ">> " . $popauthlog)) { # Force immediate output flushing for the log file. select($hndLOG); $| = 1; # Write introduction to the log file. print($hndLOG "\n¸,ø¤º°`°º¤ø,¸¸,ø¤º°`°º¤ø,¸¸,ø¤º°`°º¤ø,¸¸,ø¤º°`°º¤ø,¸¸,ø¤º°`°º¤ø,¸\n"); # Set the default output back to STDOUT and prevent output buffering. select(STDOUT); $| = 1; $written = print($hndLOG $logline . "\n"); } return($written); } ######################################################################################### # close_log # # Closes the run-time log file. # # Receives: # Nothing # Returns: # 0/1 to indicate whether the log file was closed. ######################################################################################### sub close_log { my $closed = 0; if (defined($hndLOG)) { $closed = close($hndLOG); } return ($closed); } ######################################################################################### # log_message # # Writes a message to the run-time log file. The log file is opened if necessary. # # Receives: # 1. Line to write to the log file. # Returns: # 0/1 to indicate whether the line was written. ######################################################################################### sub log_message { my $logline = $_[0]; my $written = 0; chomp($logline); # Try to log the message. If it fails, reopen the log file. if (defined($hndLOG)) { $written = print($hndLOG $logline . "\n"); } # Double-check to make sure the log line was written. Either the log isn't open, or the write failed (lost file handle). if (0 == $written) { $written = open_log($logline); } return ($written); } ######################################################################################### # open_fifo # # Attaches this process to the FIFO used to sniff mail messages. # # Receives: # Nothing # Returns: # 0/1 to indicate whether the FIFO was opened. ######################################################################################### sub open_fifo { my $opened = 0; close_fifo(); # Make sure the fifo file is a pipe. unless (-p $path_fifo) { unless ((unlink($path_fifo)) && ((system("mkfifo $path_fifo")) || (chmod(0600, $path_fifo)))) { die("Cannot initialize $path_fifo: $!"); } } # Open the fifo stream. $opened = open($hndFIFO, "< " . $path_fifo) or die("Can't open $path_fifo: $!"); return ($opened); } ######################################################################################### # close_fifo # # Closes the FIFO used to sniff mail messages. # # Receives: # Nothing # Returns: # 0/1 to indicate whether the FIFO was closed. ######################################################################################### sub close_fifo { my $closed = 0; if (defined($hndFIFO)) { $closed = close($hndFIFO); } return ($closed); } ######################################################################################### # write_pid # # Writes the process ID of this program to a local store to enable external signals. # # Receives: # Nothing # Returns: # 0/1 to indicate whether the PID file was created. ######################################################################################### sub write_pid { return ((open(PID, "> " . $popauthpidfile)) && (print(PID "$$\n")) && (close(PID))); } ######################################################################################### # reload_init # # Re-initializes this program. # # Receives: # Nothing # Returns: # Nothing ######################################################################################### sub reload_init { # Write out the Program ID (PID) file for external utility access. write_pid() or die("Cannot create PID file $popauthpidfile: $!"); # Initialize the log file. close_log(); log_message(&tstamp() . " Starting $0 with PID $$.") or die("Cannot initialize the log file $popauthlog: $!"); # Attach to the fifo. close_fifo(); open_fifo() or die("Cannot attach to the FIFO $path_fifo: $!"); # Debug: log_message("# reload_init()") if $log_debug_msgs; # Rebuild data files, as needed. log_message(&tstamp() . " " . expire_rbl_cache() . " records have been removed from $rbl_cache due to expiration."); log_message(&tstamp() . " " . rebuild_rbl_checks() . " records have been copied from $rbl_cache to $rbl_checks."); log_message(&tstamp() . " " . rebuild_popauth_checks() . " users have been granted relay authorization via $popauth_checks."); } ######################################################################################### # exithandler # # Terminates this program. # # Receives: # Nothing # Returns: # Nothing ######################################################################################### sub exithandler { # WWK: Why do this when you're not going to use $sig?: local ($sig) = @_; # Log the shutdown order. log_message("$0 is terminating."); log_message("`°º¤ø,¸¸,ø¤º°`°º¤ø,¸¸,ø¤º°`°º¤ø,¸¸,ø¤º°`°º¤ø,¸¸,ø¤º°`°º¤ø,¸¸,ø¤º°`\n\n"); # Close all outstanding files. close_fifo(); close_log(); # Terminate. exit(0); } ######################################################################################### # tstamp # # Returns a formatted date/time stamp. # # Receives: # OPTIONAL: A date-time value to format. time() is used if this is not provided. # Returns: # A formatted version of the date-time value. ######################################################################################### sub tstamp { my $datetime = time(); # Read an arbitrary time value from the argument list, if provided. if (@_ > 0) { $datetime = $_[0]; } use POSIX qw(strftime); return(POSIX::strftime("%b %d %H:%M:%S", localtime($datetime))); } ######################################################################################### # pretty_seconds # # Returns a formatted count of seconds parsed into weeks, days, hours, minutes, and # remaining seconds. # # Receives: # A number of seconds to format. # Returns: # A formatted version of the number of seconds in "Xw Xd Xh Xm Xs" format. ######################################################################################### sub pretty_seconds { my $bigseconds = $_[0]; my $weeks = 0; my $days = 0; my $hours = 0; my $minutes = 0; my $seconds = 0; my $pretty = ""; $weeks = int($bigseconds / (7 * 24 * 60 * 60)); $seconds = int($bigseconds % (7 * 24 * 60 * 60)); $days = int($seconds / (24 * 60 * 60)); $seconds = int($seconds % (24 * 60 * 60)); $hours = int($seconds / (60 * 60)); $seconds = int($seconds % (60 * 60)); $minutes = int($seconds / 60); $seconds = int($seconds % 60); $pretty = (($weeks > 0) ? $weeks . "w " : "") . ((($weeks > 0) || ($days > 0)) ? $days . "d " : "") . ((($weeks > 0) || ($days > 0) || ($hours > 0)) ? $hours . "h " : "") . ((($weeks > 0) || ($days > 0) || ($hours > 0) || ($minutes > 0)) ? $minutes . "m " : "") . $seconds . "s"; return($pretty); }