home *** CD-ROM | disk | FTP | other *** search
/ linuxmafia.com 2016 / linuxmafia.com.tar / linuxmafia.com / pub / linux / security / ssh-dictionary-attack-blacklist < prev    next >
Text File  |  2004-10-28  |  12KB  |  346 lines

  1. Archivist's note:  The successor to this initial version is in
  2. subdirectory ssh_sentry, within this directoriy.
  3.  
  4.  
  5.  
  6.  
  7. From rick
  8. Date: Mon, 27 Sep 2004 10:28:34 -0400 (EDT)
  9. From: Victor Danilchenko <danilche@cs.umass.edu>
  10. To: secureshell@securityfocus.com
  11. Subject: Re: OpenSSH -- a way to block recurrent login failures?
  12.  
  13. On Tue, 21 Sep 2004, Victor Danilchenko wrote:
  14.  
  15. > We are looking for a way to temporarily block hosts from which we
  16. > receive a given number of sequential failed login attempts, not
  17. > necessarily within the same SSH session (so MaxAuthTries is not
  18. > enough).  The best solution I could come up with so far would be to
  19. > run OpenSSH through TCPWrappers, and set up a log watcher daemon which
  20. > would edit /etc/hosts.deny on the fly based on the tracked number of
  21. > failed logins for each logged host.
  22. >
  23. > Is there a better solution known for the sort of problems we have been
  24. > plagued with lately -- repeated brute-force crack attempts from remote
  25. > hosts? I looked on FreshMeat and I searched the mailing lists, only to
  26. > come up empty-handed.
  27. >
  28. > Thanks in advance,
  29.  
  30. Thanks to all who replied with the suggestions. Alas, none of them were
  31. quite suitable.
  32.  
  33. The IPTables manipulation is a fine idea, but we need a solution that
  34. runs in a very heterogeneous environment. At the very least, we are
  35. looking at protecting Redhat Linux, OS/X, and Solaris systems.
  36.  
  37. Portsentry is IMO a little too complicated to easily deploy on a wide
  38. number of systems -- we need a fire-and-forget solution (ideally a
  39. simple modification to the sshd_config file, but that obviously is not
  40. in the cards).
  41.  
  42. In the end, I wrote a perl script that did solved the problem the brute
  43. way -- trail the SSHD logs, and modify /etc/hosts.deny on the fly.
  44. Attached in this script, should anyone here find it useful. The next
  45. logical step would be to turn this into a distributed solution where
  46. blacklists could be shared among individual nodes. Would be nice to have
  47. a DNS-based blacklisting solution eventually, similar to how SPAMming
  48. can be handled by MTAs...
  49.  
  50. Note that the attached script has been stripped of our information
  51. before being posted, so a typo or two may have crept in somewhere during
  52. the cleanup editing. It currently works on OS/X and Linux, I haven't yed
  53. added the code to make it work on Solaris.
  54.  
  55. -- 
  56. |  Victor  Danilchenko  | When in danger or in doubt,        |
  57. | danilche@cs.umass.edu | run in circles, scream, and shout. |
  58. |   CSCF   |   5-4231   |                    Robert Heinlein |
  59.  
  60.  
  61.  
  62.  
  63. [Script is intended to be named "sshd-sentry".]
  64.  
  65.  
  66. #!/usr/bin/perl -w
  67.  
  68. # Written by Victor Danilchenko, 09/22/2004
  69. #
  70. # The purpose of this script is to monitor the sshd logs, detect
  71. # repeated failed login attempts, and blacklist the hosts whence
  72. # such attempts originate.
  73. #
  74. # Supports Linux and OS/X
  75. #
  76. #################################################################
  77. #
  78. # Changelog:
  79. # 09/22/2004      Victor Danilchenko
  80. #                 Added notification by mail capability, via
  81. #                 direct SMTP injection
  82. #
  83. ################################################################
  84.  
  85. use strict;
  86. use Getopt::Long;
  87. use IO::Seekable;
  88. use IO::Socket::INET;
  89.  
  90. my $name = "sshd_sentry";
  91. my $pidfile = "/var/run/$name";
  92. my $hosts_deny = "/etc/hosts.deny";
  93. my $hosts = {};
  94. my @bad_users = sort qw(root user test admin guest);
  95. my $baddies = join (", ", @bad_users);
  96. my $tag = 'ROBOSENTRY';
  97. my ($help, $file, $restart, $interval, $threshold, $duration, $penalty);
  98. my $shost = (split(/\./, (`hostname`)[0]))[0]; chomp $shost;
  99. my $mydomain = "yourdomain.com";
  100. my $SMTPserver = "smtp.$mydomain";
  101. my @sysmail = ("sysadmin\@$mydomain");
  102.  
  103. # Various places where system logs can be found on different platforms
  104. my @files = qw(/var/log/messages /var/log/system.log /var/adm/messages);
  105. my $file_default;
  106. for (@files) { if (-e $_) { $file_default = $_; last;} }
  107.  
  108. my $interval_default = 10;
  109. my $threshold_default = 8;
  110. my $duration_default = "1 day";
  111. my $penalty_default = 1;
  112.  
  113. sub help () {
  114.     my $filr = " " x length($name);
  115.     return << "EOT";
  116. Usage: $name [-h | --help]
  117.        $filr [-r | --restart ]
  118.        $filr [-f | --file <log file name> ]
  119.        $filr [-i | --interval <polling interval> ]
  120.        $filr [-t | --threshold <threshold number of failures> ]
  121.        $filr [-d | --duration <duration of time to disable host for> ]
  122.        $filr [-p | --penalty <penalty for known-exploitable accounts> ]
  123.  
  124. help       Show this message
  125. restart    Focibly restart $name, kill current process if needed
  126. file       Specify the log file name to use
  127.            default: $file_default 
  128. interval   Number of seconds between polling of the log file 
  129.            default: $interval_default
  130. threshold  Number of detected failed logins, before the host is blocked.
  131.            Notice that the user names which are commonly used in exploits
  132.            ($baddies) count double.
  133.            default: $threshold_default.
  134. duration   Duration of time for which the host which went over the failure
  135.            threshold should be blocked. Must be a number followed by units
  136.            (e.g. '1 hr' or '3 days'). Unqualified number is treated as hours.
  137.        default: $duration_default
  138. penalty    The extra points to count as authentication failures for accounts
  139.            commonly used in exploits ($baddies)
  140.            default: $penalty_default
  141. EOT
  142. }
  143.  
  144. sub mail_to_users {
  145.     # We go this bizarre route because OS/X systems tend to not be
  146.     # properly configured for local mail delivery, so we need to
  147.     # inject the mail straight into the SMTP stream.
  148.     my $text = shift;
  149.     my $subject = shift;
  150.     my @users = @_; @users = @sysmail unless @users;
  151.  
  152.     my $socket=IO::Socket::INET->new("$SMTPserver:25");
  153.     #my $socket = \*STDOUT;
  154.     print $socket ("HELO $shost.$mydomain\n");
  155.     print $socket ("MAIL FROM: root\@$shost.$mydomain\n");
  156.     print $socket ("RCPT TO: ", join ("\nRCPT TO: ", @users), "\n");
  157.     print $socket ("DATA\n");
  158.     print $socket ("To: ", join (",", @users), "\n");
  159.     print $socket ("Subject: $subject\n\n");
  160.     print $socket($text);
  161.     print $socket ("\n.\nQUIT\n");
  162.     close $socket;
  163. }
  164.  
  165. sub die_with_mail($;@) {
  166.     my $text = shift;
  167.     my @users = @_; @users = @sysmail unless @users;
  168.     my $subject = "$name died on $shost";
  169.     mail_to_users ($text, $subject, @users);
  170.     if (-t STDIN) { die $text;}
  171.     else          { exit 1;   }
  172. }
  173.  
  174. sub negotiate_pid ($) {
  175.     my $restart = shift;
  176.     # Negotiate over possible prior instances
  177.     if (-s $pidfile) {
  178.     # PID file exists and is not empty
  179.     open (PID, $pidfile) or die_with_mail "Cannot read PID file $pidfile\n";
  180.     chomp (my $pid = <PID>);
  181.     close PID;
  182.     die_with_mail "Corrupt PID file! (read '$pid' from it)\n" unless $pid =~ /^\d+$/;
  183.     if (kill (0, $pid)) {
  184.         # The process is alive
  185.         if ($restart) {
  186.         # We are gonna kill the current process
  187.         kill (9, $pid);
  188.         sleep 1;
  189.         if (kill (0, $pid)) { die_with_mail "Cannot kill predecessor, PID $pid\n";}
  190.         else                { unlink $pidfile; }
  191.         } else {
  192.         # There's another instance already running, leave it alone.
  193.         exit 1;
  194.         }
  195.     } else {
  196.         # PID file exists but the process is dead, proceed
  197.         unlink $pidfile;
  198.     }
  199.     } elsif (-e $pidfile) {
  200.     # PID file exists but it empty, ignore it.
  201.     unlink $pidfile;
  202.     }
  203.  
  204.     if (-e $pidfile) { die_with_mail "PID file $pidfile unepectedly exists!\n"; }
  205.     elsif (open (PID, "> $pidfile")) {
  206.     print PID "$$\n";
  207.     close PID;
  208.     } else { die_with_mail "Couldn't write my PID ($$) to $pidfile\n"; }
  209.  
  210. }
  211.  
  212. sub process_line ($$) {
  213.     my $line = shift;
  214.     my $hosts = shift;
  215.     chomp $line;
  216.     if ($line =~ /\bsshd\b.*(failed|accepted)\s+\S+\s+for\s+(?:illegal user\s+)?(\S+)\s+from\s+(?:\S+:)?(\S+)/i) {
  217.     # matched line
  218.     my ($result, $user, $host) = ($1, $2, $3);
  219.     if ($host !~ /(\.cs\.umass\.edu$)|(^128\.119\.24[01234567]\.\d+$)|(^128\.119\.4[01]\.\d+$)/) {
  220.         # print "$result $user from $host\n";
  221.         if ($result =~ /accepted/) {
  222.         # Successful login, validate this address
  223.         delete $hosts->{$host};
  224.         } else {
  225.         $hosts->{$host}->{users}->{$user}++;
  226.         $hosts->{$host}->{count}++;
  227.         # Count known-exploited users double
  228.         $hosts->{$host}->{count}++ if grep (/^$user$/, @bad_users);
  229.         }
  230.     }
  231.     }
  232.     return $hosts;
  233. }
  234.  
  235. sub normalize_duration ($) {
  236.     my $duration = shift()."h";
  237.     $duration =~ s/\s//g;
  238.     my ($num, $unit) = (lc($duration) =~ /^(\d+)(\w)/);
  239.     return undef unless ($num && $unit);
  240.     my $multiplier = 0;
  241.     if    ($unit eq "s") { $multiplier = 1;}
  242.     elsif ($unit eq "m") { $multiplier = 60;}
  243.     elsif ($unit eq "h") { $multiplier = 60*60;}
  244.     elsif ($unit eq "d") { $multiplier = 60*60*24;}
  245.     elsif ($unit eq "w") { $multiplier = 60*60*24*7;}
  246.     elsif ($unit eq "m") { $multiplier = 60*60*24*30;}
  247.     elsif ($unit eq "y") { $multiplier = 60*60*24*365;}
  248.     else                 { return undef;}
  249.     return $num * $multiplier; 
  250. }
  251.  
  252. sub process_hosts ($) {
  253.     my $hosts = shift;
  254.     open (DENY, ">> $hosts_deny") or die_with_mail "Cannot write to $hosts_deny\n";
  255.     my $expo = time() + normalize_duration($duration);
  256.     for my $host (keys %$hosts) {
  257.     if ($hosts->{$host}->{count} >= $threshold) {
  258.         # Too many authentication failures for the host
  259.         my $time =  scalar (localtime($expo)); $time =~ s/^\w+\s+//;
  260.         my $str = sprintf ("ALL : %-18s \# $tag %i (expires %s)\n",
  261.                    $host, $expo, $time);
  262.         printf DENY $str;
  263.         mail_to_users("Inserting deny string:\n$str\n",
  264.               "$shost: Blocking $host");
  265.         delete $hosts->{$host};
  266.     }
  267.     }
  268.     close DENY;
  269.     return $hosts;
  270. }
  271.  
  272. sub expire_denials () {
  273.     # expire old entries in $hosts_deny
  274.     open (DENY, $hosts_deny) or die_with_mail "Cannot read $hosts_deny\n";
  275.     my @data = <DENY>;
  276.     my @new = ();
  277.     my $change = 0;
  278.     for my $line (@data) {
  279.     if ($line =~ /\#.*\b$tag\b\s+(\d+)/) {
  280.         # Our line, process it
  281.         if ($1 > time()) {
  282.         push @new, $line; # This entry has future timestamp
  283.         } else {
  284.         $change = 1;      # We reaped an entry, set the change flag
  285.         }
  286.     } else {
  287.         push @new, $line;
  288.     }
  289.     }
  290.  
  291.     if ($change) {
  292.     # We changed the contents, write them back to file
  293.     my ($mode, $uid, $gid) = (stat($hosts_deny))[2,4,5];
  294.     open (DENY, "> $hosts_deny") or die_with_mail "Cannot write to $hosts_deny\n";
  295.     print DENY @new; #"Deny:\n\n", @new,"\n\n"; exit 0;
  296.     close DENY;
  297.     chown ($uid, $gid, $hosts_deny);
  298.     chmod ($mode, $hosts_deny);
  299.     }
  300. }
  301.  
  302. #############################
  303. #                           #
  304. #   Execution begins here   #
  305. #                           #
  306. #############################
  307.  
  308. GetOptions ("help"            => \$help,
  309.             "file:s"          => \$file,
  310.         "restart"         => \$restart,
  311.         "threshold=i"     => \$threshold,
  312.         "interval=i"      => \$interval,
  313.         "duration=s"      => \$duration,
  314.         "penalty=i"       => \$penalty,
  315. );
  316.  
  317. if ($help) { print help(); exit 0;}
  318.  
  319. negotiate_pid($restart);
  320.  
  321. # Activate $<option>_default values
  322. eval "no strict 'vars'; \$$_ ||= \$${_}_default" for qw(file interval threshold duration penalty);
  323. die_with_mail "Bad duration spec ($duration)\n" unless normalize_duration ($duration);
  324. my $inode = (stat($file))[1];
  325. open (LOG, $file) or die_with_mail "Cannot read log file '$file'\n";
  326.  
  327. while (1) {
  328.     # Spin infinitely, polling $file every $interval seconds
  329.  
  330.     while (<LOG>) { $hosts = process_line ($_, $hosts) if /\bsshd\b/;} # parse SSH log
  331.     $hosts = process_hosts ($hosts);   # See if any hosts need blacklisting
  332.     expire_denials ();                 # Reap expired blacklist entries
  333.  
  334.     my $new_inode = (stat($file))[1];
  335.     if ($new_inode && ($inode != $new_inode)) {
  336.     # logs have been rotated! Open the new log file, don't sleep or reset EOF
  337.     close LOG;
  338.     open (LOG, $file) or die_with_mail "Cannot reopen rotated log file '$file'\n";
  339.     $inode = $new_inode;
  340.     } else {
  341.     # the logs have not been rotated
  342.     sleep $interval;
  343.     LOG->clearerr();
  344.     }
  345. }
  346.