Re: Massive failed FTP attempts.



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@xxxxxxxx:

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

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;
}





Relevant Pages

  • sitemap generator for Perl
    ... I want to run the sitemap generator ... Returns the minimum number of links to traverse from the root URL of ... my $class = shift; ...
    (perl.beginners)
  • Re: How can I create instantiable objects (not classes)?
    ... a child object inherits not only its parent object's ... sub fee { ... my $class = shift; ... For example, an object of type Car might receive a message named "ticket," and since a car does not know what to do with a ticket, it would pass that message to an object of type Driver. ...
    (comp.lang.perl.misc)
  • Re: passing database data to a sub
    ... > I'm not sure of the difference, why isn't it a subroutine? ... > sure about this 'shift' thing anyway :-) ... > sub teardown ... > # Setup the template to use for the output. ...
    (perl.beginners)
  • Re: Packages and returning errors
    ... > array intact. ... sub is_a_instance_method { ... my $class = shift; ... You need to fix the scope of $error by moving its declaration outside ...
    (comp.lang.perl.misc)
  • Re: Opinion from Doug Robbins and Peter Hewett Requested
    ... but I have no idea how to "output" any other data that the logit class ... >> Public Sub DoSomething() ... >> ' Write something to the log file ... >>> Public Property Let Path ...
    (microsoft.public.word.vba.general)