# HG changeset patch # User Matti Hamalainen # Date 1250379765 -10800 # Node ID 24babaa1e3316c06aebac5beeabceeec33848b6c # Parent d96229159abcdfe3a1f525734776ff231eac8779 Many cleanups and fixes; Example configuration updated. diff -r d96229159abc -r 24babaa1e331 README --- 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 (C) Copyright 2009 Tecnic Software productions (TNSP) diff -r d96229159abc -r 24babaa1e331 example.conf --- 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 ############################################################################# diff -r d96229159abc -r 24babaa1e331 maltfilter --- 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 \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] ? "" : ""; } - sub eb($) { return $_[0] ? "" : ""; @@ -251,7 +275,7 @@ return $_[0] ? "<$_[1]>" : ""; } -sub getLink($$) +sub get_link($$) { if ($settings{"WHOIS_URL"} ne "") { return $_[0] ? "\n". + "\n". "\n", "Hits | IP-address | First hit | Latest hit | Reason(s)\n" @@ -278,11 +302,11 @@ printElem($m, $f, " "); 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 = ""; - my $str2 = "IP-address | Hits | Latest hit | Class "; + my $str = ""; + my $str2 = "IP-address | Hits | First hit | Latest hit | Class "; printElem($m, $f, - "
HitsIP-addressFirst hitLatest hitReason(s)
IP-addressHitsLatest hitClassIP-addressHitsFirst hitLatest hitClass
\n". $str."".$str ."\n", + "
\n". $str."".$str ."\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, "\n\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 () { + 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 = ();