Re: Massive failed FTP attempts.
- From: Robert Bauer <rbauer@xxxxxxxxxxxxxxxxx>
- 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@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);
¬ify($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;
}
- References:
- Massive failed FTP attempts.
- From: Michael Nielson
- Re: Massive failed FTP attempts.
- From: Robert Bauer
- Massive failed FTP attempts.
- Prev by Date: Restrict certain file types on a Windows 2000 share
- Next by Date: Re: "Endpoint" security solutions?
- Previous by thread: Re: Massive failed FTP attempts.
- Next by thread: Re: Massive failed FTP attempts.
- Index(es):
Relevant Pages
|