Monday, September 21, 2015

A Little Fun with Scapy: Writing a Port Scan Detector


Hey everyone! This week, I thought it would be fun to play around with scapy.  Scapy allows you to manipulate packets to do basic things like port scanning or host discovery, but also things that might seem strange at first, like send malformed packets.  You might want to do that if you were trying to test an application for vulnerabilities by trying to see how it would react to a corrupted packet or stream.  It is a really cool tool, and today, I am going to use it to implement a port scan detector.

You might be thinking that an IDS / IPS would do this already, so what is the point of using scapy for detecting port scans?  Part of it is learning how scapy works and what it can do.  Since detecting a port scan is relatively simple, it is a good learning exercise.

For our port scan detector, we want to make sure it:

  • Does not respond to any packets it receives over the wire with a positive response.  We want it to operate as silently as possible except for when it is alerting.
  • Can alert us to when a port scan is happening.  In my example, I had the script print an informative message to the screen about the packet.  However, this could be extended to sending an e-mail or writing to a log.
  • Does not take significant resources to run.  It should be lightweight so that it could be deployed on a resource-limited piece of hardware (like a Raspberry Pi) or on a small VM (a few GB of hardware space and 512MB of RAM at most).
Scapy allows us to do all of this.  Scapy can be run as a standalone program or as part of a Python script.  I chose to integrate Scapy into a Python script to implement this little exercise.  I am using a Linux VM to execute the script.  I chose a Linux VM because it is easier to get up and running quickly, and it is not as chatty by default as a Windows box might be.  One thing I had to do to the VM was to suppress replies to ICMP echo-request (a ping), but setting up an iptables rule:

sudo iptables -A OUTPUT -p icmp --icmp-type echo-reply -j DROP



This rule says to drop any packet on the OUTPUT (outbound) chain that is an ICMP echo-reply.

In a nutshell, the script listens for any packets destined for it on any port (TCP / UDP).  It also listens for any ICMP packets it receives (like a ping).  The VM is configured in such a way to not have any services listening on it (like SSH).  When a port scanner sends a packet to the VM running the script, the Linux kernel sends an RST packet (to tell the scanning IP that the port is closed).  The sniffer that the script is running sees the packet and makes a note of it (in this case by printing information about the packet to the screen).

Let's take a look at the script to see how it works:



from scapy.all import *
import sys
from datetime import datetime
import socket

# A map of the ICMP types and codes for more friendly output
# The tuples are set up as (type, code)
icmpCodes = { (0, 0): 'ICMP Echo Reply (Ping Reply)',
       # Types 1 and 2 are reserved
       (3, 0): 'Destination network unreachable',
       (3, 1): 'Destination host unreachable',
       (3, 2): 'Desination protocol unreachable',
       (3, 3): 'Destination port unreachable',
       (3, 4): 'Fragmentation required, Don\'t Fragment (DF) Flag Set',
       (3, 5): 'Source route failed',
       (3, 6): 'Destination network unknown',
       (3, 7): 'Destination host unknown',
       (3, 8): 'Source host isolated',
       (3, 9): 'Network administratively prohibited',
       (3, 10): 'Host administratively prohibited',
       (3, 11): 'Network unreachable for TOS',
       (3, 12): 'Host unreachable for TOS',
       (3, 13): 'Communication administratively prohibited',
       (3, 14): 'Host Precedence Violation',
       (3, 15): 'Precendence cutoff in effect',
       # Code (4, 0) is deprecated
       (5, 0): 'Redirect Datagram for the Network',
       (5, 1): 'Redirect Datagram for the Host',
       (5, 2): 'Redirect Datagram for the TOS and network',
       (5, 3): 'Redirect Datagram for the TOS and host',
       # Type 6 is deprecated
       # Type 7 is reserved
       (8, 0): 'Echo / Ping Request',
       (9, 0): 'Router advertisement',
       (10, 0): 'Router discovery / selection / solicitation',
       (11, 0): 'TTL expired in transit',
       (11, 1): 'Fragment reassembly time exceeded',
       (12, 0): 'Bad IP Header',
       (12, 1): 'Bad IP Header: Missing a required option',
       (12, 2): 'Bad IP Header: Bad length',
       (13, 0): 'Timestamp',
       (14, 0): 'Timestamp Reply'
       # The rest are deprecated, reserved, or experiemental
     }

# Figure out the IP address of the first non-lo interface
localIPAddr = socket.gethostbyname(socket.gethostname())

# Ports to listen for
# The line below listens for selected ports, the commented out line
# listens for all ports
tcpPorts = [ 80, 443, 3389, 3306, 1433, 5900, 445, 135 ]
#tcpPorts = [ x for x in range(0, 65536) ]

udpPorts = [ x for x in range(0, 65536) ]

# Filters do not work too well on VM interfaces, so we will build a filter
# lfilters are python functions that apply to each packet

'''
If a filter function returns True, that means the packet
met whatever conditions were specified.  If the packet did
not meet specified conditions, then we return False.
'''
def build_lfilter(pkt):
 # Exclude packets that come from this machine
 if IP in pkt:
  if pkt[IP].src == localIPAddr:
   return False

 if TCP in pkt and pkt[TCP].dport in tcpPorts:
  return True
 elif UDP in pkt and pkt[UDP].dport in udpPorts:
  return True
 elif ICMP in pkt:
  return True
 else:
  return False

'''
This function outputs basic information about the packet.

If you wanted to do something more than print to the screen
(like write to a log or send an e-mail, you could do that
here instead.
'''
def parsePacket(pkt):
 currentTime = datetime.now().strftime('%Y-%m-%d %H:%M')
 if IP in pkt:
  sourceAddr = pkt[IP].src
  destAddr = pkt[IP].dst
 else:
  print('[{0}] Packet not an IP packet'.format(currentTime))
  return

 if TCP in pkt:
  sourcePort = pkt[TCP].sport
  destPort = pkt[TCP].dport
  print('[{0}] [TCP] {1}:{2} -> {3}:{4}'.format(currentTime, sourceAddr, sourcePort, destAddr, destPort))
 elif UDP in pkt:
  sourcePort = pkt[UDP].sport
  destPort = pkt[UDP].dport
  print('[{0}] [UDP] {1}:{2} -> {3}:{4}'.format(currentTime, sourceAddr, sourcePort, destAddr, destPort))
 elif ICMP in pkt:
  type = pkt[ICMP].type
  code = pkt[ICMP].code
  typeCodeString = icmpCodes.get((type, code))
  print('[{0}] [ICMP Type {1}, Code {2}: {3}] {4} -> {5}'.format(currentTime, type, code, 
          typeCodeString if typeCodeString else '',
          sourceAddr, destAddr))
 
 return

while True:
 '''
 prn is called to process each packet
 count = 0 means sniff an unlimited number of packets
 '''
 try:
  sniffer = sniff(lfilter=build_lfilter, count=0, prn=parsePacket)
  # If we Ctrl-C, then exit
  sys.exit()
 except socket.error:
  print('This script must be run as root / Administrator.  Exiting...')
  sys.exit()
 
We will start with the while loop.  We set up a scapy sniffer to sniff the wire for us.  If we hit Ctrl-C to stop the sniffer, we simply exit.  Scapy creates a socket when it spins up the sniffer, and we have to be root (or Administrator on Windows) to create that socket.  So if we get an error, we want to print a helpful message and exit.

Line 121 is where the magic happens.  This is where we set up our sniffer.  Scapy allows us to set a sniffer for the packets we are looking for, limit the number of packets sniffed, and take action on each packet that matches the filter.  We could leave the filter blank which means the sniffer could take action on every single packet, but we are not going to do that for two reasons.  First, we do not want to capture the RST packets that are sent by the kernel for closed TCP ports.  Second, scapy's filtering does not work so well with traditional filters on virtual machine interfaces which is what we are using.  lfilter tells scapy to call the specified function to filter a packet.  You could also use a lambda in here, but we are taking some actions that are a little more complicated than what we can put in a lambda.

We will get to the filter in a moment, but I want to introduce the other two parameters we are using to set up our sniffer.  The count parameter tells scapy how many packets to sniff.  We have set it to zero here because we want to sniff all packets until the script is terminated.  The final parameter is prn which tells scapy to call the specified function on every packet that matches our filter.

Let's look at the filter function (build_lfilter):


# Figure out the IP address of the first non-lo interface
localIPAddr = socket.gethostbyname(socket.gethostname())

# Ports to listen for
# The line below listens for selected ports, the commented out line
# listens for all ports
tcpPorts = [ 80, 443, 3389, 3306, 1433, 5900, 445, 135 ]
#tcpPorts = [ x for x in range(0, 65536) ]

udpPorts = [ x for x in range(0, 65536) ]

# Filters do not work too well on VM interfaces, so we will build a filter
# lfilters are python functions that apply to each packet

'''
If a filter function returns True, that means the packet
met whatever conditions were specified.  If the packet did
not meet specified conditions, then we return False.
'''
def build_lfilter(pkt):
 # Exclude packets that come from this machine
 if IP in pkt:
  if pkt[IP].src == localIPAddr:
   return False

 if TCP in pkt and pkt[TCP].dport in tcpPorts:
  return True
 elif UDP in pkt and pkt[UDP].dport in udpPorts:
  return True
 elif ICMP in pkt:
  return True
 else:
  return False

The first thing we do is check to see if the packet is originating from our box.  To do that, we have to ensure the packet is an IP packet.  If it is not an IP packet, then our IP will not be in there (obviously).  If our IP is the source of the packet, we return False which means this packet does not meet the criteria for filtering and should not be acted on by our prn function.  If this function returns True, that means something about it met the criteria for filtering.  In our case, we want to act on any TCP packet that is destined for any of the ports we define in the list above the function (tcpPorts), any UDP packet destined for the ports in the udpPorts list, and any ICMP packet.  Any other packet is let go.

In this example, we have two ways of defining which ports we care about.  We can specify ports we are interested in, or we can specify all ports.

The last part we will take a look at is parsePacket.  This is the function that is called when a packet matches our filter.


'''
This function outputs basic information about the packet.

If you wanted to do something more than print to the screen
(like write to a log or send an e-mail, you could do that
here instead.
'''
def parsePacket(pkt):
 currentTime = datetime.now().strftime('%Y-%m-%d %H:%M')
 if IP in pkt:
  sourceAddr = pkt[IP].src
  destAddr = pkt[IP].dst
 else:
  print('[{0}] Packet not an IP packet'.format(currentTime))
  return

 if TCP in pkt:
  sourcePort = pkt[TCP].sport
  destPort = pkt[TCP].dport
  print('[{0}] [TCP] {1}:{2} -> {3}:{4}'.format(currentTime, sourceAddr, sourcePort, destAddr, destPort))
 elif UDP in pkt:
  sourcePort = pkt[UDP].sport
  destPort = pkt[UDP].dport
  print('[{0}] [UDP] {1}:{2} -> {3}:{4}'.format(currentTime, sourceAddr, sourcePort, destAddr, destPort))
 elif ICMP in pkt:
  type = pkt[ICMP].type
  code = pkt[ICMP].code
  typeCodeString = icmpCodes.get((type, code))
  print('[{0}] [ICMP Type {1}, Code {2}: {3}] {4} -> {5}'.format(currentTime, type, code, 
          typeCodeString if typeCodeString else '',
          sourceAddr, destAddr))
 
 return

This function prints out the vital information about a packet (when it came in, what port it came in on (for TCP / UDP), and where it was destined).  Scapy uses a Python dictionary to store information about a packet.  I found the documentation to be a bit lacking, so I determined what part of each kind of packet I wanted by capturing some packets and printing them to the screen.  Once I figured out what fields I wanted, I extracted them and printed them to the screen.  You could send an e-mail or write to a log file (which could then be picked up by something like Splunk for centralized monitoring).

Let's see the script in action.  I have a Windows box set up with nmap to perform the scanning, and the Linux box set up to listen

TCP


From the Windows box that is doing the scanning:
On the box running the script:
I tried some common Windows ports, and as expected, the Linux kernel sent an RST back to the Windows box because the ports are closed.  On the Linux side, we see the script saw the packets.  We could make the script do more advanced things like alerting us.

UDP


From the Windows box that is doing the scanning:
 On the box running the script:


ICMP Ping


On the Windows side:
On the box running the script:


If I were to extend this script, I would like to find some way to suppress the TCP RST packets that are sent by the kernel so that the box would not appear to alive.  Blocking outbound TCP RST packets using iptables makes nmap realize the port is being filtered because nmap knows the box is up, so if it sends a packet and does not get a response, it assumes it is filtered.  Another way would be to block ARP (the mechanism nmap uses to tell if a box is up), but that would make it difficult for the box to communicate.  If you have suggestions, please let me know.

So that is our fun with scapy for now.  Have you used scapy for any interesting projects?  I would like to hear about them, so please let me know.  Thanks for reading!

No comments:

Post a Comment