#!/bin/sh # # Restart with Tcl \ exec tclsh $0 $@ #-------------------------------------------------------------------------- # $Id: sshBrutes.tcl,v 1.10 2010/05/12 07:40:57 robroy Exp $ # # sshBrutes uses "tail -f" to monitor /var/log/authlog, where sshd # records login failures. If a host initiates more than $::tolerance # failed login attempts within $::forgetTime milliseconds, pf is # asked to drop all packets from that host. # # In versions of this program 1.8 and greater, sshBrutes forgets any # accumulated failed login attempts upon a subsequent successful attempt. # That keeps the records of failed attempts from gradually "stacking up," # as person keeps logging in and out over an hour, failing at least once # each time, but ultimately succeeding. Thanks to Tony Morel # (morel@armory.xcom) for suggesting this feature! # # Each time $::forgetTime milliseconds have elapsed, sshBrutes forgets # about any failed login attempts within that time window--hosts that were # already blocked remain blocked indefinitely. The $::forgetTime window # is designed to avoid blocking a host because of a cumulative number of # failed logins spread out sparsely over long periods of time. # # I run sshBrutes as root. Prior to running it, you'll want to: # # 1. Create a blocked hosts file. I call this /etc/pf.sshBrutes # (indicating hosts caught doing brute-force ssh login attacks). # # # umask 077 # # touch /etc/pf.sshBrutes # # 2. Add a table definition to /etc/pf.conf for hosts blocked by # this script, and block all traffic from those hosts. Here # are the lines I added; make sure to put them in the right # pf.conf sections. # # table persist file "/etc/pf.sshBrutes" # block in quick from to any # # This script was written and tested on OpenBSD/sparc64 4.5, using Tcl # 8.5.6. # # Copyright Robroy Gregg, Computer Consultant 2010. All rights reserved. #-------------------------------------------------------------------------- set ::tolerance 4 ;# Allow this many failed logins during each period. set ::forgetTime 3600000 ;# Forget failures after this many milliseconds. set ::brutesFile /etc/pf.sshBrutes ;# File of blocked IP's. set ::logFile /var/log/sshBrutes ;# Diagnostic messages go here. ########################################################################### # # BlockHost # # Updates a file containing a list of hosts that are blocked by pf, # then triggers pf to re-read the file. # # Argument: # # host (mandatory): The IP' of the host to block. # # Results: # # Returns nothing. # ########################################################################### proc BlockHost {host} { if {[catch {open $::brutesFile a+} fd]} { set stamp [clock format [clock seconds] -format "%Y%h%d %H:%M:%S"] puts $::log "${stamp}: Failed to open ${::brutesFile}: $fd" exit 1 } seek $fd 0 while {[gets $fd line] != -1} {lappend brutes $line} #-------------------------------------------------------------------------- # If we found at least one host in the file, and if the host we're trying # to block is already blocked, then do nothing. #-------------------------------------------------------------------------- if {[info exists brutes] && [lsearch $brutes $host] != -1} { set stamp [clock format [clock seconds] -format "%Y%h%d %H:%M:%S"] puts $::log "${stamp}: Host $host already blocked." catch {close $fd} } else { set stamp [clock format [clock seconds] -format "%Y%h%d %H:%M:%S"] puts $::log "${stamp}: Adding $host to ${::brutesFile}." puts $fd $host catch {close $fd} if {[catch {exec /sbin/pfctl -f /etc/pf.conf} error]} { set stamp [clock format [clock seconds] -format "%Y%h%d %H:%M:%S"] puts $::log "${stamp}: Couldn't run pfctl to ask\ pf to re-read its configuration file." puts $::log $error exit 1 } } return } ########################################################################### # # CheckLine # # CheckLine's a callback that's triggered whenever there's output # on a pipe open to "tail -f /var/log/authlog." # # If the line of output describes an ssh login that failed because # of a bad password, it notes the IP address and updates a tally # of attempts from that IP'. Once $::tolerance failures from that # IP' have occurred, it returns the IP' (see Results). # # If the line describes an ssh login that succeeded, it forgets all # login failures that it has been tracking from that IP. That's to # prevent a gradual build-up of failures from an IP caused by # habitually mis-typing passwords while logging in. # # Argument # # tailPipe (mandatory): A pipe file descriptor to the tail command. # # Results: # # Normally returns nothing. Yet if the line describes a failed # login attempt from a host that pushes that host over the # tolerance threshold, it returns the host's IP'. # ########################################################################### proc CheckLine {tailPipe} { if {![chan eof $tailPipe]} { set line [chan gets $tailPipe] if {[regexp "sshd.+Accepted password for (.+) from (.+) port" $line \ notUsed account host]} { if {[info exists ::failure($host)]} { ;# Noted failure already. set stamp [clock format [clock seconds] -format\ "%Y%h%d %H:%M:%S"] puts $::log "${stamp}: Login to $account from\ ${host}\; failures cleared = $::failure($host)" array unset ::failure $host ;# Loose track of this host. } return } elseif {[regexp "sshd.+Failed password for (.+) from (.+) port" \ $line notUsed account host]} { #-------------------------------------------------------------------------- # If we find a failed ssh login line, note down the account and host. # If this isn't the first failed login attempt for this host during this # period of $::forgetTime milliseconds, keep track of how many attempts have # been made. If the attempts exceed $::tolerance, lock them out. #-------------------------------------------------------------------------- if {[info exists ::failure($host)]} { ;# A repeat offender. incr ::failure($host) if {$::failure($host) > $::tolerance} { return $host ;# Block this host. } } else { ;# A first-time offender. set ::failure($host) 1 } set stamp [clock format [clock seconds] -format "%Y%h%d %H:%M:%S"] puts $::log "${stamp}: Bad login to $account from\ $host ($::failure($host) total)" return } } else { set ::tailEof 1 ;# We read an EOF, so end the event loop. } return } ########################################################################### # # ForgetAllHosts # # Removes the array containing the failed login attempts from each # host since the last $::forgetTime period elapsed. Recursion's # used to run this every $::forgetTime milliseconds. # # Argument: # # None. # # Results: # # Returns nothing. # ########################################################################### proc ForgetAllHosts {} { array unset ::failure ;# Forget about failed logins. after $::forgetTime ::ForgetAllHosts ;# Be recursive. return } set ::scriptName [file tail $argv0] #-------------------------------------------------------------------------- # Determine whether we're running in the foreground or the background. #-------------------------------------------------------------------------- set ps [exec ps -p [pid] -o tpgid,pgid] regexp "(\[0-9]+)\ +(\[0-9]+)" $ps notUsed tpgid pgid if {$tpgid == $pgid} { set ::foreground yes puts "${scriptName}: This program's normally run in the background." puts "${scriptName}: Use \"tail -f $::logFile\" to view messages." } if {[catch {open $::logFile a} ::log]} { puts stderr "${::scriptName}: Can't open file ${::logFile}: $::log" exit 1 } chan configure $::log -buffering line #-------------------------------------------------------------------------- # Begin watching the authlog file. Each time something new is written # to it, run CheckLine. If CheckLine returns a host's IP', block that IP'. #-------------------------------------------------------------------------- set tailPipe [open "| tail -f /var/log/authlog" r] chan configure $tailPipe -blocking 0 -buffering line chan event $tailPipe readable { if {[set host [CheckLine $tailPipe]] != {}} { ;# Host should be blocked. BlockHost $host } } after $::forgetTime ForgetAllHosts ;# Begin periodically forgetting hosts. vwait ::tailEof ;# Enter the event loop until we read an EOF from tail. catch {close $tailPipe} catch {close $::log} exit