Mercurial > hg > maltfilter
diff maltfilter @ 0:fec14263801d
Initial import of maltfilter development version.
author | Matti Hamalainen <ccr@tnsp.org> |
---|---|
date | Thu, 13 Aug 2009 15:15:18 +0300 |
parents | |
children | 3da95f3082d9 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/maltfilter Thu Aug 13 15:15:18 2009 +0300 @@ -0,0 +1,408 @@ +#!/usr/bin/perl -w +############################################################################# +# +# Malicious Attack Livid Termination Filter daemon (maltfilter) +# Programmed by Matti 'ccr' Hämäläinen <ccr@tnsp.org> +# (C) Copyright 2009 Tecnic Software productions (TNSP) +# +############################################################################# +use strict; +use Date::Parse; +use Net::IP; + +my $progbanner = +"Malicious Attack Livid Termination Filter daemon (maltfilter) v0.7\n". +"Programmed by Matti 'ccr' Hamalainen <ccr\@tnsp.org>\n". +"(C) Copyright 2009 Tecnic Software productions (TNSP)\n"; + +############################################################################# +### Settings / configuration +############################################################################# +my %settings = ( + "VERBOSITY" => 4, + "DRY_RUN" => 1, + "WEEDPERIOD" => 72, + "TRESHOLD" => 3, + "ACTION" => "DROP", + "LOGFILE" => "/var/log/maltfilter", + "IPTABLES" => "/sbin/iptables", + "NOBLOCK_HOSTS" => "127.0.0.1", + + "CHK_SSHD" => 1, + "CHK_KNOWN_CGI" => 1, + "CHK_PHP_XSS" => 1, + "CHK_PROXY_SCAN" => 1, + "CHK_GOOD_HOSTS" => "", +); + +# Default logfiles to monitor (SCANFILES setting of configuration overrides these) +my @scanfiles_def = ( + "/var/log/auth.log", + "/var/log/httpd/error.log", + "/var/log/httpd/access.log" +); + +my @scanfiles = (); + +############################################################################# +### Script code +############################################################################# +my %hitcount = (); +my %iplist = (); +my $pid_file = ""; +my $LOGFILE; + +sub mlog +{ + my $level = shift; + my $msg = shift; + if (defined($LOGFILE)) { + print $LOGFILE "[".scalar localtime()."] ".$msg if ($settings{"VERBOSITY"} > $level); + } else { + print $msg if ($settings{"VERBOSITY"} > $level); + } +} + +sub check_hosts($$) +{ + my $host = $_[1]; + my $ip = new Net::IP($host); + foreach my $test (split(/\s*\|\s*/, $_[0])) { + my $test_ip = new Net::IP($test); + if ($host eq $test) { + return 1; + } + if (defined($ip) && defined($test_ip)) { + if ($ip->binip() eq $test_ip->binip()) { + return 1; + } + } + } + return 0; +} + +### Execute iptables +sub exec_iptables(@) +{ + my @args = ($settings{"IPTABLES"}, @_); + if ($settings{"DRY_RUN"}) { + log(3, ":: ".join(" ", @args)."\n"); + } else { + system(@args) == 0 or print join(" ", @args)." failed: $?\n"; + } +} + +### Get current Netfilter INPUT table entries we manage +sub update_iplist($) +{ + open(STATUS, $settings{"IPTABLES"}." -v -n -L INPUT |") or + die("Could not execute ".$settings{"IPTABLES"}."\n"); + while (<STATUS>) { + chomp; + if (/^\s*(\d+)\s+\d+\s+$settings{"ACTION"}\s+all\s+--\s+\*\s+\*\s+(\d+\.\d+\.\d+\.\d+)\s+0\.0\.0\.0\/0\s*$/) { + if (!defined($iplist{$2})) { + $hitcount{$2} = $settings{"TRESHOLD"}; + $iplist{$2} = $_[0]; + if ($_[0] >= 0) { mlog(2, "* $2 appeared in iptables, adding.\n"); } + } + } + } + close(STATUS); +} + +### Weed out old entries +sub check_time($) +{ + return ($_[0] >= time() - ($settings{"WEEDPERIOD"} * 60 * 60)); +} + +sub weed_do($) +{ + if (defined($iplist{$_[0]})) { + mlog(2, "* Weeding $_[0] ($iplist{$_[0]})\n"); + exec_iptables("-D", "INPUT", "-s", $_[0], "-d", "0.0.0.0/0", "-j", $settings{"ACTION"}); + undef($iplist{$_[0]}); + } +} + +sub weed_entries() +{ + foreach my $mip (keys %iplist) { + if (defined($iplist{$mip})) { + if ($iplist{$mip} >= 0) { + if (!check_time($iplist{$mip})) { weed_do($mip); } + } else { + weed_do($mip); + } + } + } +} + +### Check if given "try count" exceeds treshold and if entry +### is NOT in Netfilter already, then add it if so. +sub check_add_entry($$$$) +{ + my $mip = $_[0]; + my $mdate = str2time($_[1]); + my $mreason = $_[2]; + my $mcond = $_[3]; + + my $cnt = $hitcount{$mip}++; + if ($cnt >= $settings{"TRESHOLD"} && check_time($mdate)) { + my $pat; + if (!$mcond) { + mlog(2, "* Ignoring $mip: $mreason\n"); + return; + } + if (!defined($iplist{$mip})) { + if (!check_hosts($settings{"NOBLOCK_HOSTS"}, $mip)) { + # Add entry that has >= treshold hits and is not added yet + mlog(1, "* Adding $mip ($mdate): $mreason\n"); + exec_iptables("-I", "INPUT", "1", "-s", $mip, "-j", $settings{"ACTION"}); + } + $iplist{$mip} = $mdate; + } else { + # Over treshold, but is added, check if we can update the timedate + if ($iplist{$mip} >= 0) { + if ($mdate > $iplist{$mip}) { + $iplist{$mip} = $mdate; + } + } else { + # Empty date, set it now. + $iplist{$mip} = $mdate; + } + } + } +} + +### Check given logfile line for matches +sub check_log_line($) +{ + # (1) SSH login scan attempts + if (/^(\S+\s+\d+\s+\d\d:\d\d:\d\d)\s+\S+\s+sshd\S*?: Failed password for invalid user \S+ from (\d+\.\d+\.\d+\.\d+)/) { + check_add_entry($2, $1, "SSHD", $settings{"CHK_SSHD"}); + } + # (2) Common/known exploitable CGI/PHP software scans (like phpMyAdmin) + # NOTICE! This matches ERRORLOG, thus it only works if you DO NOT have + # any or some of these installed. Preferably none, or use uncommon + # paths and prefixes. + elsif (/^\[(.+?)\]\s+\[error\]\s+\[client\s+(\d+\.\d+\.\d+\.\d+)\]\s+(.+)$/) { + my $mdate = $1; + my $mip = $2; + my $merr = $3; + if ($merr =~ /^File does not exist: (.+)$/) { + my $tmp = $1; + if ($tmp =~ /\/mss2|\/pma|admin|sql|\/roundcube|\/webmail|\/bin|\/mail|xampp|zen|mailto:|appserv|cube|round|_vti_bin|wiki/i) { + check_add_entry($mip, $mdate, "CGI: $tmp", $settings{"CHK_KNOWN_CGI"}); + } + } + } + # Match Apache common logging format GET requests here + elsif (/(\d+\.\d+\.\d+\.\d+)\s+-\s+-\s+\[(.+?)\]\s+\"GET (\S*?) HTTP\//) { + my $mdate = $2; + my $mip = $1; + my $merr = $3; + + # (3) Simple match for generic PHP XSS vulnerability scans + # NOTICE! If your site genuinely uses (checked) PHP parameters with + # URIs, you should set CHK_GOOD_HOSTS to match your hostname(s)/IP(s) + # used in the URIs. + if ($merr =~ /\.php\?\S*?=http:\/\/([^\/]+)/) { + if (!check_hosts($settings{"CHK_GOOD_HOSTS"}, $1)) { + check_add_entry($mip, $mdate, "PHP XSS: $merr", $settings{"CHK_PHP_XSS"}); + } + } + # (4) Try to match proxy scanning attempts + elsif ($merr =~ /^http:\/\/([^\/]+)/) { + if (!check_hosts($settings{"CHK_GOOD_HOSTS"}, $1)) { + check_add_entry($mip, $mdate, "Proxy scan: $merr", $settings{"CHK_PROXY_SCAN"}); + } + } + } +} + + +### +### Utility functions +### +my %logfile = (); + +sub malt_init { + foreach my $logname (@scanfiles) { + local *INFILE; + mlog(0, "- Parsing ".$logname." ...\n"); + open(INFILE, "<", $logname) or die("Could not open '".$logname."'!\n"); + $logfile{$logname} = *INFILE; + while (<INFILE>) { + chomp; + check_log_line($_); + } + } + + mlog(0, "- Weeding out old entries.\n"); + weed_entries(); +} + +sub malt_cleanup { + mlog(0, "- Closing open filehandles.\n"); + foreach my $logname (keys %logfile) { + close($logfile{$logname}); + } +} + +sub malt_scan { + ### Keep on reading + mlog(1, "- Entering main scanning loop.\n"); + my $counter = 0; + while (1) { + my %logpos = (); + foreach my $logname (keys %logfile) { + for ($logpos{$logname} = tell($logfile{$logname}); $_ = <$logfile{$logname}>; $logpos{$logname} = tell($logfile{$logname})) { + chomp; + check_log_line($_); + } + } + sleep(5); + if ($counter++ >= 5) { + # Every once in a while, update known IP list from iptables + # (in case entries have appeared there from "outside") + # and perform weeding of old entries. + $counter = 0; + update_iplist(time()); + weed_entries(); + } + foreach my $logname (keys %logfile) { + seek($logfile{$logname}, $logpos{$logname}, 0); + } + } +} + +sub malt_finish { + # Unlink pid-file + if ($pid_file ne "" && -e $pid_file) { + unlink $pid_file; + } + # Close logfile + close($LOGFILE) if (defined($LOGFILE)); + undef($LOGFILE); +} + +sub malt_int { + mlog(-1, "\nCaught Interrupt (^C), aborting.\n"); + malt_cleanup(); + malt_finish(); + exit(1); +} + +sub malt_term { + mlog(-1, "Receinved TERM, quitting.\n"); + malt_cleanup(); + malt_finish(); + exit(1); +} + +sub malt_hup { + mlog(-1, "Received HUP, reinitializing.\n"); + malt_cleanup(); + malt_init(); + mlog(-1, "Reinitialization finished, resuming scanning.\n"); +} + + +### +### Main program +### +# Setup signal handlers +$SIG{'INT'} = 'malt_int'; +$SIG{'TERM'} = 'malt_term'; +$SIG{'HUP'} = 'malt_hup'; + +# Banner +my $argc = $#ARGV + 1; +if ($argc < 1) { + print $progbanner. + "\nUsage: maltfilter <pid filename> [config filename]\n"; + exit; +} + +# Test pid file existence +$pid_file = shift; +die("'$pid_file' already exists, not starting.\nIf the daemon is NOT running, remove the pid-file and re-start.\n") if (-e $pid_file); + +# Read configuration file +if (defined(my $config_file = shift)) { + my $errors = 0; + + # Let user define his/her own logfiles to scan + undef(@scanfiles_def); + + open(CONFFILE, "<", $config_file) or die("Could not open configuration '".$config_file."'!\n"); + while (<CONFFILE>) { + chomp; + if (/(^\s*#|^\s*$)/) { + # Ignore comments and empty lines + } elsif (/^\s*\"?([a-zA-Z0-9_]+)\"?\s*=>?\s*(\d+),?\s*$/) { + my $key = uc($1); + my $value = $2; + if (defined($settings{$key})) { + $settings{$key} = $value; + } else { + print STDERR "Unknown setting '$key' = $value\n"; + $errors = 1; + } + } elsif (/^\s*\"?([a-zA-Z0-9_]+)\"?\s*=>?\s*\"(.*?)\",?\s*$/) { + my $key = uc($1); + my $value = $2; + if ($key eq "SCANFILE") { + push(@scanfiles_def, $value); + } elsif (defined($settings{$key})) { + $settings{$key} = $value; + } else { + print STDERR "Unknown setting '$key' = '$value'\n"; + $errors = 1; + } + } else { + print STDERR "Syntax error: $_\n"; + $errors = 1; + } + } + close(CONFFILE); + die("Errors in configuration file '$config_file', bailing out.\n") unless ($errors == 0); +} + +# Clean up scanfiles from duplicate entries +my %saw = (); +@scanfiles = grep(!$saw{$_}++, @scanfiles_def); + + +# Open logfile +if ($settings{"DRY_RUN"}) { + print $progbanner. + "*********************************************\n". + "* NOTICE! DRY-RUN MODE ENABLED! No changes *\n". + "* will actually get committed to netfilter! *\n". + "*********************************************\n"; +} elsif ($settings{"LOGFILE"} ne "") { + open($LOGFILE, ">>", $settings{"LOGFILE"}) or die("Could not open logfile '".$settings{"LOGFILE"}."' for writing!\n"); + mlog(-1, "Log started\n"); +} + + +# Initialize +update_iplist(-1); +malt_init(); + +# Fork to background, unless dry-running +if ($settings{"DRY_RUN"}) { + malt_scan(); + malt_cleanup(); +} else { + if (my $pid = fork) { + open(PIDFILE, ">", $pid_file) or die("Could not open pid file '".$pid_file."' for writing!\n"); + print PIDFILE "$pid\n"; + close(PIDFILE); + } else { + malt_scan(); + malt_cleanup(); + } +}