The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Apache::MSIISProbes;

use strict;
use vars qw($VERSION $VERSION_DATE);

use Apache::Constants qw(OK DECLINED FORBIDDEN);
use Mail::Sendmail;
use Net::DNS;
use Cache::FileCache;
use Time::Zone;

$VERSION = 1.08;
$VERSION_DATE = 'December 6, 2001';

# ----------------------------------------------------------
# BEGIN USER-CONFIGURABLE OPTIONS
# ONLY change these options!

# Shall our debugging be verbose?
# Set to '1' to log each attempt. Set to '2' to log all mail failures.
# Set to '3' to log everything! Set to '0' for silence.
my $DEBUG = 1;

# What "From:" header should be inserted into outgoing e-mail?
my $from_address = 'you@example.com';

# Do you want to know when one of these alerts has been sent?
# If so, put your address here.
my $cc_address = '';

# Indicate whether the cache should expire or not. If we want to track
# the number of attempts per IP address (and thus send multiple copies
# of the mail) we never expire the cache (this is the default behavior).
# If we want to be less noisy we only send mail once per period.

# set $store to false to only send one message per IP per period
# set $store to true to send messages for every attempt
my $store = 1;

# If $store is false we must specify when to purge the cache: default
# value is one day (86400 seconds).
my %cache_options = $store ? '' : ('default_expires_in' => 86400);

# Shall we send mail only to the SOA contact and abuse@ the SOA domain,
# or shall we also send it to various addresses at the MX for the IP address?
my $soa_only = 0;
 
# List of regexps that should be ignored
my @ignore_ip = ('192\.168\..*', '10\..*');

# END USER-CONFIGURABLE OPTIONS
# ------------------------------------------------------------

my $security_focus_address = 'aris-report@securityfocus.com';
my $module_url = 'http://www.tonkinresolutions.com/software/perl/Apache/MSIISProbes/';

sub handler {
	# Get Apache request/response object
	my $r = shift;
	my %args = $r->args;

	# Get the server name
	my $s = $r->server();
	my $server_name = $s->server_hostname();

	# Stats only?
	if ($args{stats}) {
		$cache_options{'namespace'} = $args{stats};
		my $file_cache = new Cache::FileCache(\%cache_options);
		my %count;
		foreach my $ip ($file_cache->get_identifiers) {
			$count{$ip} = $file_cache->get($ip);
		}
		print "<HTML><PRE>Statistics for the MSIISProbes.pm cache for the $args{stats} worm on $server_name:\n\n";
		foreach my $ip (sort { $count{$b} <=> $count{$a} } keys %count) {
			print "$ip\t$count{$ip}\n";
		}
		print "</PRE></HTML>";
		return OK;
	}

	# Create a DNS resolver, which we'll need no matter what.
	my $res = new Net::DNS::Resolver;
	$res->tcp_timeout(20);

	# ------------------------------------------------------------
	# Open the cache of already-responded-to IP addresses,
	# which we're going to keep in /tmp, just for simplicity.
	#
	# Use the environment var set by Apache to decide which part
	# of the cache to use
	my $worm_name = $r->dir_config('worm_name');
	$cache_options{'namespace'} = $worm_name || 'Default';
	my $file_cache = new Cache::FileCache(\%cache_options);

	unless ($file_cache) {
		$r->log_error("MSIISProbes: Could not instantiate FileCache.  Exiting.");
		return DECLINED;
	}

	# Get the HTTP client's IP address.  We'll use this to
	#  send mail to the people who run the domain.
	my $remote_ip_address = $r->get_remote_host();

	# If we don't have the remote IP address, then we cannot send mail
	# to the remote server, can we?  Let's just stop now, while we're at it.
	unless (defined $remote_ip_address) {
		$r->warn("MSIISProbes: Undefined remote IP address!  Exiting.");
		return DECLINED;
	}

	# If we have the remote IP address, then check to see
	# if it's in our cache.
	my $count = $file_cache->get($remote_ip_address);

	# We update the cache with the new count no matter what,
	# although the count may be cleared (if the mail fails)
	$file_cache->set($remote_ip_address, ++$count);

	if ($count > 1) {
		if ($store) {
			# We go ahead anyway
			$DEBUG && $r->warn("MSIISProbes: Attack number [$count] with [$worm_name] from [$remote_ip_address].  Re-mailing.");
		} else {
			$DEBUG && $r->warn("MSIISProbes: Attack number [$count] with [$worm_name] in the current cache period from [$remote_ip_address].  Exiting.");
			return FORBIDDEN;
		}
	} else {
		$DEBUG && $r->warn("MSIISProbes: Attack number [$count] with [$worm_name] from [$remote_ip_address]. Mailing.");
	}

	# If the remote address matches our ignore list, then ignore it
	foreach my $ignore_ip (@ignore_ip) {
		if ($remote_ip_address =~ /^$ignore_ip$/) {
			$DEBUG && $r->warn("MSIISProbes: Detected known IP [$remote_ip_address] (matched [$ignore_ip]).  Exiting.");
			return FORBIDDEN;
		}
	}

	# ------------------------------------------------------------
	# If we only have the IP address (rather than the hostname), then get the
	# hostname.  (We can't look up the MX host for a number, only a name.)

	my $remote_hostname = $remote_ip_address;

	# If the IP address is numeric, then look up its name
	if ($remote_ip_address =~ /^[\d.]+$/) {
		my $dns_query_response = $res->search($remote_ip_address);
		if ($dns_query_response) {
			foreach my $rr ($dns_query_response->answer) {
				# All of the records we retrieve should be PTR records,
				# since we're doing an IP-to-hostname lookup.
				next unless $rr->type eq "PTR";
				# Once we know this is a PTR, we can grab its name
				$remote_hostname = $rr->rdatastr;
			}
		} else {
			my $dns_error = $res->errorstring;
			$DEBUG && $r->warn("MSIISProbes: Failed DNS lookup of [$remote_ip_address] (error: [$dns_error])");
		}
	}

	# ------------------------------------------------------------
	# Send e-mail to SecurityFocus.com, which is going to
	# deal with all of this stuff automatically
	#
	# If we are storing a count per IP, we only send this mail the first time.
	# If we are purging the cache, we send it once per period.
	my $now = scalar localtime;
	if ($count > 1) {
		$DEBUG && $r->warn("MSIISProbes: Not mailing Security Focus about duplicate IP [$remote_ip_address]");
	} else {
		$DEBUG && $r->warn("MSIISProbes: Sending e-mail to SecurityFocus about IP [$remote_ip_address]");
		my $time_zone_name = uc(tz_name());
		my $sf_message = <<EOT;
$remote_ip_address\t$now $time_zone_name

Brought to you by Apache::MSIISProbes version $VERSION for mod_perl and Apache running on
$server_name.
Information at <$module_url>.
EOT

		my %sf_mail = (
						To	  => $security_focus_address,
						CC	  => $cc_address,
						From	=> $from_address,
						Subject => "$worm_name infection on [$remote_hostname]: Automatic report",
						Message => $sf_message
					   );

		my $sf_sendmail_success = sendmail(%sf_mail);

		unless ($sf_sendmail_success) {
			$DEBUG > 1 && $r->warn("MSIISProbes: Mail::Sendmail returned [$Mail::Sendmail::error].  Exiting.");
			# We want to make sure Security Focus gets the report, so clear the cache entry for this IP
			$file_cache->set($remote_ip_address, 0);
			return DECLINED;
		} else {
			$DEBUG > 2 && $r->warn("MSIISProbes: Sent mail to Security Focus for IP [$remote_ip_address]");
		}
	}

	# ------------------------------------------------------------
	# Get some administrative e-mail addresses for this host
	my $admin_address;

    # Get an email address from the SOA of the domain
    # Courtesy of Sam Phillips' original Code Red Check
    # Patch by Brice D. Ruth
    my $dnsSOA_ipaddr = $remote_ip_address;
    my $dnsSOA_contact;
    my $dnsSOA_ptr_query = $res->query($dnsSOA_ipaddr, "PTR");
    if ($dnsSOA_ptr_query) {
        my $dnsSOA_zone = ($dnsSOA_ptr_query->answer)[0]->ptrdname;
        my @zone;
        my $dnsSOA_soa_query = 0;
        while (!$dnsSOA_soa_query) {
            $dnsSOA_soa_query = $res->query ($dnsSOA_zone, "SOA");
            if ($dnsSOA_zone eq "") { exit 1; };
            @zone = split /\./, $dnsSOA_zone;
            shift @zone;
            $dnsSOA_zone = join '.', @zone;
        }
        
		$dnsSOA_contact = ($dnsSOA_soa_query->answer)[0]->rname;
        $dnsSOA_contact =~ s/\./@/;

		# send to abuse@SOA also	
		unless ($dnsSOA_contact =~ m/^abuse/) {
			my ($junk, $dom) = split('@', $dnsSOA_contact);
			$dnsSOA_contact .= ', abuse@' . $dom;
		}
        $admin_address = $dnsSOA_contact;
        $DEBUG > 1 && $r->warn("MSIISProbes: Using SOA address [$dnsSOA_contact]");
    }

	unless ($soa_only) {
		# Get the MX for this domain.  This is trickier than you might
		# think, since some DNS servers (like my ISP's) give accurate
		# answers for domains, but not for hosts.  So www.lerner.co.il
		# doesn't have an MX, while lerner.co.il does.  So we're going to
		# do an MX lookup -- and if it doesn't work, we're going to break
		# off everything up to and including the first . in the hostname,
		# and try again.  We shouldn't have to get to the top-level
		# domain, but we'll try that anyway, just in case the others don't
		# work.

		my @mx = ();
		my @hostname_components = split /\./, $remote_hostname unless $remote_hostname eq $remote_ip_address;
		my $starting_index = 0;

		# Loop around until our starting index begins at the same location as it would end
		while ($starting_index < @hostname_components) {
			my $host_for_mx_lookup = join('.', @hostname_components[$starting_index .. $#hostname_components]);
			@mx = mx($res, $host_for_mx_lookup);

			if (@mx) {
 				last;
			} else {
				$starting_index++;
			}
		}

		if (! @mx and ! $admin_address) {
			# If we still haven't found any records, then simply return FORBIDDEN, and log an error message
			my $dns_error = $res->errorstring;
			$DEBUG > 1 && $r->warn("MSIISProbes: No MX records or SOA address for [$remote_hostname](error: [$dns_error]).  Exiting.");
			return FORBIDDEN;
		} elsif (@mx) {
			# Grab the first MX record, and assume that it'll work.
			my $mx_host = $mx[0]->exchange;
			$DEBUG > 1 && $r->warn("MSIISProbes: Using MX host [$mx_host]");
			# Send e-mail to the webmaster, postmaster, and administrator, since
			# the webmaster and/or postmaster addresses often doesn't work
			$admin_address .= ', ' if $admin_address;
			$admin_address .= "webmaster\@$mx_host, postmaster\@$mx_host, administrator\@$mx_host";
		}
	}

	# Set the outgoing message
	my $worm_url = $r->dir_config('worm_url') || 'http://www.microsoft.com/technet/default.asp';
	my $request  = $r->the_request;

	my $outgoing_message = <<EOT;

Your Microsoft IIS server (at $remote_ip_address) appears to have been
infected with the $worm_name worm.  It attempted to spread to
our Web server at $now, despite the fact that we run Apache, which is immune.
The requested URI is printed below.

This was attempt number $count from the server at $remote_ip_address
to infect our server. You should immediately view the latest information
and download any available security patches, from <$worm_url>.

Automatically generated by Apache::MSIISProbes version $VERSION for mod_perl
and Apache running on $server_name. Information at <$module_url>.

URI requested:
$request

EOT

	# ------------------------------------------------------------
	# Also send e-mail to the people running the offending host,
	# just in case SecurityFocus takes a while.

	$DEBUG > 1 && $r->warn("MSIISProbes: Sending e-mail to [$admin_address]");

	my %mail = (
				To		=> $admin_address,
				CC		=> $cc_address,
				From	=> $from_address,
				Subject	=> "$worm_name infection on [$remote_hostname]: Automatic report",
				Message	=> $outgoing_message
			   );

	my $sendmail_success = sendmail(%mail);

	if ($sendmail_success) {
		$DEBUG > 2 && $r->warn("MSIISProbes: Sent mail to [$admin_address] for IP [$remote_ip_address]");
		return FORBIDDEN;
	} else {
		$DEBUG > 1 && $r->warn("MSIISProbes: Mail::Sendmail returned [$Mail::Sendmail::error]. Exiting.");
		return DECLINED;
	}
}

# All modules must return a true value
1;

__END__

=pod

=head1 NAME

 Apache::MSIISProbes -
 Responds to worm attacks on Microsoft Internet Information Servers with e-mail warnings.

=head1 SYNOPSIS

 In your httpd.conf, put something similar to the following:

 <Location /default.ida>
   SetHandler perl-script
   PerlHandler Apache::MSIISProbes
   PerlSetVar worm_name CodeRed
   PerlSetVar worm_url http://www.microsoft.com/technet/itsolutions/security/topics/codealrt.asp
 </Location>

=head1 DESCRIPTION

 This Perl module should be invoked whenever the worms it
 knows about attack. We don't have to worry about such
 attacks on non-Windows boxes, but we can be good Internet
 citizens, warning the webmasters on infected machines of the
 problem and how to solve it.

 The module allows the user to add new configuration
 directives as new worms are discovered.

=head1 USAGE

 In your httpd.conf, put directives similar to the following:

 <Location /default.ida>
   SetHandler perl-script
   PerlHandler Apache::MSIISProbes
   PerlSetVar worm_name CodeRed
   PerlSetVar worm_url http://www.microsoft.com/technet/itsolutions/security/topics/codealrt.asp
 </Location>

 <LocationMatch (cmd.exe|root.exe)>
   SetHandler perl-script
   PerlHandler Apache::MSIISProbes
   PerlSetVar worm_name Nimda
   PerlSetVar worm_url http://www.microsoft.com/technet/security/topics/Nimda.asp
 </LocationMatch>

B<Duplicates>

 $store = 1; # Send mail for every attempt
 $store = 0; # Only send mail once per cache period

 Although rumor has it that CodeRed and other similar worms
 only attack a given IP once from a given host, experience
 shows this to be false. You can control the behavior of
 MSIISProbes.pm when it encounters a second or subsequent
 attempt from a given IP address. By default MSIISProbes.pm
 keeps a cache of IP addresses from which an attempt has
 originated, counting attempts per worm from the IP and
 including the count in each message it mails.

 You can override this behavior and send a message only the
 first time a given host attempts to spread the worm in a given
 period by setting the variable $store to a false value. This
 will cause the cache to be cleared at a given interval (by
 default, one day). Mail alerts to the IIS server's
 administrators will be sent only once per cache period.

B<Volume>

 $soa_only = 1; # only send mail to the Start of Authority
 $soa_only = 0; # send mail to address at the IP's MX also

 You can also control the "volume" of the module using the 
 $soa_only flag in the configuration. This variable controls
 the number of addresses to whom e-mail alerts are sent when
 an attempt to infect your server is made. Experience has shown
 that many administrators do not have their MX records set up
 correctly. This can lead to frequent bounced mail when the module
 is in its default configuration ($soa_only = 0).

 If you set $soa_only to a true value, the module will only attempt
 to resolve the Start of Authority for the attacking IP. This will 
 usually result in mail being sent to the SOA contact address plus 
 abuse@ the SOA's domain.

 If you set $soa_only to false, the module will additionally attempt
 to resolve the IP's MX host and send mail to postmaster@, webmaster@,
 and administrator@ that domain. This is a potential total of 5
 messages (plus Security Focus) per attempted infection.

B<Statistics>

 You may wish to see the statistics for the various worms you
 are catching with MSIISProbes.pm. The module supports this
 through setting the value of the query string argument 'stats'
 to one of the worm names you defined in httpd.conf, for
 example like this:

 http://my.server.com/cmd.exe?stats=CodeRed
 or
 http://my.server.com/foo.bar?/c+dir&stats=Nimda

 Just make sure to attache the query string to a URL that will
 be caught by your configuration of the module! Also please note
 that the statistics provided are of your cache, so if you are
 clearing entries after one day the statistics will obviously
 not be cumulative!

=head1 AUTHOR

 Author: Nick Tonkin (nick@tonkinresolutions.com)

 Based on CodeRed.pm by Reuven M. Lerner (reuven@lerner.co.il),
 with ideas from Randal Schwartz, David Young, and Salve J. Nilsen.

=head1 COPYRIGHT

 This code is copyright 2001 Nick Tonkin. All rights reserved.

=head1 LICENSE

 You may distribute this module under the same license as Perl itself.

=head1 CHANGES

 v1.08
 Added the requested URI to the body of the e-mail message sent to
 server admins, since some apparently refuse to take action without
 it (requested by Tom Moore)

 v1.07
 Added $soa_only var to config, allowing more control over the volume.
 Corrected deprecated URL for this module's home page.

 v1.06
 Updated docs to reflect correct httpd.conf sections (Steve Neuharth)

 v1.05
 Fixed bug where mail could get sent to the local server admin if the
 remote IP address doesn't resolve to a host name (in certain environments)
 (reported by Bruce Albrecht)

 Fixed typo in page headline for statistical report (spotted by Alex Vandiver)

 v1.04
 Added code to provide statistics on the Cache (suggested by Paul DuBois)

 v1.03
 Added code to get e-mail for the SOA of the host (Brice D. Ruth)

 Cut the DNS Resolver's timeout to 20 seconds

 v1.02
 Moved the URL for info for each worm into PerlSetVar in httpd.conf

=head1 SEE ALSO

L<Apache>.

=cut