""" anti-crack-hack.py detects some password crack attempts coming in via ssh by monitoring /var/www/secure and firewalling off offending sites. Copyright (C) 2006 Mike Howard - All rights reserved This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. """ import sys import os import getopt import select import stat import re import time secure_log_path = '/var/log/secure' use_msg = "useage: %s [-h | options]" % sys.argv[0] verbose = 0 time_window = 3600 threshold = 6 poll_interval = 60 progname = sys.argv[0].split(os.sep)[-1] pid_path = os.path.join(os.sep, "var", "run", progname) shortopts = "hvs=t:Tp:P:" longopts = ['help', 'secure-log-path=', 'verbose', 'time-window=', 'threshold=', 'poll-interval=', 'pid-path=' ] hlp = ( "Option Meaning", "", "-v/--verbose increase verbosity", "-p/poll-interval= interval between polls of secure log [%d]" % poll_interval, "-P/pid-path= path to pid file [%s]" % pid_path, "-s/--secure-log-path=path path to 'secure' log [%s]" % secure_log_path, "-t/--time-window= time window to count attempts as fresh [%d]" % time_window, "-T/--threshold= Number of events to count before declaring hostile [%d]" % threshold, ) opts, args = getopt.getopt(sys.argv[1:], shortopts, longopts) for opt, val in opts: if opt == '-h' or opt == '--help': for l in hlp: print l print use_msg sys.exit(0) elif opt == '-v' or opt == '--verbose': verbose += 1 elif opt == '-s' or opt == '--secure-log-path': secure_log_path = val elif opt == '-p' or opt == '--poll-interval': poll_interval = int(val) elif opt == '-P' or opt == '--pid-path': pid_path = val elif opt == '-t' or opt == '--time-window': time_window = int(val) elif opt == '-T' or opt == '--threshold': threshold = int(val) elif opt == '-' or opt == '--': val = val else: print "Illegal Option: '%s'" % opt print use_msg sys.exit(1) # check to see if I am running def get_running_pid(): try: pid = int(file(pid_path).readline()) if verbose: print 'PID from %s: %d' % (pid_path, pid) except: return None try: os.kill(pid, 0) if verbose: print "Already running as pid %s" % pid return pid except: return None print 'Internal error in get_running_pid() - should not be reached' return None # append my pid to pid_path and then truncate all but the # first line to keep the size bounded try: f = open(pid_path, "a+") f.write("%d\n" % os.getpid()) f.seek(0, 0) f.readline() f.truncate(f.tell()) f.close() except Exception, e: print "Unable to append pid to pid file: %s" % pid_path print e sys.exit(1) if os.getpid() != get_running_pid(): if verbose: print "Startup Failure - already running as pid:", get_running_pid() sys.exit(0) # compile regular expressions ip_re = re.compile(r'([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})') if verbose: print 'Path to Secure Log: %s' % secure_log_path # compile regular expressions ip_re = re.compile(r'([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})') if verbose: print 'Path to Secure Log: %s' % secure_log_path class secure_log_file(object): def __init__(self, path, poll_interval = 60): """ secure_log_file(path, [poll_interval = 60]) opens an maintains a connection to the secure log file. poll_interval is used to control polling of the log file while reading records. Log rollover is monitored by checking the mtime of currently open file verses the actual log file. """ self.__path = path self.__open() self.set_poll_interval(poll_interval) def valid(self): return self.__valid def set_poll_interval(self, poll_interval): self.__poll_interval = poll_interval def poll_interval(self): return self.__poll_interval def next(self): return self.readline() def readline(self): while self.__valid: read_list, write_list, except_list = \ select.select([self.__file], [], [], 1.0) if verbose: print 'read_list', read_list # check size of self.__path in case log has rolled over if len(read_list) == 0: if self.__has_rolled_over(): self.__close() self.__open() elif self.__more_data(): buf = self.__file.readline() self.__size += len(buf) return buf else: time.sleep(self.__poll_interval) # we only get here if self.__valid is false return None def __more_data(self): my_stat = os.fstat(self.__fd) return self.__file.tell() < my_stat[stat.ST_SIZE] def __has_rolled_over(self): my_stat = os.fstat(self.__fd) real_stat = os.stat(self.__path) return my_stat[stat.ST_MTIME] < real_stat[stat.ST_MTIME] def __open(self): print '__open()' try: self.__file = file(self.__path) except Exception, e: self.__valid = False raise self.__valid = True self.__fd = self.__file.fileno() self.__size = 0 def __close(self): print '__close()' if self.__valid: self.__file.close(self) self.__valid == False self.__size = 0 class node(object): def __init__(self, ip, time_window = 3600, threshold = 5): """ node(ip, [time_window = 3600 seconds] [threshold = 5 events]) manages information about login attempts from an IP address. Events are collected. When the count of events exceeds threshold within 'time_window' seconds, the ip address is declared hostile. """ self.__ip = ip self.__count = 0 self.__events = dict() self.__state = 'Active' self.__time_window = time_window self.__threshold = threshold self.__latest_time_tag = 0 self.__blocked_time = 0 def active(self): return self.__state == 'Active' def str(self): return 'IP: %s State: %s, count: %d' % (self.__ip, self.__state, self.__count) def mark_blocked(self, time_tag): """mark_blocked(time_tag) marks node as inactive as of given clock time """ self.__state = 'Blocked' self.__blocked_time = time_tag def register(self, time_tag, message): """register(time_tag, message) records the information in 'message' and tags it with 'time_tag'. If this event exceeds the threshold after pruning stale events, register() returns the ip address of this node; else returns None """ if message.lower().find('illegal user') >= 0: user_id = message.split()[2] event = 'illegal-user' elif message.lower().find('failed password') >= 0: user_id = message.split()[3] event = 'failed-pw' else: user_id = 'unknown' event = 'unknown: ' + message self.__count += 1 if user_id in self.__events: self.__events[user_id].append((event, time_tag)) else: self.__events[user_id] = [(event, time_tag)] self.__latest_time_tag = time_tag if self.__count > self.__threshold: self.__prune_events() return self.__count > self.__threshold def __prune_events(self): lower_limit = self.__latest_time_tag - self.__time_window self.__count = 0 for user_id in self.__events: self.__events[user_id] = [(e, t) for e, t in self.__events[user_id] if t >= lower_limit] self.__count += len(self.__events[user_id]) node_dict = dict() previous_seconds_in_year = 0 #for line in file('/var/log/secure'): f = secure_log_file('/var/log/secure') try: while f.valid(): sys.stdout.flush() line = f.readline() idx = line.find('sshd[') if idx < 0: continue colon_idx = line[idx:].find(':') + idx line_date = ' '.join(line.split()[:3]) daemon = line[idx:colon_idx] message = line[colon_idx+2:] if message.find('Accepted') == 0: continue # this is a failed loggin attempt if verbose > 1: print "%s: '%s'" % (daemon, message) ip_obj = ip_re.search(message) if ip_obj == None: if verbose: print 'line read:', line continue ip = ip_obj.group() if verbose > 1: print "'%s'" % line[0:15] time_tuple = time.strptime(line[0:15], '%b %d %T') seconds_in_year = ((time_tuple[7] * 24 + time_tuple[3])*60+time_tuple[4])*60 if seconds_in_year < previous_seconds_in_year: reset_tuple = time.strptime('Dec 31 23:59:59', '%b %d %T') reset_time = ((reset_tuple[7] * 24 + reset_tuple[3])*60+reset_tuple[4])*60 for ip in node_dict: node_dict[ip] = [(e, t - reset_time) for e, t in node_dict[ip]] previous_seconds_in_year = seconds_in_year if ip not in node_dict: node_dict[ip] = node(ip, time_window, threshold) if node_dict[ip].register(seconds_in_year, message): cmd = '/sbin/iptables -I INPUT -s %s -p tcp --dport 22 -j DROP' % ip print time.ctime() + ':', 'at %s: ' % line_date, node_dict[ip].str() if node_dict[ip].active(): print 'active - shutting down' if verbose: print cmd os.system(cmd) node_dict[ip].mark_blocked(time.time()) sys.stdout.flush() except: os.unlink(pid_path) raise