Archivist's note: The successor to this initial version is in subdirectory ssh_sentry, within this directoriy. From rick Date: Mon, 27 Sep 2004 10:28:34 -0400 (EDT) From: Victor Danilchenko To: secureshell@securityfocus.com Subject: Re: OpenSSH -- a way to block recurrent login failures? On Tue, 21 Sep 2004, Victor Danilchenko wrote: > We are looking for a way to temporarily block hosts from which we > receive a given number of sequential failed login attempts, not > necessarily within the same SSH session (so MaxAuthTries is not > enough). The best solution I could come up with so far would be to > run OpenSSH through TCPWrappers, and set up a log watcher daemon which > would edit /etc/hosts.deny on the fly based on the tracked number of > failed logins for each logged host. > > Is there a better solution known for the sort of problems we have been > plagued with lately -- repeated brute-force crack attempts from remote > hosts? I looked on FreshMeat and I searched the mailing lists, only to > come up empty-handed. > > Thanks in advance, Thanks to all who replied with the suggestions. Alas, none of them were quite suitable. The IPTables manipulation is a fine idea, but we need a solution that runs in a very heterogeneous environment. At the very least, we are looking at protecting Redhat Linux, OS/X, and Solaris systems. Portsentry is IMO a little too complicated to easily deploy on a wide number of systems -- we need a fire-and-forget solution (ideally a simple modification to the sshd_config file, but that obviously is not in the cards). In the end, I wrote a perl script that did solved the problem the brute way -- trail the SSHD logs, and modify /etc/hosts.deny on the fly. Attached in this script, should anyone here find it useful. The next logical step would be to turn this into a distributed solution where blacklists could be shared among individual nodes. Would be nice to have a DNS-based blacklisting solution eventually, similar to how SPAMming can be handled by MTAs... Note that the attached script has been stripped of our information before being posted, so a typo or two may have crept in somewhere during the cleanup editing. It currently works on OS/X and Linux, I haven't yed added the code to make it work on Solaris. -- | Victor Danilchenko | When in danger or in doubt, | | danilche@cs.umass.edu | run in circles, scream, and shout. | | CSCF | 5-4231 | Robert Heinlein | [Script is intended to be named "sshd-sentry".] #!/usr/bin/perl -w # Written by Victor Danilchenko, 09/22/2004 # # The purpose of this script is to monitor the sshd logs, detect # repeated failed login attempts, and blacklist the hosts whence # such attempts originate. # # Supports Linux and OS/X # ################################################################# # # Changelog: # 09/22/2004 Victor Danilchenko # Added notification by mail capability, via # direct SMTP injection # ################################################################ use strict; use Getopt::Long; use IO::Seekable; use IO::Socket::INET; my $name = "sshd_sentry"; my $pidfile = "/var/run/$name"; my $hosts_deny = "/etc/hosts.deny"; my $hosts = {}; my @bad_users = sort qw(root user test admin guest); my $baddies = join (", ", @bad_users); my $tag = 'ROBOSENTRY'; my ($help, $file, $restart, $interval, $threshold, $duration, $penalty); my $shost = (split(/\./, (`hostname`)[0]))[0]; chomp $shost; my $mydomain = "yourdomain.com"; my $SMTPserver = "smtp.$mydomain"; my @sysmail = ("sysadmin\@$mydomain"); # Various places where system logs can be found on different platforms my @files = qw(/var/log/messages /var/log/system.log /var/adm/messages); my $file_default; for (@files) { if (-e $_) { $file_default = $_; last;} } my $interval_default = 10; my $threshold_default = 8; my $duration_default = "1 day"; my $penalty_default = 1; sub help () { my $filr = " " x length($name); return << "EOT"; Usage: $name [-h | --help] $filr [-r | --restart ] $filr [-f | --file ] $filr [-i | --interval ] $filr [-t | --threshold ] $filr [-d | --duration ] $filr [-p | --penalty ] help Show this message restart Focibly restart $name, kill current process if needed file Specify the log file name to use default: $file_default interval Number of seconds between polling of the log file default: $interval_default threshold Number of detected failed logins, before the host is blocked. Notice that the user names which are commonly used in exploits ($baddies) count double. default: $threshold_default. duration Duration of time for which the host which went over the failure threshold should be blocked. Must be a number followed by units (e.g. '1 hr' or '3 days'). Unqualified number is treated as hours. default: $duration_default penalty The extra points to count as authentication failures for accounts commonly used in exploits ($baddies) default: $penalty_default EOT } sub mail_to_users { # We go this bizarre route because OS/X systems tend to not be # properly configured for local mail delivery, so we need to # inject the mail straight into the SMTP stream. my $text = shift; my $subject = shift; my @users = @_; @users = @sysmail unless @users; my $socket=IO::Socket::INET->new("$SMTPserver:25"); #my $socket = \*STDOUT; print $socket ("HELO $shost.$mydomain\n"); print $socket ("MAIL FROM: root\@$shost.$mydomain\n"); print $socket ("RCPT TO: ", join ("\nRCPT TO: ", @users), "\n"); print $socket ("DATA\n"); print $socket ("To: ", join (",", @users), "\n"); print $socket ("Subject: $subject\n\n"); print $socket($text); print $socket ("\n.\nQUIT\n"); close $socket; } sub die_with_mail($;@) { my $text = shift; my @users = @_; @users = @sysmail unless @users; my $subject = "$name died on $shost"; mail_to_users ($text, $subject, @users); if (-t STDIN) { die $text;} else { exit 1; } } sub negotiate_pid ($) { my $restart = shift; # Negotiate over possible prior instances if (-s $pidfile) { # PID file exists and is not empty open (PID, $pidfile) or die_with_mail "Cannot read PID file $pidfile\n"; chomp (my $pid = ); close PID; die_with_mail "Corrupt PID file! (read '$pid' from it)\n" unless $pid =~ /^\d+$/; if (kill (0, $pid)) { # The process is alive if ($restart) { # We are gonna kill the current process kill (9, $pid); sleep 1; if (kill (0, $pid)) { die_with_mail "Cannot kill predecessor, PID $pid\n";} else { unlink $pidfile; } } else { # There's another instance already running, leave it alone. exit 1; } } else { # PID file exists but the process is dead, proceed unlink $pidfile; } } elsif (-e $pidfile) { # PID file exists but it empty, ignore it. unlink $pidfile; } if (-e $pidfile) { die_with_mail "PID file $pidfile unepectedly exists!\n"; } elsif (open (PID, "> $pidfile")) { print PID "$$\n"; close PID; } else { die_with_mail "Couldn't write my PID ($$) to $pidfile\n"; } } sub process_line ($$) { my $line = shift; my $hosts = shift; chomp $line; if ($line =~ /\bsshd\b.*(failed|accepted)\s+\S+\s+for\s+(?:illegal user\s+)?(\S+)\s+from\s+(?:\S+:)?(\S+)/i) { # matched line my ($result, $user, $host) = ($1, $2, $3); if ($host !~ /(\.cs\.umass\.edu$)|(^128\.119\.24[01234567]\.\d+$)|(^128\.119\.4[01]\.\d+$)/) { # print "$result $user from $host\n"; if ($result =~ /accepted/) { # Successful login, validate this address delete $hosts->{$host}; } else { $hosts->{$host}->{users}->{$user}++; $hosts->{$host}->{count}++; # Count known-exploited users double $hosts->{$host}->{count}++ if grep (/^$user$/, @bad_users); } } } return $hosts; } sub normalize_duration ($) { my $duration = shift()."h"; $duration =~ s/\s//g; my ($num, $unit) = (lc($duration) =~ /^(\d+)(\w)/); return undef unless ($num && $unit); my $multiplier = 0; if ($unit eq "s") { $multiplier = 1;} elsif ($unit eq "m") { $multiplier = 60;} elsif ($unit eq "h") { $multiplier = 60*60;} elsif ($unit eq "d") { $multiplier = 60*60*24;} elsif ($unit eq "w") { $multiplier = 60*60*24*7;} elsif ($unit eq "m") { $multiplier = 60*60*24*30;} elsif ($unit eq "y") { $multiplier = 60*60*24*365;} else { return undef;} return $num * $multiplier; } sub process_hosts ($) { my $hosts = shift; open (DENY, ">> $hosts_deny") or die_with_mail "Cannot write to $hosts_deny\n"; my $expo = time() + normalize_duration($duration); for my $host (keys %$hosts) { if ($hosts->{$host}->{count} >= $threshold) { # Too many authentication failures for the host my $time = scalar (localtime($expo)); $time =~ s/^\w+\s+//; my $str = sprintf ("ALL : %-18s \# $tag %i (expires %s)\n", $host, $expo, $time); printf DENY $str; mail_to_users("Inserting deny string:\n$str\n", "$shost: Blocking $host"); delete $hosts->{$host}; } } close DENY; return $hosts; } sub expire_denials () { # expire old entries in $hosts_deny open (DENY, $hosts_deny) or die_with_mail "Cannot read $hosts_deny\n"; my @data = ; my @new = (); my $change = 0; for my $line (@data) { if ($line =~ /\#.*\b$tag\b\s+(\d+)/) { # Our line, process it if ($1 > time()) { push @new, $line; # This entry has future timestamp } else { $change = 1; # We reaped an entry, set the change flag } } else { push @new, $line; } } if ($change) { # We changed the contents, write them back to file my ($mode, $uid, $gid) = (stat($hosts_deny))[2,4,5]; open (DENY, "> $hosts_deny") or die_with_mail "Cannot write to $hosts_deny\n"; print DENY @new; #"Deny:\n\n", @new,"\n\n"; exit 0; close DENY; chown ($uid, $gid, $hosts_deny); chmod ($mode, $hosts_deny); } } ############################# # # # Execution begins here # # # ############################# GetOptions ("help" => \$help, "file:s" => \$file, "restart" => \$restart, "threshold=i" => \$threshold, "interval=i" => \$interval, "duration=s" => \$duration, "penalty=i" => \$penalty, ); if ($help) { print help(); exit 0;} negotiate_pid($restart); # Activate $