#!/usr/bin/env python """ defendmenow.py Don Petravick, FNAL. This python program helps to find machines probing your computer. The notion is that the attacking machines will try to connect to services you do not offer on your computer, or thry to connect randomly. The presumption is that N connections attempts to those ports by a single computer within a window of time is an attack on your computer. You can control the number of seconds in the detection window (-detectwindow) , and the number of connection attempts in that window which are deemed to constitute an attack (-ndetects). The program listen()s on the TCP ports you specify in the argument list. You can specify ports numerically or as tcp service names (ala /etc/servinces) The program exits if more than --maxbinderrors exceptions occur while binding to sockets you have specified in the argument list. (Default 0). You must specify at least one argument. In addition to the ports you list in the argument list, The program will find --randomports (default:0) additional ports to listen to. Such random ports exclude the system ports, i.e. those less than 1024. The program provides defensive actions when there are sufficient detections in the detection window. There are two actions, grouse (log and do nothing) and temporarily block using ipchains. Blocks are for --blockmaxtime seconds. The --whitelist option is used to specify the lists of hosts we grouse about, all others are blocked. Certain nodes (for example 127.0.0.1 and the value returned by socket.gethostbyname(socket.gethostname()) are automatically placed on the whitelist unless --unsafe is specified. Blockes are not eneacted if --justkidding is specified. The time that an Ip address ought to be unblocked is recorded in a file specified by the -eventfile switch. (defaulf events.dat). If you stop and restart this program, expired unblock events in --eventfile are acted upon. A second type of defensive action is to "bag" a connection. Bagging is simply adding a delay before closing the socket that is created when a connection is accepted. The hope is to tie up the attackers resources. n this version, the pool of such sockets is limited to 20, and no such socket is sustained for more than 20 seconds. Bagging is enabled by the --baggem switch. If enabled, all connection attempts on all ports are bagged, including the connections leading up to a trigger. Both whitelisted and non whitelisted nodes are bagged when enabled. The program writes a logfile, and writes to stdout, Writing to stdout is suppressed by the --quiet. Invoking the program with the --help switch prints help on the options to stdout. Notes: This program provides a bit of the defense in depth lilke the tarpits and autoblocker at a certain location in the midwest. Sophisticated attackers might use tools such as Tor to present a differing IP address for each access to your computer. Bagging would present a hinderance to such an attacker, perhaps a considerable hinderance if an attacekr tried to scan a farm of computers running defedmenow. Blocking would present no appreciable hinderance to such an attacker, I think. The program is in a select() loop, Do not give it so many ports to listen to that it cannot operate. Seem to work at the 100 level on my MAC where I develop it. BUGS: The python optionparser sometimes seems to think the last parameter to the last option is part of the argument list. (grr, I've nto figured this out yet. So prototype and watchout. Example: ./defendmenow.py -random 100 http ftp telnet 7 On a machine not meant to run http,tftp, telent or echo (port7) defendmenow defends against machines probing more than two of 100 random high numbered ports and the services listed. You'd have to run defendmenow.py as root to bind to the low number ports and for ipchains commands to have any effect. Connecting machines are not bagged. ./defendmenow.py -h print help and exit. """ import socket import select import time import sys import os import string import random # # This class manages persistent Events. These are events which should # persist across invocations of the program. Events are kelpt # in a statefile. # # The statefile keeps just one event # -- The time by which an ip address should have been unblocked # class PersistentEventManager: def __init__(self, logger, statefile): # if statefile does not exist, create it. # if statefile exists, make lots of assertions -- that is a file, and what # we expect. self._logger = logger self._statefile = statefile try: os.stat(statefile) except (OSError): # might mean that file does not exist, try to make one fd = open(statefile,"w") fd.close() # # ugly utility functions -- get data in and out of the statefile. # I wish I was not continually opening and re-writing this file. # But the goal of the moment is to understand useful features for the program. # def _statefile2list(self): fd = open(self._statefile,"r") list = fd.readlines() newlist = [] for l in list: l = l[:-1] #strip newlines. (grrr) if len(l) == 0 : break if l[0] == '#' : break newlist.append(l) fd.close() return newlist def _list2statefile(self,list): fd = open(self._statefile,"w") for l in list: fd.write("%s\n" % (l)) fd.close() return list # # routines to analyze the statefile and find events to execute. # Loop though the file and find events whose time has past. # execute them. Re-write the file with events whose time has not yet come. # def act_on_persistent_events(self): newlist = [] list = self._statefile2list() for l in list: if self._isExpired(l): self._doExpired(l) else: newlist.append(l) self._list2statefile(newlist) def _isExpired(self,l): asciitime = string.split(l,'|')[1] scheduled_time = time.mktime(time.strptime(asciitime)) return scheduled_time < time.time() def _doExpired(self,l): unblock_info = string.split(l,'|') ## O.K. not writing a general, abstract dispatcher just yet. assert unblock_info[0] == "Unblock" self._unblocker(unblock_info[2]) #call the unblocker. # # section for each event to be handled from persistent state -- right now, # there is one event -- the unblock action. # # put an unblock into the file def schedule_unblock(self, release_time, ipaddress): list = self._statefile2list() what = "Unblock|%s|%s" % ( time.asctime(time.localtime(release_time)),ipaddress) self._logger.say("recording:%s" % (what)) list.append(what) self._list2statefile(list) # give us a callback for the unblocker -- i.e. we expect a function to be passed. def set_unblocker(self, unblocker_action_routine): self._unblocker = unblocker_action_routine # # log to a file and be verbose to standard output # class Logger: def __init__(self, prog, verbose, logfile): self._fd = open(logfile, "w") self._prog = prog self._verbose = verbose def log_only (self, txt): self._fd.write("%s (%s): %s\n" % (self._prog, time.ctime(time.time()), txt)) self._fd.flush() def speak_only (self, txt): if self._verbose: print "%s: %s" % (self._prog, txt) def say (self, txt): self.speak_only(txt) self.log_only(txt) # # convenience functions for our open sockets. # class SocketValet : def __init__(self, logger, ports, maxBindErrors, nrandomports): self._sockets = [] self._filenos = [] self._ports = [] self._logger = logger self._bindErrors = 0 # user-specified ports for p in ports: if self.socket(p): self._logger.say("bound to port: %d" % (p)) else: self._logger.say("did not bind to port: %d" % (p)) assert self._bindErrors <= maxBindErrors # additional, high numbered ports we select. nrandom = 0 while nrandom < nrandomports: port = random.randint(1024,65535) if self.socket(port) : nrandom = nrandom + 1 self._logger.say("bound to random port: %d" % (port)) def socket (self, port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR, 1) try: s.bind(("",port)) except (socket.error): self._bindErrors = self._bindErrors + 1 return False s.listen(5) self._sockets.append(s) self._filenos.append(s.fileno()) self._ports.append(port) return True def filenos(self): return self._filenos def fileno2sock(self, fileno): return self._sockets[self._filenos.index(fileno)] def fileno2port(self, fileno): return self._ports[self._filenos.index(fileno)] # # A class to hold information about one ip address which has come a-knocking # class BadActor: def __init__(self, ipaddress): self._ipaddress = ipaddress self._act_times = [time.time()] # time series of acccess by bad actor def new_act(self): self._act_times.append(time.time()) def n_acts(self): return len(self._act_times) def last_act(self): return self._act_times[-1] # # A class to manage all the data we use to trigger on bad actors. # class BadActorValet: def __init__(self, logger, persistent_state_manager, timeout, action_tigger, whitelist, blockmaxtime, justkidding): self._actors = {} self._acts_threshold = action_tigger self._acts_timeout = timeout self._logger = logger self._whitelist = whitelist self._logger.say("whitelist: %s" % (self._whitelist)) self._psm = persistent_state_manager self._blockmaxtime = blockmaxtime pem.set_unblocker(self.unblock) self._block_cmd = "/sbin/iptables -A INPUT -s %s -j DROP" self._unblock_cmd = "/sbin/iptables -D INPUT -s %s -j DROP" if justkidding : self._block_cmd = "#" + self._block_cmd self._unblock_cmd = "#" + self._unblock_cmd def detection(self, ipaddress): if self._actors.has_key(ipaddress): #not detecting in time window self._actors[ipaddress].new_act() else: self._actors[ipaddress]= BadActor(ipaddress) ## had enough of this host ## block or grouse, then forget the host so as to take action if it returns. if self._actors[ipaddress].n_acts() == self._acts_threshold: if ipaddress in self._whitelist : self.grouse(ipaddress) else: self.block(ipaddress) del self._actors[ipaddress] def block(self, ipaddress): # actually do something. self._logger.say("blocktime is %d" % (self._blockmaxtime)) self._psm.schedule_unblock(time.time()+self._blockmaxtime, ipaddress) self._logger.say("blocking %s" % (ipaddress)) self._shellcmd(self._block_cmd % (ipaddress)) def unblock(self, ipaddress): self._logger.say("unblocking %s" % (ipaddress)) self._shellcmd(self._unblock_cmd % (ipaddress)) def _shellcmd(self, cmd): self._logger.say("attempting: %s" % cmd) stat = os.system (cmd) self._logger.say("result was: %s" % stat) def grouse(self, ipaddress): what = "i've been told to do nothing about this bad boy: %s" % ( ipaddress) self._logger.say(what) # do nothing def tidy(self): now = time.time() for a in self._actors.keys(): actor = self._actors[a] if now - actor.last_act() > self._acts_timeout: what = "tidying out %s due to timeout" % (a) self._logger.say(what) del self._actors[a] """ Bagger This classes manage connections from attackers. Note that these are not the sockets we are listening on, they are the socket implementing TCP connections to our presumed attacker Maintain a list of maxsockets size. A given list entry is either a socket returned by accept() representing a connection to the attacker or None -- which represents an empty slot -- the iscode simpler if the list is of constant size. The first and last entries are manipulated when an attacker makes a new connection or on a periodic action. The first entry is deleted, existing members shuffle up, and there is a new last item. Not that there are some bugs in the python options parser, """ class BaggedSocket: def __init__(self, logger, socket, ipaddress, listenport): self._socket = socket self._ipaddress = ipaddress self._listenport = listenport self._logger = logger def close(self): self._logger.say ("closing bagged socket from %s connected thru port %d" % ( self._ipaddress, self._listenport)) self._socket.close() class Bagger: def __init__(self, logger, isActive): self._isActive = isActive self._maxsockets = 20 self._logger = logger self._bagged = [] for i in range (self._maxsockets): self._bagged.append(None) def _prune_oldest(self): # make the list have len(self.maxlist)-1), # n.b. if bagged[0] was a socket it will be destroyed # by just forgetting it -- there are no other references to it. oldest = self._bagged[0] if oldest: #if its not None, then its a socket... # gees you'd think we'd have more to say here... oldest.close() self._bagged = self._bagged[1:] def bag(self, socket, ipaddress, port): if not self._isActive: socket.close() return self._prune_oldest() # it might tbe fun to write something to the socket # small enough as to not block # and not regular enough that we are easy to fingerprint self._logger.say ("bagging %s which connected to %s" % (ipaddress, port)) # let the socket age - -put it in a container, put the container on list end. self._bagged.append(BaggedSocket( self._logger, socket, ipaddress, port)) def periodic(self): self._prune_oldest() # fill the new slot with a no-op. self._bagged.append(None) if __name__ == "__main__": #deal with command line input. from optparse import OptionParser parser = OptionParser("usage: %prog [options] port ....") parser.add_option("-q", "--quiet", action="store_false", dest="verbose", default=True, help="don't print status messages to stdout") parser.add_option("-B", "--baggem", dest="baggem", default=False, help="try to sandbag attackers by holding up a limited number of attacker's connections.") parser.add_option("-t", "--ndetects", dest="ndetects", default=2, type="int", help="number of detections before triggering action") parser.add_option("-d", "--detectwindow", dest="detectwindow", default=3600, type="int", help="size of the detection window, in seconds. We take action if -t detections in -d seconds") parser.add_option("-l", "--logfile", dest="logfile", default="defendmenow.log", help="file to log actions to") parser.add_option("-w", "--whitelist", dest="whitelist", default="", help="no-spaces, comma-seperated, list of dotted quads") parser.add_option("-m", "--maxbinderrors", dest="maxbinderrors", default=0, type="int", help="exit when these many BIN errors occur") parser.add_option("-b", "--blockmaxtime", dest="blockmaxtime", default=3600, type="int", help="release block after blockmaxtime seconds") parser.add_option("-e", "--eventfile", dest="eventfile", default='events.dat', type="string", help="file holding state from invocation to invocation") parser.add_option("-r", "--randomports", dest="randomports", default=0, type='int', help="listen to these many program-selected uniformly-distributed, but > 1023 additional ports") parser.add_option("-U", "--unsafe", dest="unsafe", default=False, help="Drop safegaurds to facilitate software testing") parser.add_option("-j", "--justkidding", dest="justkidding", default=False, help="pretend to block and unblock") (opts, args) = parser.parse_args() ports=[] if not args: print "you must supply ports as arguments" sys.exit(1) for port in args: #if an int, then a port name, else a service name from /etc/services. try: ports.append(int(port)) except: ports.append(socket.getservbyname(port,'tcp')) logger = Logger(sys.argv[0], opts.verbose, opts.logfile) logger.say("verbose:%s" % (opts.verbose)) logger.say("detection window is :%d sec" % (opts.detectwindow)) logger.say("trigger at %d connects" % (opts.ndetects)) logger.say("logfile:%s" % (opts.logfile)) logger.say("baggem:%s" % (opts.baggem)) if opts.whitelist: whitelist = string.split(opts.whitelist,",") else: whitelist = [] if not opts.unsafe : whitelist.append(socket.gethostbyname('localhost')) whitelist.append(socket.gethostbyname(socket.gethostname())) logger.say("whitelist:%s" % (opts.whitelist)) logger.say("maxbinderrors:%d" % (opts.maxbinderrors)) logger.say("ports you requested:%s" % (ports)) logger.say("blocks last for no more than :%d seconds" % (opts.blockmaxtime)) logger.say("file holding events between invocations :%s" % (opts.eventfile)) logger.say("number of additional random ports :%d" % (opts.randomports)) logger.say("are we justkidding? :%s" % (opts.justkidding)) #Setup the book keeping "valet" classes. # #The Socket Valet holds our sockets and has accessors that make the fundamental code smaller # The Bad Actor Valet book-keeps our detections and triggers actions. pem = PersistentEventManager(logger,opts.eventfile) sv = SocketValet(logger, ports, opts.maxbinderrors, opts.randomports) bav = BadActorValet(logger, pem, opts.detectwindow, opts.ndetects, whitelist, opts.blockmaxtime, opts.justkidding) bagger = Bagger(logger, opts.baggem) #wait for and process connection requests we expect no legit business on these ports. filenos = sv.filenos() while 1: infilenos, outfilenos, exceptfilenos = select.select(filenos, filenos, filenos,1) for fileno in infilenos: # right now I assume that input file nos are connections requests listenport = sv.fileno2port(fileno) (newsock, (remhost, remport)) = sv.fileno2sock(fileno).accept() bagger.bag(newsock, remhost, listenport) # deal with the connection logger.say("bad actor %s connected to port %s" % ( remhost, listenport)) bav.detection(remhost) if outfilenos or exceptfilenos: # I assert that these lists are empty since I do nothing with them print outfilenos, expceptfilenos # this is an assertion for now logger.say("exiting, unexpected output from select, fix the code" ) logger.say("outfilenos: %s" % outfilenos ) logger.say("exceptfilenos: %s" % exceptfilenos ) sys.exit(1) if not infilenos and not outfilenos and not exceptfilenos : #purge down the timed out and otherwise tidy up our state bav.tidy() bagger.periodic() pem.act_on_persistent_events()