Mercurial > hg > maltfilter
view maltfilter @ 15:b05d0f0ff106
Cleanups in progress, does not work.
author | Matti Hamalainen <ccr@tnsp.org> |
---|---|
date | Fri, 14 Aug 2009 19:12:14 +0300 |
parents | fc053b001027 |
children | 87c0cdc048f5 |
line wrap: on
line source
#!/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 $progversion = "0.9"; my $progbanner = "Malicious Attack Livid Termination Filter daemon (maltfilter) v$progversion\n". "Programmed by Matti 'ccr' Hamalainen <ccr\@tnsp.org>\n". "(C) Copyright 2009 Tecnic Software productions (TNSP)\n"; ############################################################################# ### Settings / configuration ############################################################################# my %settings = ( "VERBOSITY" => 3, "DRY_RUN" => 1, "WEEDPERIOD" => 150, "TRESHOLD" => 3, "ACTION" => "DROP", "LOGFILE" => "", "IPTABLES" => "/sbin/iptables", "STATUS_FILE_PLAIN" => "", "STATUS_FILE_HTML" => "", "STATUS_FILE_CSS" => "", "CHK_SSHD" => 1, "CHK_KNOWN_CGI" => 1, "CHK_PHP_XSS" => 1, "CHK_PROXY_SCAN" => 1, "CHK_ROOT_SSH_PWD" => 0, "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 @noblock_ips_def = ( "127.0.0.1", ); ############################################################################# ### Script code ############################################################################# my $reportmode = 0; # Full report mode my @scanfiles = (); # Files to scan my @noblock_ips = (); # IPs not to block my %filehandles = (); # Global hash holding opened scanned log filehandles my $pid_file = ""; # Name of Maltfilter daemon pid file my $LOGFILE; # Maltfilter logfile handle my %blocklist = (); # IPs currently blocked in Netfilter $blocklist{$ip} = date_blocked # Gathered information about hosts # $statlist{$ip}-> # "date" = latest change # "hits" = number of hits # "reason" = latest reason why added # "reasons" = array of reasons (only set when $reportmode == 1) my %statlist = (); # Gathered information about ignored hits (e.g. hits for tests that are not enabled) # Same fields as in %statlist my %ignorelist = (); ### Check given logfile line for matches sub check_log_line($) { # (1) SSHD scans if (/^(\S+\s+\d+\s+\d\d:\d\d:\d\d)\s+\S+\s+sshd\S*?: (.*)/) { my $mdate = $1; my $merr = $2; # (1.1) Generic login scan attempts if ($merr =~ /^Failed password for invalid user \S+ from (\d+\.\d+\.\d+\.\d+)/) { check_add_entry($1, $mdate, "SSH login scan", "", $settings{"CHK_SSHD"}); } # (1.2) Root SSH login password bruteforcing attempts # NOTICE! Do not enable this setting, if you allow SSH root logins via # password authentication! Mistyping password may get you blocked then. :) elsif (/^Failed password for root from (\d+\.\d+\.\d+\.\d+)/) { check_add_entry($1, $mdate, "Root SSH password bruteforce", "", $settings{"CHK_ROOT_SSH_PWD"}); } } # (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 vuln scan", $tmp, $settings{"CHK_KNOWN_CGI"}); } } } # (3) 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.1) 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"}); } } # (3.2) 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"}); } } } } ############################################################################# ### Status output functionality ############################################################################# sub cmp_ips($$) { my @ipa = split(/\./, $_[0]); my @ipb = split(/\./, $_[1]); for (my $i = 0; $i < 4; $i++) { return -1 if ($ipa[$i] > $ipb[$i]); return 1 if ($ipa[$i] < $ipb[$i]); } return 0; } sub cmp_ip_hits($$$$) { return -1 if ($_[2] > $_[3]); return 1 if ($_[2] < $_[3]); return cmp_ips($_[0], $_[1]); } sub printH($$$$) { my $fh = $_[1]; if ($_[0]) { print $fh "<h".$_[2].">".$_[3]."</h".$_[2].">\n"; } else { my $c = ($_[2] <= 1) ? "=" : "-"; print $fh $_[3]."\n". $c x length($_[3]) ."\n"; } } sub printTD($$$) { my $fh = $_[1]; if ($_[0]) { print $fh "<td>".$_[2]."</td>"; } else { print $fh $_[2]; } } sub printP($$$) { my $fh = $_[1]; if ($_[0]) { print $fh "<p>\n".$_[2]."</p>\n"; } else { print $fh $_[2]."\n"; } } sub printElem { my $fh = $_[1]; if ($_[0]) { print $fh $_[2]; } elsif (defined($_[3])) { print $fh $_[3]; } } sub bb($) { return $_[0] ? "<b>" : ""; } sub eb($) { return $_[0] ? "</b>" : ""; } sub pe($$) { return $_[0] ? "<$_[1]>" : ""; } sub getIP($$) { return $_[0] ? "<a href=\"http://whois.domaintools.com/$_[1]\">$_[1]</a>" : $_[1]; } sub generate_status($$) { my $filename = shift; my $m = shift; return unless ($filename ne ""); open(STATUS, ">", $filename) or die("Could not open '".$filename."'!\n"); my $f = \*STATUS; my $mtime = scalar localtime(); printElem($m, $f, " <html> <head> <title>Maltfilter status report</title> "); printElem($m, $f, "<link href=\"".$settings{"STATUS_FILE_CSS"}."\" rel=\"stylesheet\" type=\"text/css\" />") if ($settings{"STATUS_FILE_CSS"}); printElem($m, $f, " </head> <body> "); printH($m, $f, 1, "Maltfilter v$progversion status report"); my $val = $settings{"WEEDPERIOD"}; my $period; if ($val > 30 * 24) { $period = sprintf("%1.1f months", $val / (30.0 * 24.0)); } elsif ($val > 24 * 7) { $period = sprintf("%1.1f weeks", $val / 24); } elsif ($val > 24) { $period = sprintf("%d days", $val / 24); } else { $period = sprintf("%d hours", $val); } printP($m, $f, "Generated ".bb($m).$mtime.eb($m).". Data computed from ". ($reportmode ? "complete logfile scan" : "a period of last $period").".\n"); printH($m, $f, 2, $reportmode ? "Detailed report" : "Blocked entries"); printElem($m, $f, "<table>\n<tr>". "<th>Hits</th><th>IP-address</th><th>Date of last hit</th><th>Reason(s)</th>"."</tr>\n"); my $nexcluded = 0; my $ntotal = 0; foreach my $mip (sort { $hitcount{$b} <=> $hitcount{$a} } keys %iplist) { $nexcluded++ if check_hosts_array(\@noblock_ips, $mip); printElem($m, $f, " <tr>"); printTD($m, $f, sprintf("%-10d", $hitcount{$mip})); printTD($m, $f, sprintf("%-15s", getIP($m, $mip))); printElem(!$m, $f, " : "); printTD($m, $f, scalar localtime($iplist{$mip})); my @s = (); foreach my $cond (sort keys %{$reason{$mip}}) { my $str; if ($reportmode) { my @tmp = reverse(@{$reason{$mip}{$cond}}); $#tmp = 5 if ($#tmp > 5); $str = join(" | ", @tmp); } else { $str = $reason{$mip}{$cond}; } push(@s, bb($m).$cond.eb($m)." [".$reason_n{$mip}{$cond}." hits] (".$str.")"); } printTD($m, $f, join(", ".($m ? "<br />" : ""), @s)); printElem($m, $f, "</tr>\n", "\n"); $ntotal++; } printElem($m, $f, "</table>\n"); printP($m, $f, bb($m).$ntotal.eb($m)." entries listed, ". bb($m).($ntotal - $nexcluded).eb($m)." blocked, ".bb($m).$nexcluded.eb($m). " excluded (defined in NOBLOCK_IPS).\n"); printH($m, $f, 2, "Overview of hits in general"); printP($m, $f, "List of 'hits' of suspicious activity noticed by Maltfilter, but not necessarily acted upon.\n"); my $tmp = "<th>IP-address</th><th># of hits</th><th>Reasons</th>"; printElem($m, $f, "<table>\n<tr>". $tmp."<th> </th>".$tmp ."</tr>\n"); my $hits = 0; my @keys = sort { cmp_ips($a, $b) } keys %hitcount; my $nkeys = scalar @keys; my $printEntry = sub { printTD($m, $f, sprintf("%-15s", getIP($m, $_[0]))); printElem(!$m, $f, " : "); printTD($m, $f, sprintf("%-8d ", $hitcount{$_[0]})); printElem(!$m, $f, " : "); my $tmp = join(", ", sort keys %{$reason{$_[0]}}); printTD($m, $f, sprintf("%-30s", $tmp)); $hits += $hitcount{$_[0]}; }; my $kmax = $nkeys / 2; for (my $i = 0; $i <= $kmax; $i++) { printElem($m, $f, " <tr>"); if ($i < $kmax) { &$printEntry($keys[$i]); } printElem($m, $f, "<th> </th>", " || "); if ($i + $kmax + 1 < $nkeys) { &$printEntry($keys[$i + $kmax + 1]); } printElem($m, $f, "</tr>\n", "\n"); } printElem($m, $f, "</table>\n"); printP($m, $f, bb($m).(scalar keys %hitcount).eb($m)." IPs total, ".bb($m).$hits.eb($m)." hits total.\n"); printH($m, $f, 2, "Ignored hit types"); printP($m, $f, "List of hits that were ignored (not acted upon), because the test was disabled.\n"); printElem($m, $f, "<table>\n<tr><th>IP-address</th><th>Type (hits, last time of note)</th></tr>\n"); foreach my $mip (sort { cmp_ips($a, $b) } keys %ignored) { printElem($m, $f, "<tr>"); printTD($m, $f, sprintf("%-15s", $mip)); printElem($m, $f, "<td>", " : "); foreach my $mcond (sort keys %{$ignored{$mip}}) { my $s = $mcond." (".$hitcount{$mip}." hits, last ".scalar localtime($ignored_d{$mip}{$mcond}).")"; unless ($ignored{$mip}{$mcond} eq "") { $s .= " for '".$ignored{$mip}{$mcond}."'"; } print $f $s; } printElem($m, $f, "</td></tr>", "\n"); } printElem($m, $f, "</table>\n"); printElem($m, $f, "</body>\n</html>\n"); close(STATUS); } ############################################################################# ### Entry management / handling functions ############################################################################# ### Host and IP matching functions sub check_hosts_array($$) { my $chk_host = $_[1]; my $chk_ip = new Net::IP($chk_host); foreach my $host (@{$_[0]}) { if ($chk_host eq $host) { return 1; } my $ip = new Net::IP($host); if (defined($chk_ip) && defined($ip)) { if ($chk_ip->binip() eq $ip->binip()) { return 1; } } } return 0; } sub check_hosts($$) { my @tmp = split(/\s*\|\s*/, $_[0]); return check_hosts_array(\@tmp, $_[1]); } ### Execute iptables sub exec_iptables(@) { my @args = ($settings{"IPTABLES"}, @_); if ($settings{"DRY_RUN"}) { mlog(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($) { my $mdate = $_[0]; 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*$/) { my $mcount = $1; my $mip = $2; if (!defined($blocklist{$mip}) && $mdate >= 0) { mlog(2, "* $mip appeared in iptables, adding.\n"); $hitcount{$mip} = $settings{"THRESHOLD"}; check_add_entry($mip, $mdate, "?", "added from iptables", 1); } } } close(STATUS); } ### Weed out old entries sub check_time($) { return ($_[0] >= time() - ($settings{"WEEDPERIOD"} * 60 * 60)); } sub weed_do($) { if (defined($blocklist{$_[0]})) { mlog(2, "* Weeding $_[0] ($iplist{$_[0]})\n"); exec_iptables("-D", "INPUT", "-s", $_[0], "-d", "0.0.0.0/0", "-j", $settings{"ACTION"}); } undef($reason{$_[0]}); undef($reason_n{$_[0]}); undef($ignored{$_[0]}); undef($ignored_d{$_[0]}); undef($iplist{$_[0]}); undef($blocklist{$_[0]}); } sub weed_entries() { # Don't weed in report mode. # return if ($reportmode); 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); } } } mlog(-1, "hmm\n"); } ### 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 $mclass = $_[2]; my $mreason = $_[3]; my $mcond = $_[4]; my $cnt = $hitcount{$mip}++; $reason_n{$mip}{$mclass}++; if ($reportmode) { push(@{$reason{$mip}{$mclass}}, $mreason); } else { $reason{$mip}{$mclass} = $mreason; } if ($reportmode || ($cnt >= $settings{"TRESHOLD"} && check_time($mdate))) { my $pat; if (!$mcond) { $ignored{$mip}{$mclass} = $mreason; $ignored_d{$mip}{$mclass} = $mdate; return; } if (!defined($iplist{$mip})) { if (!check_hosts_array(\@noblock_ips, $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 ($mdate > $iplist{$mip}) { $iplist{$mip} = $mdate; } } } } ############################################################################# ### Main helper functions ############################################################################# ### Print log entry sub mlog { my $level = shift; my $msg = shift; if (defined($LOGFILE)) { print $LOGFILE "[".scalar localtime()."] ".$msg if ($settings{"VERBOSITY"} > $level); } elsif ($settings{"DRY_RUN"}) { print STDERR $msg if ($settings{"VERBOSITY"} > $level); } } ### Initialize sub malt_init { mlog(0, "Updating initial blocklist from netfilter.\n"); update_iplist(-1); foreach my $filename (@scanfiles) { local *INFILE; mlog(0, "Parsing ".$filename." ...\n"); open(INFILE, "<", $filename) or die("Could not open '".$filename."'!\n"); $filehandles{$filename} = *INFILE; while (<INFILE>) { chomp; check_log_line($_); } } mlog(0, "Weeding old entries.\n"); weed_entries(); } ### Quick cleanup (not complete shutdown) sub malt_cleanup { foreach my $filename (keys %filehandles) { close($filehandles{$filename}); } } 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, "Received 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 scanning function sub malt_scan { mlog(1, "Entering main scanning loop.\n"); my $counter = -1; while (1) { my %filepos = (); foreach my $filename (keys %filehandles) { for ($filepos{$filename} = tell($filehandles{$filename}); $_ = <$filehandles{$filename}>; $filepos{$filename} = tell($filehandles{$filename})) { chomp; check_log_line($_); } } if ($counter < 0 || $counter++ >= 30) { # 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(); generate_status($settings{"STATUS_FILE_PLAIN"}, 0); generate_status($settings{"STATUS_FILE_HTML"}, 1); } sleep(5); foreach my $filename (keys %filehandles) { seek($filehandles{$filename}, $filepos{$filename}, 0); } } } sub malt_read_config($) { my $filename = $_[0]; my $errors = 0; my $line = 0; open(CONFFILE, "<", $filename) or die("Could not open configuration '".$filename."'!\n"); while (<CONFFILE>) { $line++; 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 "[$filename:$line] 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 ($key eq "NOBLOCK_IPS") { push(@noblock_ips_def, $value); } elsif (defined($settings{$key})) { $settings{$key} = $value; } else { print STDERR "[$filename:$line] Unknown setting '$key' = '$value'\n"; $errors = 1; } } else { print STDERR "[$filename:$line] Syntax error: $_\n"; $errors = 1; } } close(CONFFILE); return $errors; } ############################################################################# ### ### Main program ### ############################################################################# # Setup signal handlers $SIG{'INT'} = 'malt_int'; $SIG{'TERM'} = 'malt_term'; $SIG{'HUP'} = 'malt_hup'; # Print banner and help if no arguments my $argc = $#ARGV + 1; if ($argc < 1) { print $progbanner. "\n". "Usage: maltfilter <pid filename> [config filename] [config filename...]\n". " maltfilter -f [config filename] [config filename...]\n". "-f turns on the full report mode.\n"; exit; } # Test pid file existence unless report mode $pid_file = shift; if ($pid_file eq "-f") { $reportmode = 1; } else { die("'$pid_file' already exists, not starting.\n". "If the daemon is NOT running, remove the pid-file and re-start.\n") if (-e $pid_file); } # Read configuration files if (defined(my $filename = shift)) { # Let user define his/her own logfiles to scan undef(@scanfiles_def); die("Errors in configuration file '$filename', bailing out.\n") unless (malt_read_config($filename) == 0); } # Force dry run mode if we are reporting only if ($reportmode) { $settings{"DRY_RUN"} = 1; $settings{"VERBOSITY"} = 1; } # Clean up certain arrays duplicate entries my %saw = (); @scanfiles = grep(!$saw{$_}++, @scanfiles_def); undef(%saw); @noblock_ips = grep(!$saw{$_}++, @noblock_ips_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"); } # Test existence of iptables if (! -e $settings{"IPTABLES"} || ! -x $settings{"IPTABLES"}) { my $msg = "iptables binary does not exist or is not executable: ".$settings{"IPTABLES"}."\n"; mlog(-1, $msg); die($msg); } mlog(-1, "Not blocking following IPs: ".join(", ", @noblock_ips)."\n"); # Initialize malt_init(); # Fork to background, unless dry-running if ($settings{"DRY_RUN"}) { if ($reportmode) { mlog(-1, "Outputting report files.\n"); generate_status($settings{"STATUS_FILE_PLAIN"}, 0); generate_status($settings{"STATUS_FILE_HTML"}, 1); malt_cleanup(); } else { 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(); } }