home *** CD-ROM | disk | FTP | other *** search
/ linuxmafia.com 2016 / linuxmafia.com.tar / linuxmafia.com / pub / linux / security / ssh-dictionary-attack-blacklist~ < prev    next >
Internet Message Format  |  2004-09-30  |  12KB

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