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); ¬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; }
Current thread:
- Massive failed FTP attempts. Michael Nielson (Sep 04)
- Re: Massive failed FTP attempts. l00t3r (Sep 04)
- RE: Massive failed FTP attempts. Paul Conaghan (Sep 04)
- RE: Massive failed FTP attempts. whip (Sep 11)
- RE: Massive failed FTP attempts. Dan Denton (Sep 12)
- RE: Massive failed FTP attempts. whip (Sep 11)
- RE: Massive failed FTP attempts. James Finnican (Sep 04)
- RE: Massive failed FTP attempts. Mark Sutton (Sep 05)
- Re: Massive failed FTP attempts. Robert Bauer (Sep 06)
- Re: Massive failed FTP attempts. Robert Bauer (Sep 07)
- Re: Massive failed FTP attempts. Oumar Niane (Sep 11)