From the vault: "A script to monitor log files and add persistent offenders to /etc/hosts.deny"

Posted by sam Sat, 04 Feb 2012 18:56:00 GMT

I’ve been sorting through some old code, and apparently on the 10th of September 2008 at 08:49 I felt compelled to write a daemon in Perl that would add persistently connecting source IPs to hosts.deny if they continually abused sshd.

I remember doing this: I was between jobs and somebody somewhere whom I’ve long abandoned to my mail archives asked for it, and I had nothing better to do. So, for the sake of posterity, here is probably the last piece of significant Perl I ever wrote before making the move to Ruby - make of it what you will:

#!/usr/bin/perl -w
#
# ssh-deny.pl - A script to monitor log files and add persistent offenders to /etc/hosts.deny
#
# Author:       Sam Pointer
# Contact:      sam@outsidethe.net
# Version:      0.03
#
# Usage:
#
# Should a given IP address connect more than the specified number of times, add
# it to the TCP wrappers host.deny file.
#
# Note that this script simply parses the ssh_log file for the number of failures
# for a given IP address, tests that against a threshold, and adds a tcp wrappers
# rule if that threshold is exceeded. Therefore, if your log files roll around
# daily, more than $max_connections failure in that 24 hour period will cause a
# rule to be generated.
#
# It will look for and add a rule in the format:
#
#       sshd : 66.6.136.62 : deny
#
# See the hosts_access manpages or TCP Wrappers documentation for more information.
#
# Generally the script should be invoked as UID 0 (root user), due to the permissions
# set on the hosts.deny and log files to be scanned.
#
# The configuration option @exception_list contains a list of full or partial IP
# addresses that will never be blocked. See the example configuration for the format.
#
# When started the script will detach from the console and become a daemon. It can
# be terminated via a SIGHUP/signal 15.
#
# DISCLAIMER: USE THIS SCRIPT AT YOUR OWN RISK. NO WARRANTY IMPLIED OR GIVEN. TEST
# ON A NON-PRODUCTION BOX FIRST.
#
# I've tested this on my own machine and it works fine. Change the configuration below
# to some files you can offord to loose first. Only $deny_file is opened for writing,
# so to test I copied that to my home directory, set the path there and checked that
# the rules added were correct, without affecting my live /etc/hosts.deny file.

# -- Configuration ----------------------------
my $max_connections     = '3';                  # Maximum number of denied connections
my $failure_string      = 'Failed password';        # Always present on a failed connection
my $ssh_log             = '/var/log/secure';    # Log file to scan for failed connections
my $deny_file           = '/home/hosts.deny';    # Location of TCP Wrappers host.deny file
my $daemon_list         = 'ALL';                # daemon_list string to add to hosts.deny. See hosts_access(5)
my $sleep_period        = '30';                 # Value in seconds to sleep before parsing log again. Adjust to suit system load.

# A list of full or partial IP addresses to never block
my @exception_list      = ('192.168', '81.214.108.250');
# ---------------------------------------------

# Internal Global Variables
my ($record);
my (%failed, %blocked);
my $ip_regex = '\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b';

# Perlisms
use strict;
use POSIX qw(setsid);                           # Required to daemonize the script

# Make ourselves into a daemon
daemonize();

# Loop perpetually as a daemon
while(1) {
        # Get a list of failed user IP addresses from the log file
        %failed = get_failed($ssh_log);

        # Get a list of what's already blocked
        %blocked = get_already_blocked($deny_file);

        # Move through the keys of our failure hash. Anything with
        # more than $max_connections should be written to the file,
        # providing it is not already blocked by a rule and is not in our exceptions list.
        open (DENYFILE, ">>$deny_file") || die "ssh-deny: Cannot open $deny_file for writing\n";

        foreach $record (keys(%failed)) {
                if ( ! $blocked{$record} && $failed{$record} > $max_connections) {
                        print DENYFILE "$daemon_list : $record \n";
                }
        }

        close DENYFILE;

        sleep $sleep_period;
}

# -- Subroutines ------------------------------
sub get_already_blocked {
# This subroutine will parse $deny_file looking for all IPs
# in a rule that matches $daemon_list. These are returned as
# a hash for matching against later on

        # Local Variables
        my ($deny, $record);
        my (@fields);
        my (%already_denied);

        # We expect a deny file to be passed. Open or die.
        $deny = pop(@_);
        open (DENY, "$deny") || die "ssh-deny.pl: Cannot open $deny for reading\n";

        # Move through the file. For any rule that matches $daemon_list get the IPs
        while ($record = ) {
                @fields = split(/ /, $record);
                if ($fields[0] eq $daemon_list &&
                    $fields[2] =~ $ip_regex) {
                        $already_denied{$fields[2]}++;
                }
        }
        close DENY;
        return(%already_denied);
}


sub get_failed {
# This subroutine retrieves a list of failed logins and returns a hash of IPs, times, and
# failed connections.

        # Local Variable declarations
        my ($log, $record, $marked, $exception);
        my (@failure_records, @fields);
        my (%failure_stats);

        # We expect a path to be passed. Open the file or fail.
        $log = pop @_;
        open (LOG, "$log") || die "ssh-deny.pl: Cannot open $log for reading\n";

        # Iterate through the log file, selecting rows that have failed connections
        while ($record = ) {
                if ($record =~ $failure_string) {
                        push @failure_records, $record;
                }
        }

        # Close the log file
        close LOG;

        # Build a hash for each IP that has a connection
        while ($record = pop(@failure_records)) {
                @fields = split(/ /,$record);           # Field index 13 is the IP address
                $failure_stats{$fields[13]}++;          # Increase failure counter for IP
        }

        # If any of the failed connections are in our @exception_list (never block)
        # ensure that the IP is deleted from the hash so that they aren't blocked.
        foreach $exception (@exception_list) {
                foreach $marked (keys(%failure_stats)) {
                        if ($marked =~ /$exception/) {
                                delete $failure_stats{$marked};
                        }
                }
        }

        # Return our hash with IP addresses as keys, and counts for each IP address as values
        return(%failure_stats);
}

sub daemonize {
# This subroutine handles detaching the console, forking a new process, etc.
        chdir('/')                      || die "ssh-deny: Cannot chdir to /\n";
        open (STDIN, '/dev/null')       || die "ssh-deny: Cannot read /dev/null\n";

        # Uncomment the next two lines if you really don't want this to
        # echo anything out as a daemon. Probably best to leave it
        # so any error messages make it to the console.
        #open (STDOUT,'>>/dev/null')    || die "ssh-deny: Cannot write to /dev/null\n";
        #open (STDERR,'>>/dev/null')    || die "ssh-deny: Cannot write to /dev/null\n";

        defined(my $pid = fork)         || die "ssh-deny: Cannot fork\n";
        exit if $pid;
        setsid                          || die "ssh-deny: Cannot start a new session\n";
        umask 0;
}

Logging in OpenSSH chroot jails will work properly in version 5.2

Posted by sam Sun, 02 Nov 2008 22:28:00 GMT

In February of this year the ability to easily chroot accounts in OpenSSH moved from a maintained but unofficial patch to a properly supported part of the main branch. Except when using internal-sftp DEBUG logging didn’t seem to work properly, a problem that seems to have popped up on numerous mailing lists and message boards since chroot became "official" in February.

Thankfully a patch to bug report 1527 fixes this, and it will be part of 5.2 when it is released. Hopefully with it we’ll have chroot with detailed logging, making the building of highly audited chroot jailed SFTP servers a less painful process than it currently is.