changeset 40:24babaa1e331

Many cleanups and fixes; Example configuration updated.
author Matti Hamalainen <ccr@tnsp.org>
date Sun, 16 Aug 2009 02:42:45 +0300
parents d96229159abc
children b11a56e256a9
files README example.conf maltfilter
diffstat 3 files changed, 196 insertions(+), 112 deletions(-) [+]
line wrap: on
line diff
--- a/README	Sun Aug 16 01:30:13 2009 +0300
+++ b/README	Sun Aug 16 02:42:45 2009 +0300
@@ -1,4 +1,4 @@
-Malicious Attack Livid Termination Filter daemon (maltfilter) v0.11.0
+Malicious Attack Livid Termination Filter daemon (maltfilter) v0.12.0
 =====================================================================
 Programmed by Matti 'ccr' Hämäläinen <ccr@tnsp.org>
 (C) Copyright 2009 Tecnic Software productions (TNSP)
--- a/example.conf	Sun Aug 16 01:30:13 2009 +0300
+++ b/example.conf	Sun Aug 16 02:42:45 2009 +0300
@@ -68,16 +68,61 @@
 #############################################################################
 ## Enabled checks (1 = enabled, 0 = disabled). Please read the test
 ## descriptions from "check_log_line" function in the maltfilter script.
+
+# (1) SSHD scans
+## (1.1) Generic login scan attempts.
+## Bruteforce attempts of login/password combinations leads to lots of
+## "Failed password for invalid user" errors. This check catches them.
 CHK_SSHD            = 1
+
+## (1.2) Root account SSH login password bruteforcing attempts.
+## This check catches failed password logins for root account.
+##
+## NOTICE! Do not enable this setting, if you allow SSH root logins via
+## password authentication! Mistyping password may get you blocked unless
+## your host IP is defined in NOBLOCK_IPS. If you wish to enable this
+## check, you should set "PermitRootLogin" to "without-password" or "no"
+## in your sshd_config.
+CHK_ROOT_SSH_PWD    = 0
+
+## (1.3) System account SSH login password bruteforcing attempts.
+## Catches failed password logins for system accounts.
+##
+## NOTICE! If you enable this setting, make sure have defined safe
+## host IPs in NOBLOCK_IPS, and that your system DOES NOT have passwords
+## for system accounts .. which would be stupid anyway.
+CHK_SYSACCT_SSH_PWD = 0
+
+## Set range of system account UIDs here, default is 1-100.
+## Root account is handled by CHK_ROOT_SSH_PWD check.
+#SYSACCT_MIN_UID     = 1
+#SYSACCT_MAX_UID     = 100
+
+
+# (2) Common/known vulnerable 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.
 CHK_KNOWN_CGI       = 1
-CHK_PHP_XSS         = 1
-CHK_PROXY_SCAN      = 1
+
+
+# (3) pache common logging format checks
+## With CHK_GOOD_HOSTS setting you can define hostnames and IPs
+## which do not cause section (3) checks to trigger. For example
+## if your website uses local URL pointers, you should define
+## the hostname(s) and IPs here.
 #CHK_GOOD_HOSTS      = "example.org|google.com|74.125.45.100"
 
-## Notice! ONLY enable this setting, if you have disabled password root
-## logins from sshd_config (e.g. you have "PermitRootLogin without-password")
-## or that alternatively you have defined "safe" hosts in NOBLOCK_IPS.
-CHK_ROOT_SSH_PWD    = 0
+## (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.
+CHK_PHP_XSS         = 1
+
+## (3.2) Try to match proxy scanning attempts
+## Certain attempts to find open HTTP proxies are caught by this check.
+CHK_PROXY_SCAN      = 1
 
   
 #############################################################################
--- a/maltfilter	Sun Aug 16 01:30:13 2009 +0300
+++ b/maltfilter	Sun Aug 16 02:42:45 2009 +0300
@@ -10,14 +10,14 @@
 use Date::Parse;
 use Net::IP;
 
-my $progversion = "0.11.0";
+my $progversion = "0.12.0";
 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
+### Default settings and configuration
 #############################################################################
 my %settings = (
   "VERBOSITY" => 3,
@@ -42,6 +42,11 @@
   "CHK_ROOT_SSH_PWD"    => 0,
   "CHK_SYSACCT_SSH_PWD" => 0,
   "CHK_GOOD_HOSTS"      => "",
+
+  "SYSACCT_MIN_UID"     => 1,
+  "SYSACCT_MAX_UID"     => 100,
+
+  "FULL_TIME"           => 1,
 );
 
 # Default logfiles to monitor (SCANFILES setting of configuration overrides these)
@@ -55,8 +60,70 @@
   "127.0.0.1",
 );
 
+my %systemacct = ();
+
 #############################################################################
-### Script code
+### 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_hit($2, $mdate, "SSH login scan", "", $settings{"CHK_SSHD"});
+    }
+    # (1.2) Root account SSH login password bruteforcing attempts.
+    elsif (/^Failed password for root from (\d+\.\d+\.\d+\.\d+)/) {
+      check_add_hit($1, $mdate, "Root SSH password bruteforce", "", $settings{"CHK_ROOT_SSH_PWD"});
+    }
+    # (1.3) System account SSH login password bruteforcing attempts.
+    if ($merr =~ /^Failed password for (\S+) from (\d+\.\d+\.\d+\.\d+)/) {
+      my $mip = $2; my $macct = $1;
+      if (defined($systemacct{$macct})) {
+        check_add_hit($mip, $mdate, "SSH system account bruteforce", $macct, $settings{"CHK_SYSACCT_SSH_PWD"});
+      }
+    }
+  }
+  # (2) Common/known vulnerable CGI/PHP software scans (like phpMyAdmin)
+  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_hit($mip, $mdate, "CGI vuln scan", $tmp, $settings{"CHK_KNOWN_CGI"});
+      }
+    }
+  }
+  # (3) Apache common logging format checks
+  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
+    if ($merr =~ /\.php\?\S*?=http:\/\/([^\/]+)/) {
+      if (!check_hosts($settings{"CHK_GOOD_HOSTS"}, $1)) {
+        check_add_hit($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_hit($mip, $mdate, "Proxy scan", $merr, $settings{"CHK_PROXY_SCAN"});
+      }
+    }
+  }
+}
+
+
+#############################################################################
+### Global variables
 #############################################################################
 my $reportmode = 0;      # Full report mode
 my @scanfiles = ();      # Files to scan
@@ -65,7 +132,6 @@
 my $pid_file = "";       # Name of Maltfilter daemon pid file
 my @configfiles = ();    # Array of configuration file names
 my $LOGFILE;             # Maltfilter logfile handle
-my %systemacct = ();
 
 # IPs currently blocked in Netfilter $blocklist{$ip} = date
 my %blocklist = ();      
@@ -87,76 +153,6 @@
 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_hit($2, $mdate, "SSH login scan", $1, $settings{"CHK_SSHD"});
-    }
-    # (1.2) Root account 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 unless
-    # your host IP is defined in NOBLOCK_IPS!
-    elsif (/^Failed password for root from (\d+\.\d+\.\d+\.\d+)/) {
-      check_add_hit($1, $mdate, "Root SSH password bruteforce", "", $settings{"CHK_ROOT_SSH_PWD"});
-    }
-    # (1.3) System account SSH login password bruteforcing attempts.
-    # NOTICE! If you enable this setting, make sure have defined safe
-    # host IPs in NOBLOCK_IPS, and that your system DOES NOT have passwords
-    # for system accounts (UID < 100) .. which would be stupid anyway.
-    if ($merr =~ /^Failed password for (\S+) from (\d+\.\d+\.\d+\.\d+)/) {
-      my $mip = $2; my $macct = $1;
-      if (defined($systemacct{$macct})) {
-        check_add_hit($mip, $mdate, "SSH system account bruteforce", $macct, $settings{"CHK_SYSACCT_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_hit($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_hit($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_hit($mip, $mdate, "Proxy scan", $merr, $settings{"CHK_PROXY_SCAN"});
-      }
-    }
-  }
-}
-
-
 #############################################################################
 ### Status output functionality
 #############################################################################
@@ -184,7 +180,7 @@
   return $value;
 }
 
-sub getTimeStr($)
+sub get_time_str($)
 {
   if ($_[0] >= 0) {
     return scalar localtime($_[0]);
@@ -193,6 +189,35 @@
   }
 }
 
+my @paskat = (30*24*60*60, 7*24*60*60, 24*60*60, 60*60, 60);
+my @opaskat = ("months", "weeks", "days", "hours", "minutes");
+my @upaskat = ("month", "week", "day", "hour", "minute");
+
+sub get_ago_str($)
+{
+  return get_time_str($_[0]) if ($settings{"FULL_TIME"});
+  if ($_[0] >= 0) {
+    my $str = "";
+    my $cur = time() - $_[0];
+    my ($r, $k, $p, $n);
+    $n = 0;
+    foreach my $div (@paskat) {
+      $r = int($cur / $div);
+      $k = ($cur % $div);
+      if ($r > 0) {
+        $p = ($r > 1) ? $opaskat[$n] : $upaskat[$n];
+        $str .= ", " if ($str ne "");
+        $str .= sprintf("%d %s", $r, $p);
+      }
+      $cur = $k;
+      $n++;
+    }
+    return $str." ago";
+  } else {
+    return "?";
+  }
+}
+
 sub printH($$$$)
 {
   my $fh = $_[1];
@@ -240,7 +265,6 @@
   return $_[0] ? "<b>" : "";
 }
 
-
 sub eb($)
 {
   return $_[0] ? "</b>" : "";
@@ -251,7 +275,7 @@
   return $_[0] ? "<$_[1]>" : "";
 }
 
-sub getLink($$)
+sub get_link($$)
 {
   if ($settings{"WHOIS_URL"} ne "") {
     return $_[0] ? "<a href=\"".$settings{"WHOIS_URL"}.$_[1].
@@ -261,13 +285,13 @@
   }
 }
 
-sub printTable1($$$$$)
+sub print_table1($$$$$$)
 {
-  my ($m, $f, $table, $keys, $func) = @_;
+  my ($m, $f, $table, $keys, $func, $class) = @_;
   my $ntotal = 0;
 
   printElem($m, $f,
-  "<table class=\"detailed\">\n".
+  "<table class=\"".$class."\">\n".
   "<tr><th>Hits</th><th>IP-address</th><th>First hit</th><th>Latest hit</th><th>Reason(s)</th></tr>\n",
 
   "Hits       | IP-address      | First hit                | Latest hit               | Reason(s)\n"
@@ -278,11 +302,11 @@
     printElem($m, $f, " <tr class=\"$blocked\">");
     printTD($m, $f, sprintf(bb($m)."%-10d".eb($m), $table->{$mip}{"hits"}));
     printElem(!$m, $f, " | ");
-    printTD($m, $f, sprintf("%-15s", getLink($m, $mip)));
+    printTD($m, $f, sprintf("%-15s", get_link($m, $mip)));
     printElem(!$m, $f, " | ");
-    printTD($m, $f, getTimeStr($table->{$mip}{"date1"}));
+    printTD($m, $f, get_ago_str($table->{$mip}{"date1"}));
     printElem(!$m, $f, " | ");
-    printTD($m, $f, getTimeStr($table->{$mip}{"date2"}));
+    printTD($m, $f, get_ago_str($table->{$mip}{"date2"}));
     printElem(!$m, $f, " | ");
     my @reasons = ();
     foreach my $class (sort keys %{$table->{$mip}{"reason"}}) {
@@ -309,26 +333,28 @@
 }
 
 
-sub printTable2($$$$$)
+sub print_table2($$$$$$)
 {
-  my ($m, $f, $table, $keys, $func) = @_;
+  my ($m, $f, $table, $keys, $func, $class) = @_;
   my $nhits = 0;
-  my $str = "<th>IP-address</th><th>Hits</th><th>Latest hit</th><th>Class</th>";
-  my $str2 = "IP-address      | Hits      | Latest hit               | Class                         ";
+  my $str = "<th>IP-address</th><th>Hits</th><th>First hit</th><th>Latest hit</th><th>Class</th>";
+  my $str2 = "IP-address      | Hits      | First hit                | Latest hit               | Class                         ";
 
   printElem($m, $f,
-  "<table class=\"summary\">\n<tr>". $str."<th> </th>".$str ."</tr>\n",
+  "<table class=\"".$class."\">\n<tr>". $str."<th> </th>".$str ."</tr>\n",
   $str2." || ".$str2."\n");
   
   my $printEntry = sub {
     my $blocked = defined($blocklist{$_[0]}) ? "blocked" : "unblocked";
-    printTD($m, $f, sprintf("%-15s", getLink($m, $_[0])), $blocked);
+    printTD($m, $f, sprintf("%-15s", get_link($m, $_[0])), $blocked);
     printElem(!$m, $f, " | ");
     printTD($m, $f, sprintf("%-8d ", $table->{$_[0]}{"hits"}), $blocked);
     printElem(!$m, $f, " | ");
-    printTD($m, $f, getTimeStr($table->{$_[0]}{"date2"}), $blocked);
+    printTD($m, $f, get_ago_str($table->{$_[0]}{"date1"}), $blocked);
     printElem(!$m, $f, " | ");
-    my $tmp = join(", ", sort keys %{$table->{$_[0]}{"reason"}}, $blocked);
+    printTD($m, $f, get_ago_str($table->{$_[0]}{"date2"}), $blocked);
+    printElem(!$m, $f, " | ");
+    my $tmp = join(", ", sort keys %{$table->{$_[0]}{"reason"}});
     printTD($m, $f, sprintf("%-30s", $tmp), $blocked);
     $nhits += $table->{$_[0]}{"hits"};
   };
@@ -415,7 +441,7 @@
   my $period = get_period($settings{"WEED_GLOBAL"});
 
   printP($m, $f,
-  "Generated ".bb($m).getTimeStr(time()).eb($m).". Data computed from ".
+  "Generated ".bb($m).get_time_str(time()).eb($m).". Data computed from ".
   ($reportmode ? "complete logfile scan" : "a period of last $period").".\n");
 
   printP($m, $f, "The hit classes marked as 'IPTABLES' are a pseudo-class meaning an\n".
@@ -425,16 +451,16 @@
   $period = get_period($settings{"WEED_BLOCK"});
   printP($m, $f, "List of IPs that are currently blocked (or would be, if this is\n".
   "a report-only mode). Data from period of $period.\n");
-  printTable1($m, $f, \%statlist, \%blocklist, \&cmp_hits);
+  print_table1($m, $f, \%statlist, \%blocklist, \&cmp_hits, "blocked");
 
   printH($m, $f, 2, "Summary of non-ignored entries");
   printP($m, $f, "List of 'hits' of suspicious activity noticed by Maltfilter, but not\n".
   "necessarily acted upon. Sorted by descending IP address.\n");
-  printTable2($m, $f, \%statlist, \%statlist, \&cmp_ips);
+  print_table2($m, $f, \%statlist, \%statlist, \&cmp_ips, "global");
 
   printH($m, $f, 2, "Ignored entries");
   printP($m, $f, "List of hits that were ignored (not acted upon), because the test was disabled.\n");
-  printTable1($m, $f, \%ignorelist, \%ignorelist, \&cmp_hits);
+  print_table1($m, $f, \%ignorelist, \%ignorelist, \&cmp_hits, "ignored");
 
   printElem($m, $f, "</body>\n</html>\n");
   close(STATUS);
@@ -529,7 +555,7 @@
 sub weed_do($)
 {
   my $mtime = $blocklist{$_[0]};
-  mlog(2, "* Weeding $_[0] (".getTimeStr($mtime)."\n");
+  mlog(2, "* Weeding $_[0] (".get_time_str($mtime)."\n");
   exec_iptables("-D", "INPUT", "-s", $_[0], "-d", "0.0.0.0/0", "-j", $settings{"ACTION"});
   delete($blocklist{$_[0]});
   delete($statlist{$_[0]});
@@ -558,7 +584,7 @@
     if (defined($statlist{$mip})) {
       my $mtime = $statlist{$mip}{"date2"};
       if (!check_time2($mtime) && !defined($blocklist{$mip})) {
-        mlog(3, "* Deleting stale $mip (".getTimeStr($mtime).")\n");
+        mlog(3, "* Deleting stale $mip (".get_time_str($mtime).")\n");
         delete($statlist{$mip});
       }
     }
@@ -568,7 +594,7 @@
     if (defined($ignorelist{$mip})) {
       my $mtime = $ignorelist{$mip}{"date2"};
       if (!check_time2($mtime)) {
-        mlog(3, "* Deleting stale ignored $mip (".getTimeStr($mtime).")\n");
+        mlog(3, "* Deleting stale ignored $mip (".get_time_str($mtime).")\n");
         delete($ignorelist{$mip});
       }
     }
@@ -653,12 +679,13 @@
   my $level = shift;
   my $msg = shift;
   if ($LOGFILE) {
-    print $LOGFILE "[".getTimeStr(time())."] ".$msg if ($settings{"VERBOSITY"} > $level);
+    print $LOGFILE "[".get_time_str(time())."] ".$msg if ($settings{"VERBOSITY"} > $level);
   } elsif ($settings{"DRY_RUN"}) {
     print STDERR $msg if ($settings{"VERBOSITY"} > $level);
   }
 }
 
+### Like Perl's die(), but also print a logfile entry.
 sub mdie($)
 {
   mlog(-1, $_[0]);
@@ -669,7 +696,6 @@
 sub malt_init
 {
   mlog(0, "Updating initial blocklist from netfilter.\n");
-  malt_configure();
   update_blocklist();
 
   foreach my $filename (@scanfiles) {
@@ -799,6 +825,10 @@
         mlog(-1, "[$filename:$line] Unknown setting '$key' = '$value'\n");
         $errors = 1;
       }
+      # Force dry run mode if we are reporting only
+      if ($reportmode) {
+        $settings{"DRY_RUN"} = 1;
+      }
     } else {
       mlog(-1, "[$filename:$line] Syntax error: $_\n");
       $errors = 1;
@@ -819,6 +849,20 @@
     mdie("Errors in configuration file '$filename', bailing out.\n")
       unless (malt_read_config($filename) == 0);
   }
+
+  mdie("SYSACCT_MIN_UID must be >= 1.\n") unless ($settings{"SYSACCT_MIN_UID"} >= 1);
+  mdie("SYSACCT_MAX_UID must be >= SYSACCT_MIN_UID.\n") unless ($settings{"SYSACCT_MAX_UID"} >= $settings{"SYSACCT_MIN_UID"});
+  my $passfile = "/etc/passwd";
+
+  mlog(0, "Reading $passfile for system accounts.\n");
+  open(PASSWD, "<", $passfile) or mdie("Could not open '".$passfile."' for reading!\n");
+  while (<PASSWD>) {
+    my @fields = split(/\s*:\s*/);
+    if ($fields[2] >= $settings{"SYSACCT_MIN_UID"} && $fields[2] <= $settings{"SYSACCT_MAX_UID"}) {
+      $systemacct{$fields[0]} = $fields[2];
+    }
+  }
+  close(PASSWD);
 }
 
 #############################################################################
@@ -859,11 +903,6 @@
 
 malt_configure();
 
-# 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 = ();