home *** CD-ROM | disk | FTP | other *** search
- From rick
- Date: Mon, 27 Sep 2004 10:28:34 -0400 (EDT)
- From: Victor Danilchenko <danilche@cs.umass.edu>
- 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 <log file name> ]
- $filr [-i | --interval <polling interval> ]
- $filr [-t | --threshold <threshold number of failures> ]
- $filr [-d | --duration <duration of time to disable host for> ]
- $filr [-p | --penalty <penalty for known-exploitable accounts> ]
-
- 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 = <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 = <DENY>;
- 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 $<option>_default values
- eval "no strict 'vars'; \$$_ ||= \$${_}_default" for qw(file interval threshold duration penalty);
- die_with_mail "Bad duration spec ($duration)\n" unless normalize_duration ($duration);
- my $inode = (stat($file))[1];
- open (LOG, $file) or die_with_mail "Cannot read log file '$file'\n";
-
- while (1) {
- # Spin infinitely, polling $file every $interval seconds
-
- while (<LOG>) { $hosts = process_line ($_, $hosts) if /\bsshd\b/;} # parse SSH log
- $hosts = process_hosts ($hosts); # See if any hosts need blacklisting
- expire_denials (); # Reap expired blacklist entries
-
- my $new_inode = (stat($file))[1];
- if ($new_inode && ($inode != $new_inode)) {
- # logs have been rotated! Open the new log file, don't sleep or reset EOF
- close LOG;
- open (LOG, $file) or die_with_mail "Cannot reopen rotated log file '$file'\n";
- $inode = $new_inode;
- } else {
- # the logs have not been rotated
- sleep $interval;
- LOG->clearerr();
- }
- }
-