Category: Security Tools
Author/Contact Info Matt Joubert
Description: sshmon was written to fend of some of the ssh brute force attacks I seem to get at least 1 or 2 a day of. if the failed attempts get to high, then the script adds a reject route into the routing table and poof, problem solved. For CentOS 4 - make sure to add a log facility or change it.. I used local1.
#!/usr/bin/perl
# This script watches the /var/log/secure for failed ssh attempts.  Wh
+en found, it will add a reject route to your routing table, making it
+ impossible for your machine to communicate with that host again.
#
# Written By: Matt Joubert (matt __at__ uucp _dot_ ca)
# Date: June 2, 2006
#
use strict;
use warnings;
use Sys::Syslog;

# define ssh log file location
my $logFile = '/var/log/secure';

# syslog facility
my $syslogFac = 'local0';
# syslog level
my $syslogLevel = 'info';

# SAFE IPS, NEVER BLOCK  (I sometimes forget a password..)
my @safe = qw/ 192.168.0.1 /;

# define threshold in minutes to reset the failed attempt counter
# note, this shouldn't be nuts because if set to high, you could shoot
+ yourself in the foot
my $thresHold   = 10;    # minutes
my $maxAttempts = 5;

##### Probably wont need to change anything below #####

use File::Tail;
use Time::ParseDate;

$thresHold *= 60;        # convert to seconds;

my $fh = File::Tail->new(
  name        => $logFile,
  maxinterval => 10,
  interval    => 10,
  adjustafter => 5
) || die ("could not open log file: $!");

# at this point, the file is open.. so the rest should be able to work
+ fine in fork.
defined(my $pid = fork)   or die "Can't fork: $!";
exit if $pid;

my %db;
my @rejects;
while ( my $line = $fh->read ) {
    # ignore all lines except for the failed ones
    next unless $line =~ /Failed password/ig;
    my @column = split ( /\s+/, $line );

    # grab the IP address
    $line =~ m/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g;
    my $ip = $1;
    next unless $ip;
    next if safe( $ip, @safe );
    my ( $mon, $day, $time ) = (@column)[ 0, 1, 2 ];
    my $epoch = parsedate("$mon $day $time");
    if ( !defined( $db{$ip} ) ) {
        push ( @{ $db{$ip} }, $epoch );
    }
    else {
        push ( @{ $db{$ip} }, $epoch );

        # past threshold.. start over from scratch
        if ( $db{$ip}[ $#{ $db{$ip} } ] - $db{$ip}[ $#{ $db{$ip} } - 1
+ ] > $thresHold ) {
            undef( $db{$ip} );
            push ( @{ $db{$ip} }, $epoch );
        }
        else {    # threshold is good - check for attempts

            if ( scalar @{ $db{$ip} } > $maxAttempts ) {
                if ( !isRejected( $ip, @rejects ) ) {
                    push ( @rejects, $ip );
                    syslog("$syslogLevel|$syslogFac", "Adding $ip to r
+eject route table for too many failed attempts");
                    system("/sbin/route add -host $ip reject");
                }
            }
        }
    }

}

# check if ip is "safe" from being blocked
sub safe {
    my $ip   = shift;
    my @safe = @_;
    foreach (@safe) {
        return 1 if $ip eq $_;
    }
    return 0;
}

# check if ip has alread been recently reacted too
sub isRejected {
    my $ip      = shift;
    my @rejects = @_;

    foreach (@rejects) {
        return 1 if $ip eq $_;
    }

    return 0;
}
Replies are listed 'Best First'.
Re: SSH Failed Attempt Monitor
by Hue-Bond (Priest) on Jul 06, 2006 at 06:54 UTC

    Just a few random comments from another set of eyes:

    #!/usr/bin/perl # This script watches the /var/log/secure

    So it runs as root, but you're not using taint mode. No bugs at first sight, but you never know.

    my @safe = qw/ 192.168.0.1 /; [Some code] my @rejects;

    Why not define @rejects just under my @safe since it serves a similar purpose?

    next unless $line =~ /Failed password/ig;

    If I were you, I'd tight that regex a little more to avoid false positives, althought that has the drawback that maybe in a future version of the sshd daemon, the message about failed login attempts changes and suddenly you don't seem to be attacked anymore ;^). Also, why /ig?

    if ( !defined( $db{$ip} ) ) { push ( @{ $db{$ip} }, $epoch ); } else { push ( @{ $db{$ip} }, $epoch );

    The push could be moved outside the if. Plus, is there more information stored in $db{$ip}? You seem to store only the IP, so there's no need to have a hash and use push and undef below. Just $db{$ip} = $epoch.

    my @safe = @_; foreach (@safe) { return 1 if $ip eq $_; } return 0;

    If you like compact code, try: return !!grep { $ip eq $_ } @_ ## tested

    sub isRejected {

    Either rename this to rejected, or safe to isSafe.

    foreach (@rejects) {

    Same grep as before.

    --
    David Serrano

      >So it runs as root, but you're not using taint mode. No bugs at first sight, but you never know.

      True, however it should run as is in taint mode. The only input per say is the entry in the log file. The IP address is pulled out with a regex and $1 is reassigned to $ip.. Therefor when system() is called, its taint free and shouldn't ever be an issue. By all means, turn it on if its a concern.

      >If I were you, I'd tight that regex a little more to avoid false positives, althought that has the drawback that maybe in a future version of the sshd daemon, the message about failed login attempts changes and suddenly you don't seem to be attacked anymore ;^). Also, why /ig?

      /g -- the g isn't needed, just a habbit
      /i -- same reason as you mentioned above. a little bit of a loose regular expression, but hopefully will work if the log format changes a little bit.
      @safe and @reject are two entities, kept apart for readability purposes, you are correct that other ways exist, perhaps even better ways.

      the $db{$ip} stores more than IP.. it also stores the count, and epoch times of the event. The count being the number of array elements, and the times are the element stores as an epoch time:

      if ( $db{$ip}[ $#{ $db{$ip} } ] - $db{$ip}[ $#{ $db{$ip} } - 1 +] > $thresHold ) {


      Here I say .. if the last array item (which holds the highest epoch time) minus the previous attempts epoch time is greater than the threshold (ie. 10 minute), then clear the current attack and treat as a new attack. I'm basically just clearing the entire record of previous attack. The else of that is the handler for ongoing attack -- increase count or reject the attacker.


      I like your grep suggestion as well, although I opted foreach because its slightly more readable. That's the only real reason for that one.

      And yes, the subroutine names are inconsistent, good point. That will be fixed.

      Matt