view maltfilter @ 3:368182409eac

More variable cleanups.
author Matti Hamalainen <ccr@tnsp.org>
date Thu, 13 Aug 2009 17:44:25 +0300
parents 3da95f3082d9
children b2c7c76b3529
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",
  "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"
);


#############################################################################
### Script code
#############################################################################
my @scanfiles = ();
my %filehandles = ();
my %hitcount = ();
my %iplist = ();
my $pid_file = "";
my $LOGFILE;

### 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"});
      }
    }
  }
}


#############################################################################
### 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($$)
{
  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"}) {
    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($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;
      }
    }
  }
}

###
### 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 (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();
  }
}