Security Basics mailing list archives

Re: Massive failed FTP attempts.


From: Robert Bauer <rbauer () snowcompanies com>
Date: Fri, 07 Sep 2007 09:11:47 -0400

I realized after sending this that our email server, which blocks most
non-US domains (to limit spam) will also block legitimate requests for
the script.  Sorry, world!

So, if the moderator will allow it, here is the script.  It is based
rather heavily on an ssh lockout script written by someone else.  I just
made it a little more generic so that it could monitor any log file.
I'm no perl whiz, so please forgive me you see some grossly inefficient
code in there.  (It has received the coveted "it works for me" seal of
approval...)

To run as a daemon and monitor proftpd, sending lockout notifications to
blah () blah org:

lockout.pl --log=/var/log/proftpd.log \
--pat="^(.*) UNKNOWN root .*PASS .* 530 -$" \
--daemon --mailto=blah () blah org

By default, it allows 5 bad attempts (within 60 seconds) before it locks
out a host.  The lockout expires in one hour.  These parameters are all
configurable with command line options.

If you're using the stock perl distribution, you will probably have to
install (IIRC) NetAddr::IP, Sys::Syslog, and Mail::Send.

Hope this helps!
Robert

Robert Bauer wrote:
I use a log-monitoring perl script (similar to what many have done for ssh) which locks out offending hosts via iptables. If you're interested, I'll email it to you.

Robert


Michael Nielson wrote:
I run several small LAMP virtual servers, I've noticed a large amount of failed FTP login attempts, these all attempt to login with common FTP usernames like Administrator, or webmaster (the FTP server is proFTPd version 1.2.10). The attacker will try from one IP address maybe 30 or 40 times and then moving to a new IP address. I have several questions, first what are they trying to do? Crack my password? Or exploit a bug with proftpd? I've been more diligent about choosing a difficult to break password. More important what can I do to limit the number of attempts on my server? Thanks tons!
Michael



#!/usr/bin/perl -T
#
# lockout.pl
#
# A generic logfile watcher and system locker-outer
#
# Based *heavily* on SSH Lockout 0.4.0, (C) 2004,2005 Corey Edwards
#
# This file is free software; you can redistribute it and/or modify it
# under the same terms as Perl itself.
#
# This program is distributed in the hope that it will be useful, but
# without any warranty; without even the implied warranty of
# merchantability or fitness for a particular purpose.
#

use strict qw(vars);
use Getopt::Long;
use Socket;
use NetAddr::IP;
use Sys::Syslog;
require Mail::Send;

# config vars & default values, set/overridden by command line
my %CFG = (
        log_file => "", # given on cmdline
        state_file => "", # built at runtime
        pid_file => "", # built at runtime
        whitelist => '127.0.0.1',
        ban_time => 3600, # one hour
        tries => 5,
        timeout => 60, # one minute
        daemon => 0,
        debug => 0,
        pats => [],
        mailto => '',

        ip4_block_cmd => 'iptables -I INPUT -p tcp -s %s -j DROP',
        ip4_unblock_cmd => 'iptables -D INPUT -p tcp -s %s -j DROP',
        ip6_block_cmd => 'ip6tables -I INPUT -p tcp -s %s -j DROP',
        ip6_unblock_cmd => 'ip6tables -D INPUT -p tcp -s %s -j DROP',
);

$ENV{'PATH'} = '/bin:/sbin:/usr/bin:/usr/sbin';
delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};

# process command-line opts
my $result = GetOptions(
        "logfile=s"   => \$CFG{log_file},
        "pattern=s"   => $CFG{pats},
        "bantime:i"   => \$CFG{ban_time},
        "tries:i"     => \$CFG{tries},
        "timeout:i"   => \$CFG{timeout},
        "daemon!"     => \$CFG{daemon},
        "debug!"      => \$CFG{debug},
        "mailto=s"    => \$CFG{mailto},
        "whitelist=s" => \$CFG{whitelist}
);
if (!$result) {
        &usage;
        exit 1;
}

# make sure logfile and pattern were given
if (!$CFG{log_file} || @{$CFG{pats}}==0) {
        print "Error: Missing one or more required parameters\n";
        &usage;
        exit 1;
}

# make sure logfile is readable
if (!-r $CFG{log_file}) {
        print "Error: $CFG{log_file} is non-existant or unreadable\n";
        exit 1;
}

# create unique identifier from log file name
my $tmp_log_file = $CFG{log_file};
$tmp_log_file =~ s/\//_/g; # replace / with _
$tmp_log_file =~ s/\./_/g; # replace . with .

# create state, pid file names
$CFG{state_file} = "/var/run/lockout-$tmp_log_file.state";
$CFG{pid_file} = "/var/run/lockout-$tmp_log_file.pid";

# see if another lockout process is already monitoring
# this log file
if ( &is_running($CFG{pid_file}) ) {
        print "Error: Another process is already monitoring $CFG{log_file}\n";
        exit 1;
}

# show parm info if debugging
if ($CFG{debug}) {
        print "patterns:\n";
        foreach ( @{$CFG{pats}} ) {
                print "  pat = $_\n";
        }
        print "state_file: $CFG{state_file}\n";
        print "pid_file: $CFG{pid_file}\n";
}

&logger("starting", 1);
&logger("monitoring $CFG{log_file}", 1);

# set signal handlers
$SIG{HUP} = \&save_state;
$SIG{INT} = \&exit_graceful;
$SIG{SEGV} = \&exit_graceful;
$SIG{TERM} = \&exit_graceful;

# daemonize if so requested
if ($CFG{daemon}){
        &logger("forking off", 2);
        exit 0 if (fork());
        chdir "/";
        close(STDERR);
        close(STDOUT);
        close(STDIN);
}

&make_pid_file;

# load previous ips and delete any stale rules
my %blacklist = ();
my %log_tries = ();
my %log_times = ();
my %log_lines = ();
&read_state;
&prune_old_entries;

my @whitelist;
&scan_whitelist;

# main stuff

my @stat = stat($CFG{log_file});
my $last_size = $stat[7];
open(LOG, $CFG{log_file}) || die &logger("open $CFG{log_file} failed: $?", 1);
seek(LOG, 0, 2);
my $timer = 0;
while (1){
        @stat = stat($CFG{log_file});
        seek(LOG, $last_size, 0);
        # if file is bigger than it was last time
        if ($stat[7] > $last_size){
                $_ = <LOG>;
                my $loglinelen = length($_); # important to do this before calling check_log
                &check_log($_);
                $last_size += $loglinelen;
        }
        # if file is smaller than it was last time (it was rotated)
        elsif ($stat[7] < $last_size){
                close(LOG);
                open(LOG, $CFG{log_file}) || die &logger("open $CFG{log_file} failed: $?", 1);
                $last_size = 0;
        }
        else{
                if ($timer++ > 60){
                        &prune_old_entries;
                        &save_state;
                        $timer = 0;
                }
                sleep 1;
        }
}

# shouldn't happen, but...
&exit_graceful;

# go through blacklist, look for expired entries
# if expired, the ip is unblocked and the entry is deleted
sub prune_old_entries
{
        if ($CFG{debug}) {
                &logger("prune_old_entries - vardump:", 2);
                foreach my $ip (keys(%blacklist)){
                        &logger("  ip=$ip, blacklist=$blacklist{$ip}, log_times=$log_times{$ip}, 
log_tries=$log_tries{$ip}", 2);
                }
        }
        my $now = time();
        foreach my $ip (keys(%blacklist)){
                my $elapsed = $now - $blacklist{$ip};
                &logger("checking $ip for ban expiration: now=$now, ban_time=$blacklist{$ip}, elapsed=$elapsed", 2);
                if ($elapsed >= $CFG{ban_time}){
                        &logger("$ip ban has expired", 1);

                        delete $blacklist{$ip};
                        delete $log_times{$ip};
                        delete $log_tries{$ip};
                        delete $log_lines{$ip};

                        &handle_ip('unblock', $ip);
                }
        }
}

# build whitelist array from whitelist CFG var
# this is called at startup
sub scan_whitelist
{
        my @ips = split(/ /, $CFG{whitelist});
        foreach my $ip (@ips){
                &logger("whitelisting $ip", 2);
                push @whitelist, new NetAddr::IP $ip;
        }
}

# check if a given ip is on the whitelist
sub is_whitelisted
{
        $_ = shift;
        # ipv6 isn't supported yet :(
        s/.*:// if (/::ffff:\d*\.\d*\.\d*\.\d*/);
        return 0 if (/:/);
        my $ip = new NetAddr::IP $_;
        foreach my $white (@whitelist){
                return 1 if ($ip->within($white));
        }
        return 0;
}

# check the latest line coming from the monitored log file
# host/ip info is extracted from matching lines, and the ip
# is used to track home many times a matching line occurs
# within a timeout period.  if this count exceeds a threshold,
# the ip address is blocked
sub check_log
{
        my $line = shift;
        chomp $line;
        #&logger("check_log: $line", 2);
        my $host = "";
        my $pat;
        foreach $pat ( @{$CFG{pats}} ) {
                if ($line =~ /$pat/) {
                        &logger("check_log: matched $pat", 2);
                        $host = $1;
                        last;
                }
        }

        if ($host) {
                my $ip = gethostbyname($host);
                $ip = inet_ntoa($ip);

                if (&is_whitelisted($ip)){
                        &logger("$host ($ip) is from whitelisted range", 1);
                        return;
                }

                # see how recently they failed
                my $now = time();
                if (defined $log_times{$ip} && ($now - $log_times{$ip} < $CFG{timeout})){
                        &logger("now=$now, host=$host, ip=$ip: within timeout period, incrementing tries", 2);
                        $log_times{$ip} = $now;
                        $log_tries{$ip}++;
                }
                else{
                        &logger("now=$now, host=$host, ip=$ip: first try, commencing timeout period", 2);
                        $log_times{$ip} = $now;
                        $log_tries{$ip} = 1;
                }
                push @{ $log_lines{$ip} }, $line;

                # this happens occasionally - because sometimes another log entry sneaks in as we're processing the 
last one
                # also - it might be blacklisted but not have any accumulated enough attempts - allow these to fall 
through
                if (defined $blacklist{$ip}){
                        &logger("$host ($ip) is from blacklisted address, tries=$log_tries{$ip}", 1);
                        if ($log_tries{$ip} > $CFG{tries}) {
                                return;
                        }
                } else {
                        &logger("$host ($ip), tries=$log_tries{$ip}", 1);
                }

                # if they've tried too many times
                if ($log_tries{$ip} >= $CFG{tries}){
                        &logger("$host ($ip) too many attempts ($log_tries{$ip} >= $CFG{tries})", 1);
                        # black hole the sucker
                        $blacklist{$ip} = $now;
                        &handle_ip('block', $ip);
                        &notify($ip);
                }
        }
}

# tell someone when a lockout occurs
sub notify {
        my $ip = shift;

        my $to = $CFG{mailto};
        if ($to) {
                $ip =~ s/::ffff://;
                my $iaddr = inet_aton($ip); # or whatever address
                my $name  = gethostbyaddr($iaddr, AF_INET);

                my $msg = new Mail::Send;
                $msg->to($to);
                $msg->subject('Lockout Notification');

                my $fh = $msg->open;
                print $fh "Log file: $CFG{log_file}\n";
                print $fh "Address locked out: $ip ($name)\n";
                print $fh "Matching log line(s):\n";
                foreach my $line ( @{ $log_lines{$ip} } ) {
                        print $fh "$line\n";
                }
                $fh->close;         # complete the message and send it
        }
}

# this actually issues the block/unblock command
sub handle_ip
{
        my $type = shift;
        my $ip = shift;

        # untaint $ip
        $ip =~ /([\d\.:a-f]*)/;
        $ip = $1;

        return if (!($type eq 'block' || $type eq 'unblock'));

        # handle ipv6
        if ($ip =~ /:/){
                my $cmd = sprintf($CFG{'ip6_'.$type.'_cmd'}, $ip);
                &logger("$cmd", 2);
                my $ret = system $cmd;
                if ($ip =~ /^::ffff:(\d+\.\d+\.\d+\.\d+)/){
                        my $cmd = sprintf($CFG{'ip4_'.$type.'_cmd'}, $1);
                        &logger("$cmd", 2);
                        if (!$CFG{debug}) {
                                my $ret = system $cmd;
                        }
                }
        }
        # ipv4
        else{
                my $cmd = sprintf($CFG{'ip4_'.$type.'_cmd'}, $ip);
                &logger("$cmd", 2);
                if (!$CFG{debug}) {
                        my $ret = system $cmd;
                }
        }
}

# save ip blacklist info
# called at shutdown
sub save_state
{
        &logger("saving state:", 2);
        # write banned ips to a file
        open(STATE, ">$CFG{state_file}") || die &logger("can't save state: $?", 1);
        foreach my $ip (sort(keys(%blacklist))){
                print STATE "$ip,$blacklist{$ip}\n";
                &logger("  $ip,$blacklist{$ip}", 2);
        }
        close(STATE);
}

# make sure we clean up on the way out
# called at shutdown
sub exit_graceful
{
        &logger("exiting", 1);
        &save_state;
        close(LOG);
        unlink $CFG{pid_file};
        exit 0;
}

# read saved blacklist info
# called at startup
sub read_state
{
        &logger("reading state:", 2);
        open(STATE, "$CFG{state_file}") || return;
        while (<STATE>){
                chomp;
                s/\#.*//;
                my ($ip, $time) = split(/,/);
                &logger("  $ip,$time", 2);
                if ($ip && $time){
                        $blacklist{$ip} = $time;
                        #$log_times{$ip} = $time;
                }
        }
        close(STATE);
}

# create a pid file for tracking
# called at startup
sub make_pid_file
{
        open(PID, ">$CFG{pid_file}") || die &logger("can't open pid file $CFG{pid_file}: $?", 1);
        print PID $$;
        close(PID);
}

# log info to log file or stdout, depending on 
# mode we are running in
sub logger
{
        my $msg = shift;
        my $level = shift;

        return if ($level > 1 && !$CFG{debug});

        # syslog if we're a daemon
        if ($CFG{daemon}){
                openlog('lockout', 'cons,pid', 'daemon');
                my $priority;
                if ($level > 1){
                        $priority = 'debug';
                }
                else{
                        $priority = 'warning';
                }
                syslog($priority, $msg);
                closelog();
        }
        # print to stdout
        else{
                my $prefix;
                if ($level > 1){
                        $prefix = 'D: ';
                }
                else{
                        $prefix = 'W: '
                }
                print $prefix . $msg . "\n";
        }
}

# the venerable usage info
sub usage {
        print <<EOF;

lockout - Watch log file for pattern[s]; lock out offending host/ip when they are found.
Usage:
lockout  --logfile=file --pattern=regexp [--bantime=n] [--tries=n] [--timeout=n] [--daemon] [--debug] 
[--mailto=address] [--whitelist=ip addrs]
       logfile:   what to monitor (REQUIRED)
       pattern:   regular expression to use for monitoring the logfile (REQUIRED)
                  NOTE: each pattern must contain one subpattern which matches
                  the host name or ip address
       bantime:   how long the lockout lasts (seconds) [$CFG{ban_time}]
       tries:     how many unsuccessful tries before lockout occurs [$CFG{tries}]
       timeout:   maximum time between unsuccessfull attempts (seconds) [$CFG{timeout}]
                  if more time elapses than specified by this parameter,
                  the try counter is reset
       daemon:    causes process to detach from terminal and run in the
                  background; output will be sent to syslog instead
       debug:     causes lots of helpful output to be sent to the terminal
                  or syslog; also prevents actual block/unblock commands from being
                  issued
       mailto:    email address to notify when a lockout occurs
       whitelist: space-separated list of ip addresses (in CIDR format) which will
                  never be locked out [$CFG{whitelist}]
       
Note: you can specify multiple patterns by using the --pattern option multiple times

EOF
}

# check pidfile for another running lockout process
# called at startup
sub is_running {
        my $pidfile = shift;
        open (PIDFILE, "<$pidfile") || return 0;
        my $pid = <PIDFILE>;
        close PIDFILE;
        open (CMDLINE, "</proc/$pid/cmdline") || return 0;
        my $line = <CMDLINE>;
        close CMDLINE;
        $line = join(" ", split("\0", $line));
        return 1 if ($line =~ /$0/);
        return 0;
}




Current thread: