Mercurial > hg > maltfilter
view maltfilter @ 8:29ddb6b9b521
Moar changes!
author | Matti Hamalainen <ccr@tnsp.org> |
---|---|
date | Thu, 13 Aug 2009 19:13:07 +0300 |
parents | ee5f7b8dcdea |
children | 26c2cc5077aa |
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 $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", "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 @scanfiles = (); my @noblock_ips = (); my %filehandles = (); my %hitcount = (); my %iplist = (); my $pid_file = ""; my $LOGFILE; ### 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, "SSHD", $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: $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"}); } } } } ############################################################################# ### Script code ############################################################################# 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_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($) { 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_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 ($iplist{$mip} >= 0) { if ($mdate > $iplist{$mip}) { $iplist{$mip} = $mdate; } } else { # Empty date, set it now. $iplist{$mip} = $mdate; } } } } ### ### Utility functions ### sub malt_init { 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 out old entries.\n"); weed_entries(); } sub malt_cleanup { mlog(0, "- Closing open filehandles.\n"); foreach my $filename (keys %filehandles) { close($filehandles{$filename}); } } sub malt_scan { ### Keep on reading mlog(1, "- Entering main scanning loop.\n"); my $counter = 0; 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($_); } } 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 $filename (keys %filehandles) { seek($filehandles{$filename}, $filepos{$filename}, 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 ($key eq "NOBLOCK_IPS") { push(@noblock_ips_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 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 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(); } }