# HG changeset patch # User Matti Hamalainen # Date 1250165718 -10800 # Node ID fec14263801d84733a2307197c2059c64140976a Initial import of maltfilter development version. diff -r 000000000000 -r fec14263801d COPYING --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/COPYING Thu Aug 13 15:15:18 2009 +0300 @@ -0,0 +1,32 @@ +Malicious Attack Livid Termination Filter daemon (maltfilter) +============================================================= +Programmed by Matti 'ccr' Hämäläinen +(C) Copyright 2009 Tecnic Software productions (TNSP) + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff -r 000000000000 -r fec14263801d README --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README Thu Aug 13 15:15:18 2009 +0300 @@ -0,0 +1,70 @@ +Malicious Attack Livid Termination Filter daemon (maltfilter) v0.7 +================================================================== +Programmed by Matti 'ccr' Hämäläinen +(C) Copyright 2009 Tecnic Software productions (TNSP) + +Distributed under the modified ("3-clause") BSD license. Please see +included file COPYING for more information. + +About +===== +Automagic management script for adding and removing Netfilter/iptables +filtering rules based on continuous logfile parsing for certain break-in +and exploitation scanning attempts. + +Maltfilter daemon script continuously scans various system logfiles +including auth.log, httpd logs, etc. for signs of malicious connections +break-in and exploitation attempts. The originating IP addresses of +these connections are then blocked via Netfilter (iptables). + +Requirements: + + - Perl 5.8 or later + - Date::Parse (libtimedate-perl) + - Net::IP (libnet-ip-perl) + + +Installation +============ +Copy maltfilter script to /usr/sbin and set permissions + +$ cp maltfilter /usr/sbin/maltfilter +$ chmod 755 /usr/sbin/maltfilter +$ chown root:root /usr/sbin/maltfilter + +Copy example configuration under /etc (you may not want to +to have the configuration readable to regular users, so below +example sets mode 600 to it.) + +$ cp example.conf /etc/maltfilter.conf +$ chmod 600 /etc/maltfilter.conf +$ chown root:root /etc/maltfilter.conf + + +Optional +======== +Additionally you can set up the provided Debian style init script: + +$ cp example.init /etc/init.d/maltfilter +$ chmod 755 /etc/init.d/maltfilter +$ chown root:root /etc/init.d/maltfilter + +You need to edit the script, if you didn't install the configuration +and maltfilter to paths described in installation section. + + +Configuration and usage +======================= +See example.conf or /etc/maltfilter.conf for general settings. +I HIGHLY recommend that you carefully think which + +The script itself contains additional information about what +certain scan options actually do. + +Start maltfilter either via the init script or through commandline: + +$ maltfilter /var/run/maltfilter.pid /etc/maltfilter.conf + +If you want to use the init script, you need to edit your init runlevel +settings to enable it, for example in Debian/Ubuntu you can use rcconf(8) +or chkconfig(8). diff -r 000000000000 -r fec14263801d example.conf --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/example.conf Thu Aug 13 15:15:18 2009 +0300 @@ -0,0 +1,51 @@ +## Maltfilter configuration file. +## PLEASE GO THROUGH THIS FILE VERY CAREFULLY! + +# Verbosity level (0 = quiet, bigger values add noise. valid range 0 - 4) +VERBOSITY = 4 + +# Dry-run: 1 = disables daemonization/forking to background, disables +# modification of netfilter/iptables, printing the iptables commands to +# stdout instead. +# NOTICE! IF YOU DON'T CHANGE THIS TO 0, MALTFILTER WILL NOT DAEMONIZE! +DRY_RUN = 1 + +# Define system log files to scan. Only auth.log and Apache errorlog / +# common log format files are supported for now. You can have as many +# of SCANFILE settings as you wish. +SCANFILE = "/var/log/auth.log" +SCANFILE = "/var/log/httpd/error.log" +SCANFILE = "/var/log/httpd/access.log" + + +# Weeding treshold in hours. Entries older than this will be "weeded" +# off from current netfilter settings. +WEEDPERIOD = 72 + +# How many "hits" the IP needs until it is eligible to be blocked. +# (the "hits" can be from any "source", e.g. sshd crack, httpd, etc.) +TRESHOLD = 3 + +# Target iptables action for added entries, default is DROP, but you +# can use whatever rule chain name you want to here. +ACTION = "DROP" + +# Enabled checks (1 = enabled, 0 = disabled). Please read the test +# descriptions from "check_log_line" function in the maltfilter script. +CHK_SSHD = 1 +CHK_KNOWN_CGI = 1 +CHK_PHP_XSS = 1 +CHK_PROXY_SCAN = 1 +CHK_GOOD_HOSTS = "example.org|google.com|74.125.45.100" + +# Maltfilter logfile path and name (set empty "" if you don't want logging) +LOGFILE = "/var/log/maltfilter" + +# Full path to iptables binary +IPTABLES = "/sbin/iptables" + +# IP(s) NOT to be blocked under any circumstances, separated by pipes (|). +# You should set this if you wish to have a surefire open channel from +# somewhere, even in case someone tries to spoof IPs for denial of service. +# NOTICE! This setting supports only IPv4 addresses, no IPv6 or DNS names. +NOBLOCK_HOSTS = "127.0.0.1|74.125.45.100" diff -r 000000000000 -r fec14263801d example.init --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/example.init Thu Aug 13 15:15:18 2009 +0300 @@ -0,0 +1,121 @@ +#! /bin/sh +### BEGIN INIT INFO +# Provides: maltfilter +# Required-Start: $syslog $remote_fs +# Required-Stop: $syslog $remote_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 1 +# Short-Description: Malicious Attack Livid Termination Filter +### END INIT INFO +# Author: Matti Hamalainen + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="MALicious Termination Filter daemon (maltfilter)" +NAME=maltfilter +DAEMON="/usr/sbin/$NAME" +CONFIG="/etc/maltfilter.conf" +PIDFILE="/var/run/$NAME.pid" +SCRIPTNAME="/etc/init.d/$NAME" + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.0-6) to ensure that this file is present. +. /lib/lsb/init-functions + +# +# Function that starts the daemon/service +# +do_start() +{ + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null || return 1 + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- $PIDFILE $CONFIG || return 2 +} + +# +# Function that stops the daemon/service +# +do_stop() +{ + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME + RETVAL="$?" + [ "$RETVAL" = 2 ] && return 2 + + # Many daemons don't delete their pidfiles when they exit. + rm -f $PIDFILE + return "$RETVAL" +} + +# +# Function that sends a SIGHUP to the daemon/service +# +do_reload() { + # + # If the daemon can reload its configuration without + # restarting (for example, when it is sent a SIGHUP), + # then implement that here. + # + start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE --name $NAME + return 0 +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + reload|force-reload) + log_daemon_msg "Reloading $DESC" "$NAME" + do_reload + log_end_msg $? + ;; + restart) + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|restart|reload|force-reload}" >&2 + exit 3 + ;; +esac + +: diff -r 000000000000 -r fec14263801d maltfilter --- /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 +# (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 \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 () { + 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 () { + 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 [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 () { + 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(); + } +}