#!/usr/bin/env python """ pyscanlogger: Simple port scan detector/logger tool, inspired by scanlogd {http://www.openwall.com/scanlogd} """ import sys, os import dpkt, pcap import struct import socket import time import threading import optparse # UDP - in progress... SCAN_TIMEOUT = 5 WEIGHT_THRESHOLD = 25 PIDFILE="/var/run/pyscanlogger.pid" # TCP flag constants TH_URG=dpkt.tcp.TH_URG TH_ACK=dpkt.tcp.TH_ACK TH_PSH=dpkt.tcp.TH_PUSH TH_RST=dpkt.tcp.TH_RST TH_SYN=dpkt.tcp.TH_SYN TH_FIN=dpkt.tcp.TH_FIN # Protocols TCP=dpkt.tcp.TCP UDP=dpkt.udp.UDP get_timestamp = lambda : time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()) class ScanEntry(object): """ Port scan entry """ def __init__(self, hash): self.src = 0 self.dst = 0 self.timestamp = 0 self.logged = False self.type = '' self.tcpflags_or = 0 self.weight = 0 self.ports = [] self.next = None self.hash = hash class EntryLog(dict): """ Modified dictionary class with fixed size, which automatically removes oldest items """ # This will work only if the value is an object storing # its key in the 'hash' attribute and links to other # objects usin the 'next' attribute. def __init__(self, maxsz): self.oldest = None self.last = None self.maxsz = maxsz super(EntryLog, self).__init__() def __setitem__(self, key, value): if not self.__contains__(key) and len(self)==self.maxsz: # Remove oldest if self.oldest: self.__delitem__(self.oldest.hash) self.oldest = self.oldest.next super(EntryLog, self).__setitem__(key,value) if self.last: self.last.next = value self.last = value else: self.last = value self.oldest = self.last class TimerList(list): """ List class of fixed size with entries that time out automatically """ def __getattribute__(self, name): if name in ('insert','pop','extend'): raise NotImplementedError else: return super(TimerList, self).__getattribute__(name) def __init__(self, maxsz, ttl): # Maximum size self.maxsz = maxsz # Time to live for every entry self.ttl = ttl def append(self, item): """ Append an item to end """ if len(self)self.ttl: old.append(item) for item in old: self.remove(item) return len(old) # Access functions def __getitem__(self, index): item = super(TimerList, self).__getitem__(index) return item[1] def __setitem__(self, index, item): # Allow only tuples with time-stamps >= current time-stamp as 1st member if type(item) == tuple and len(item) == 2 and type(item[0]) == float and item[0]>=time.time(): super(TimerList, self).__setitem__(index, item) else: raise TypeError, 'invalid entry' def __contains__(self, item): items = [rest for (tstamp,rest) in self] return item in items class ScanLogger(object): """ Port scan detector/logger """ # TCP flags to scan type mapping scan_types = {0: 'TCP null', TH_FIN: 'TCP fin', TH_SYN: 'TCP syn', TH_SYN|TH_RST: 'TCP syn', TH_ACK: 'TCP ack', TH_URG|TH_PSH|TH_FIN: 'TCP x-mas', TH_URG|TH_PSH|TH_FIN|TH_ACK: 'TCP x-mas', TH_SYN|TH_FIN: 'TCP syn/fin', TH_FIN|TH_ACK: 'TCP fin/ack', TH_SYN|TH_ACK|TH_RST: 'TCP full-connect', TH_URG|TH_PSH|TH_ACK|TH_RST|TH_SYN|TH_FIN: 'TCP all-flags' } def __init__(self, timeout, threshold, maxsize, daemon=True, logfile='/var/log/scanlog'): self.scans = EntryLog(maxsize) # Port scan weight threshold self.threshold = threshold # Timeout for scan entries self.timeout = timeout # Daemonize ? self.daemon = daemon # Log file try: self.scanlog = open(logfile,'a') except (IOError, OSError), (errno, strerror): print "Error opening scan log file %s => %s" % (logfile, strerror) self.scanlog = None # Recent scans - this list allows to keep scan information # upto last 'n' seconds, so as to not call duplicate scans # in the same time-period. 'n' is 60 sec by default. # Since entries time out in 60 seconds, max size is equal # to maximum such entries possible in 60 sec - assuming # a scan occurs at most every 5 seconds, this would be 12. self.recent_scans = TimerList(12, 60.0) def hash_func(self, addr): """ Hash a host address """ value = addr h = 0 while value: # print value h ^= value value = value >> 9 return h & (8192-1) def host_hash(self, src, dst): """ Hash mix two host addresses """ return self.hash_func(src)^self.hash_func(dst) def log_scan(self, scan, continuation=False): """ Log the scan to file and/or console """ srcip, dstip = socket.inet_ntoa(struct.pack('I',scan.src)), socket.inet_ntoa(struct.pack('I',scan.dst)) ports = ','.join([str(port) for port in scan.ports]) if not continuation: line = '[%s]: %s scan (flags:%d) from %s to %s (ports:%s)' % (get_timestamp(), scan.type, scan.tcpflags_or, srcip, dstip, ports) else: line = '[%s]: Continuation of %s scan from %s to %s (ports:%s)' % (get_timestamp(), scan.type, srcip, dstip, ports) if self.scanlog: self.scanlog.write(line + '\n') self.scanlog.flush() if not self.daemon: print line def process(self, pkt): if not hasattr(pkt, 'ip'): return ip = pkt.ip # Ignore non-tcp, non-udp packets if type(ip.data) not in (TCP, UDP): return pload = ip.data src,dst,dport,flags = int(struct.unpack('I',ip.src)[0]),int(struct.unpack('I', ip.dst)[0]),int(pload.dport),0 proto = type(pload) if proto == TCP: flags = pload.flags key = self.host_hash(src,dst) curr=time.time() # Keep dropping old entries self.recent_scans.collect() if key in self.scans: scan = self.scans[key] if scan.src != src: # Skip packets in reverse direction or invalid protocol return # Update only if not too old, else skip and remove entry if curr - scan.timestamp > self.timeout: del self.scans[key] return if scan.logged: return # Update TCP flags if existing port if dport in scan.ports: # Same port, update flags scan.tcpflags_or |= flags return scan.timestamp = curr scan.tcpflags_or |= flags scan.ports.append(dport) # Add weight for port if dport < 1024: scan.weight += 3 else: scan.weight += 1 if scan.weight>=self.threshold: scan.logged = True if proto==TCP: scan.type = self.scan_types.get(scan.tcpflags_or,'unknown') elif proto==UDP: scan.type = 'UDP' # Reset flags for UDP scan scan.tcpflags_or = 0 # See if this was logged recently scanentry = (key, scan.type, scan.tcpflags_or) if scanentry not in self.recent_scans: self.log_scan(scan) self.recent_scans.append(scanentry) else: self.log_scan(scan, True) else: # Add new entry scan = ScanEntry(key) scan.src = src scan.dst = dst scan.timestamp = curr scan.tcpflags_or |= flags scan.ports.append(dport) self.scans[key] = scan def log(self): pc = pcap.pcap() decode = { pcap.DLT_LOOP:dpkt.loopback.Loopback, pcap.DLT_NULL:dpkt.loopback.Loopback, pcap.DLT_EN10MB:dpkt.ethernet.Ethernet } [pc.datalink()] try: print 'listening on %s: %s' % (pc.name, pc.filter) for ts, pkt in pc: self.process(decode(pkt)) except KeyboardInterrupt: if not self.daemon: nrecv, ndrop, nifdrop = pc.stats() print '\n%d packets received by filter' % nrecv print '%d packets dropped by kernel' % ndrop def run_daemon(self): # Disconnect from tty try: pid = os.fork() if pid>0: sys.exit(0) except OSError, e: print >>sys.stderr, "fork #1 failed", e sys.exit(1) os.setsid() os.umask(0) # Second fork try: pid = os.fork() if pid>0: open(PIDFILE,'w').write(str(pid)) sys.exit(0) except OSError, e: print >>sys.stderr, "fork #2 failed", e sys.exit(1) self.log() def run(self): # If dameon, then create a new thread and wait for it if self.daemon: print 'Daemonizing...' self.run_daemon() else: # Run in foreground self.log() def main(): if os.geteuid() != 0: sys.exit("You must be super-user to run this program") o=optparse.OptionParser() o.add_option("-d", "--daemonize", dest="daemon", help="Daemonize", action="store_true", default=False) o.add_option("-f", "--logfile", dest="logfile", help="File to save logs to", default="/var/log/scanlog") options, args = o.parse_args() s=ScanLogger(SCAN_TIMEOUT, WEIGHT_THRESHOLD, 8192, options.daemon, options.logfile) s.run() if __name__ == '__main__': main()