Packet Actions – Python and Scapy

Hello and welcome to the “Packet Actions” series of blog posts. I’d like to spend a few posts talking through how you can programmatically integrate with a network dataplane. I had thrown around the idea of calling this series “Doing things with packets” but that seemed a bit long and also could mean just about anything. So what does Packet Actions mean? Well – its the shortest way I could come up with to say “Looking at packets on the wire and doing things based on what you see in the packet”. To discuss this further I’d like to talk about the often made analogy of network engineers being plumbers – an analogy that makes fairly good sense in most cases. For instance, network engineers create the paths for data to flow – plumbers make paths for water to flow. Additionally both need to make sure that there are no blockages or issues with handling the amount of data or water that needs to flow through the pipes. Going a step further – plumbers might use a diagnostic tool like a scope to physically look inside the pipes if theres a blockage or issue so they can see what’s going on. One could quite easily translate this to a network engineer using a network diagnostic tool such as TCPDump to inspect the flow of bits to see if we can spot an issue. So all in all – the analogy makes pretty good sense.

In this series of posts – I’d like to take things one further step and not only look at the packets – but also take actions based on them. Im sure at this point someone will comment “We already do this!”. Certainly what Im referring to is heading in the direction of network applications and that is a space that has seen an explosion in recent years. But let’s back up a second – a network engineer certainly already does do this – after all we manage network applications like routing protocols, link management protocols, traffic monitoring tooling, and the list goes on. We even have applications that look deeply into the packets and can do a myriad of things for us based on that information. The problem is that this has largely been a feature of vendor provided products and tooling. There’s nothing wrong with that – I have been happily watching these tools and products mature over the last few years. But at the same time – there has been an explosion of functionality that is very (very, very, very) accessible to you as well. What was once something that lived in the realm of vendors who did magical things in secret ASICs (or at least that’s what we were told) is now largely there for you to do yourself. And that’s the point of this series – to show you how you can do some interesting things based on real packets on a real wire. In each post in this series we’ll use different tools to build the same application in the hopes of exploring all of our options for interacting with real packets.

But before we get into the weeds – I’ll take a moment to pause here and say two more things. First – just because you can do something doesn’t mean you should. There are a couple of “packet actions” I will discuss that are really neat – but won’t scale to the point you need them to. Are there ways to make them scale? Sure – but that’s sort of the fun part about all of this – figuring out how things work and how you can apply them to real world problems. The point of these posts is to just show you what’s possible. The application we build will be largely useless but should give you and idea of what’s possible and what level of effort is required to make something that can scale and be resilient. Second – Some of this is going to be totally new to me so you’ll need to bear with me. As we progress through the posts I’ll be learning some of this as we go. This includes new programming languages and new Linux kernel functions that I have only been playing with recently (sometimes because the functionality is so recent too!). As always – if you spot something that’s wrong or could be better just let me know! Feedback is much appreciated.

So now that we got the intro out of the way – let’s talk about what our packet action is going to be. In this series we’re going to be building an application Im going to called “ping_return”. What ping return does is simple. If you ping an IP – it will do what’s required to facilitate that ping to work. Something that looks like this…

That seems like an entirely useless application, which it is, but again the point here is to look at different ways in which we can look at the real packets on the wire and do “something” based on them. So in our case – we want this flow to happen….

  • Client has a default route pointed at the server address.
  • Client pings a random IP address which matches the default route
  • The server receives these ICMP request packets
  • Our application on the server see these ICMP request packets and adds an IP address matching the destination to a local interface
  • The client gets a ICMP return and things work!

Should be easy right? So let’s take a look at how to do this. First thing first – let’s look at what our lab is going to look like for this…


Perhaps the easiest lab we’ve ever had, but that’s OK – we want to focus on the packet actions not the lab topology. The two servers we’re going to use here are straight Ubuntu 20 VMs. Base installs – nothing fancy – we’ll work through the dependancies as we come across them. So let’s get right into it!

As the title suggest, we’re going to use Scapy to accomplish our goals in this post. Many of you may be familiar with Scapy, but for those who aren’t, it’s an open source Python library that lets you do all sorts of interesting things with packets. While Im not an everyday user, I suspect that the majority of the use cases for Scapy are more around packet creation. It seems to be a pretty popular tool to use in the security space for this very reason. In any case, it has a rather unique function called sniff which can be used much like TCPDump in order to see real traffic on the wire. But the first thing we need to do is to install it. Since this is a blank fresh install of Ubuntu 20 – the first thing we need to do is to install PIP3 so we can install Python packages…

apt-get update
apt-get install python3-pip

Now that we have PIP installed we can install the scapy module as follows…

pip3 install scapy

Awesome, with the prereqs out of the way we can write a super simple script that shows us how the sniffing capability can work. Create a new file called ping_return.py and put this code in it…

import scapy.all as scapy
ens6_traffic = scapy.sniff(iface="ens6", count=5)
ens6_traffic.nsummary()

Now on your client box – start a ping to the server IP address. Something like ping 10.10.10.1 and just leave it running. Now go back on the server and run the script you just created (python3 ping_return.py1) and wait a few seconds…

root@server:~# python3 ping_return.py 
0000 Ether / IP / ICMP 10.10.10.0 > 10.10.10.1 echo-request 0 / Raw
0001 Ether / IP / ICMP 10.10.10.1 > 10.10.10.0 echo-reply 0 / Raw
0002 Ether / IP / ICMP 10.10.10.0 > 10.10.10.1 echo-request 0 / Raw
0003 Ether / IP / ICMP 10.10.10.1 > 10.10.10.0 echo-reply 0 / Raw
0004 Ether / IP / ICMP 10.10.10.0 > 10.10.10.1 echo-request 0 / Raw
root@server:~# 

So that was easy! Our second line of code simply told it to sniff on interface ens6 and that it should only sniff 5 packets. Then the third line of code said to simply print out a summary of those 5 packets. Pretty cool huh? Let’s look at a couple of other ways we can look at this data though before we move on. To do that we’re going to use the prn function of the sniff to call a function for each packet the sniffer receives. So let’s modify our script to be something like this…

import scapy.all as scapy

def process_packet(packet):
    packet.show()

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet, count=1)

So what’s changed here is that we’ve lowered our packet count to 1 (count=1) and we’ve also added a prn flag which references a function (prn=process_packet).

Note: If you’re like me and you’re wondering what the heck prn stands for there’s an actual StackOverFlow question about it. At least Im not the only one who was wondering…

The prn flag tells the sniffer to call the defined function every time it receives a packet. This may seem sort of counterintuitive considering we’ve also set a the count equal to 1 but all this does is tells Scapy to just take in one packet and then end. So let’s give it a run…

root@server:~# python3 ping_return.py 
###[ Ethernet ]### 
  dst       = 52:ab:54:cd:02:01
  src       = 52:ab:54:cd:01:01
  type      = IPv4
###[ IP ]### 
     version   = 4
     ihl       = 5
     tos       = 0x0
     len       = 84
     id        = 48642
     flags     = DF
     frag      = 0
     ttl       = 64
     proto     = icmp
     chksum    = 0x5492
     src       = 10.10.10.0
     dst       = 10.10.10.1
     \options   \
###[ ICMP ]### 
        type      = echo-request
        code      = 0
        chksum    = 0xdaae
        id        = 0x2
        seq       = 0x613b
        unused    = ''
###[ Raw ]### 
           load      = 'м\\xaf`\x00\x00\x00\x00o#\x0e\x00\x00\x00\x00\x00\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'

root@server:~# 

Very cool! So we got a packet and we printed it out. Looking at the packet we can see it is clearly split into 4 sections. Ethernet, IP, ICMP, and RAW. The relevant fields for all of these sections are also called out. What’s cool about this is that we can actually reference all of these fields in our code! There are two ways to do this. First, we can do it by index. For instance, say that we wanted to print out the destination ethernet address and destination IP address we could change our code to look like this…

import scapy.all as scapy

def process_packet(packet):
    packet.show()
    destination_mac_address = packet[0][0].dst
    destination_ip_address = packet[0][1].dst
    print("Received a packet with a destination MAC address of %s and a destination IP address of %s" % (destination_mac_address,destination_ip_address))

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet, count=1)

Notice that in addition to showing the packet summary, we are now also defining variables for the destination ethernet and IP address as well. What’s interesting is how we’re pulling those values out of the packet variable. Sort of looks like we’re referring to list indexes or something right? What we’re actually doing here is referencing the header we want to look at. The syntax packet[0][0] means look at the first packet (index 0) and the first header (in our case Ethernet).

Note: You could for instance sniff 10 packets and then assign them to a variable. In that case, you could reference that set of packets by index 0-9. In our case since the prn function gets called each time we get a packet we’ll always be looking at index 0.

That said – our packet summary looks a bit like this…


That makes pretty good sense – but it turns out you can also reference the sections by name. For instance we could change the program to look like this…

import scapy.all as scapy

def process_packet(packet):
    packet.show()
    destination_mac_address = packet[0][scapy.Ether].dst
    destination_ip_address = packet[0][scapy.IP].dst
    print("Received a packet with a destination MAC address of %s and a destination IP address of %s" % (destination_mac_address,destination_ip_address))

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet, count=1)

Notice how we’re now able to refer to the fields by name instead of by index? Running this version of the program would provide the same results. I myself dislike this approach for two reasons. First, the variable names aren’t the same that as what shows up in the packet display. AKA – Ether is not Ethernet. Second – I like to import Scapy a certain way. Notice how I do an import scapy.all as scapy? If you look at lots of other examples you’ll see that it’s common to just do from scapy.all import *. This allows you to just reference the different Scapy types just by name. I tend to prefer to be rather explicit in the code so I can tell what comes from where. This is truly a matter of “to each their own” so you’ll need to figure out what works best for you. So for now, we’ll revert to using the index based approach.

So now we know how to get traffic off the wire and look at the values from each packet. Now let’s talk about how to put some of this together to work toward our goal of ping return. The first thing we need to do is figure out what kind of datagram we have coming in. Let me show you what I mean. Let’s update our program to look like this…

import scapy.all as scapy

def process_packet(packet):
    packet.show()
    destination_mac_address = packet[0][0].dst
    destination_ip_address = packet[0][1].dst
    icmp_type_code = packet[0][2].type
    print("Received a packet with a destination MAC address of %s and a destination IP address of %s and ICMP type code of %s" % (destination_mac_address,destination_ip_address, icmp_type_code))

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)

We’ve made two big changes. First, we removed the count command from the sniff instantiation. This just means that our prn function will get every packet it picks up with no limit. Second we added a new field to grab the ICMP type code from the packet. Let’s give that a run….

root@server:~# python3 ping_return.py 
###[ Ethernet ]### 
  dst       = 52:ab:54:cd:02:01
  src       = 52:ab:54:cd:01:01
  type      = IPv4
###[ IP ]### 
     version   = 4
     ihl       = 5
     tos       = 0x0
     len       = 84
     id        = 22559
     flags     = DF
     frag      = 0
     ttl       = 64
     proto     = icmp
     chksum    = 0xba75
     src       = 10.10.10.0
     dst       = 10.10.10.1
     \options   \
###[ ICMP ]### 
        type      = echo-request
        code      = 0
        chksum    = 0x6124
        id        = 0x2
        seq       = 0x6881
        unused    = ''
###[ Raw ]### 
           load      = 'Cį`\x00\x00\x00\x00s`\t\x00\x00\x00\x00\x00\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'

Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
###[ Ethernet ]### 
  dst       = 52:ab:54:cd:01:01
  src       = 52:ab:54:cd:02:01
  type      = IPv4
###[ IP ]### 
     version   = 4
     ihl       = 5
     tos       = 0x0
     len       = 84
     id        = 15055
     flags     = 
     frag      = 0
     ttl       = 64
     proto     = icmp
     chksum    = 0x17c6
     src       = 10.10.10.1
     dst       = 10.10.10.0
     \options   \
###[ ICMP ]### 
        type      = echo-reply
        code      = 0
        chksum    = 0x6924
        id        = 0x2
        seq       = 0x6881
        unused    = ''
###[ Raw ]### 
           load      = 'Cį`\x00\x00\x00\x00s`\t\x00\x00\x00\x00\x00\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'

Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
###[ Ethernet ]### 
  dst       = 52:ab:54:cd:01:01
  src       = 52:ab:54:cd:02:01
  type      = ARP
###[ ARP ]### 
     hwtype    = 0x1
     ptype     = IPv4
     hwlen     = 6
     plen      = 4
     op        = who-has
     hwsrc     = 52:ab:54:cd:02:01
     psrc      = 10.10.10.1
     hwdst     = 00:00:00:00:00:00
     pdst      = 10.10.10.0

Traceback (most recent call last):
  File "/usr/local/lib/python3.8/dist-packages/scapy/packet.py", line 428, in __getattr__
    fld, v = self.getfield_and_val(attr)
  File "/usr/local/lib/python3.8/dist-packages/scapy/packet.py", line 423, in getfield_and_val
    raise ValueError
ValueError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "ping_return.py", line 10, in <module>
    ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sendrecv.py", line 1263, in sniff
    sniffer._run(*args, **kwargs)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sendrecv.py", line 1210, in _run
    session.on_packet_received(p)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sessions.py", line 108, in on_packet_received
    result = self.prn(pkt)
  File "ping_return.py", line 6, in process_packet
    destination_ip_address = packet[0][1].dst
  File "/usr/local/lib/python3.8/dist-packages/scapy/packet.py", line 430, in __getattr__
    return self.payload.__getattr__(attr)
  File "/usr/local/lib/python3.8/dist-packages/scapy/packet.py", line 428, in __getattr__
    fld, v = self.getfield_and_val(attr)
  File "/usr/local/lib/python3.8/dist-packages/scapy/packet.py", line 1828, in getfield_and_val
    raise AttributeError(attr)
AttributeError: dst
root@server:~# 

Aha! Can you sort out what happened? We were assuming that every packet would have an ICMP header and we ended up capturing an ARP request. The packet summary displayed fine, but when we tried to look at packet[0][2].type it bombed because in an ARP frame the 3rd header section doesn’t exist. So we can fix this a couple of ways. Let’s explore both of them. First, we can add a filter as part of the sniff instantiation. For instance, we could change our program to look like this…

import scapy.all as scapy

def process_packet(packet):
    packet.show()
    destination_mac_address = packet[0][0].dst
    destination_ip_address = packet[0][1].dst
    icmp_type_code = packet[0][2].type
    print("Received a packet with a destination MAC address of %s and a destination IP address of %s and ICMP type code of %s" % (destination_mac_address,destination_ip_address, icmp_type_code))

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet, filter="icmp")

All we did was add on a filter variable and pass in icmp. For those of you that are familiar with TCPDump filtering – this syntax will be largely the same. This means that we’ll only be sniffing for ICMP packets which means our prn function will never receive any ARP or non-ICMP packets to start with. While this is OK (it works), I sort of want to see the other packets we’re getting too so let’s try another approach…

import scapy.all as scapy

def process_packet(packet):
    if (packet.haslayer(scapy.ICMP)):
        destination_mac_address = packet[0][0].dst
        destination_ip_address = packet[0][1].dst
        icmp_type_code = packet[0][2].type
        print("Received a packet with a destination MAC address of %s and a destination IP address of %s and ICMP type code of %s" % (destination_mac_address,destination_ip_address, icmp_type_code))
    else:
        print("We received a non-ICMP packet")

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)

Here we check the packet for a certain header (or as Scapy refers to them “layer”). We check to see if the packet has an ICMP layer and if it does we run our logic. If it does not – we just print a message saying we don’t have it. Our output would look like this…

root@server:~# python3 ping_return.py 
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
We received a non-ICMP packet
We received a non-ICMP packet
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8

Cool! But Im not satisfied yet. I want to see what that packet was that wasn’t ICMP…

import scapy.all as scapy

def process_packet(packet):
    if (packet.haslayer(scapy.ICMP)):
        destination_mac_address = packet[0][0].dst
        destination_ip_address = packet[0][1].dst
        icmp_type_code = packet[0][2].type
        print("Received a packet with a destination MAC address of %s and a destination IP address of %s and ICMP type code of %s" % (destination_mac_address,destination_ip_address, icmp_type_code))
    else:
        print("We received a non-ICMP packet")
        print(packet.summary())

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)

With this slight change we can get a quick view into the packet that wasn’t ICMP…

^Croot@server:~# python3 ping_return.py 
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
We received a non-ICMP packet
Ether / ARP who has 10.10.10.0 says 10.10.10.1
We received a non-ICMP packet
Ether / ARP is at 52:ab:54:cd:01:01 says 10.10.10.0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0
Received a packet with a destination MAC address of 52:ab:54:cd:02:01 and a destination IP address of 10.10.10.1 and ICMP type code of 8
Received a packet with a destination MAC address of 52:ab:54:cd:01:01 and a destination IP address of 10.10.10.0 and ICMP type code of 0

And as expected – the non-ICMP packet was indeed an ARP request. As one last tweak, what if we only want to look at ICMP echo requests? That is, we don’t want to process the ICMP echo reply as well. Simple, just check the type code! We are already looking at it – so let’s add some logic to only look for ICMP type code of 8…

import scapy.all as scapy

def process_packet(packet):
    if (packet.haslayer(scapy.ICMP)):
        icmp_type_code = packet[0][2].type
        if icmp_type_code == 8:
            src_ip_address = packet[0][1].src
            dst_ip_address = packet[0][1].dst
            print("Received an ICMP echo request from source %s with a destination of %s" % (src_ip_address, dst_ip_address))
    else:
        print("We received a non-ICMP packet")
        print(packet.summary())

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)

Quite a few changes here – but we’ve already talked about all of them. We are checking to make sure the packet is ICMP, checking that the ICMP type is an echo request, and then grabbing the source and destination IP of out the IP layer (1). Pretty straight forward right? There actually isnt much left to do here in order to make this a ping return program. All we need to do is configure the IP address that the client is trying to ping on an interface on the server. To do that we’ll use pyroute2 so let’s get that installed…

pip3 install pyroute2

Now we can add a new function that will add an IP address to an interface…

import scapy.all as scapy
import pyroute2

ipr = pyroute2.IPRoute()

def add_interface_ip(interface_name, ip_address):
    interface_index_id = ipr.link_lookup(ifname=interface_name)[0]
    ipr.addr("add", index=interface_index_id, address=ip_address, prefixlen=32)

def process_packet(packet):
    if (packet.haslayer(scapy.ICMP)):
        icmp_type_code = packet[0][2].type
        if icmp_type_code == 8:
            src_ip_address = packet[0][1].src
            dst_ip_address = packet[0][1].dst
            print("Received an ICMP echo request from source %s with a destination of %s" % (src_ip_address, dst_ip_address))
            add_interface_ip("ens6", dst_ip_address)
    else:
        print("We received a non-ICMP packet")
        print(packet.summary())

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)

The biggest change in the above is our new function add_interface_ip. Here we’re using pyroute2 to send netlink commands to the kernel to configure a specific IP address on a specific interface. Note that in the function we first need to get the link index of the interface we wish to add the IP address to. Also note that the function returns a list so we really just want to grab the first (and only) item in that list. This is then used as the index in the next command in which we add the IP address to the interface that matches the index. Since these IP’s will all be individual I can safety hard code the mask to a /32. So let’s run this new version on the server and see what happens…

root@server:~# python3 ping_return.py 
Received an ICMP echo request from source 10.10.10.0 with a destination of 10.10.10.1
Traceback (most recent call last):
  File "ping_return.py", line 22, in <module>
    ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sendrecv.py", line 1263, in sniff
    sniffer._run(*args, **kwargs)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sendrecv.py", line 1210, in _run
    session.on_packet_received(p)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sessions.py", line 108, in on_packet_received
    result = self.prn(pkt)
  File "ping_return.py", line 17, in process_packet
    add_interface_ip("ens6", dst_ip_address)
  File "ping_return.py", line 8, in add_interface_ip
    ipr.addr("add", index=interface_index_id, address=ip_address, prefixlen=32)
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/iproute/linux.py", line 1518, in addr
    ret = self.nlm_request(msg,
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 391, in nlm_request
    return tuple(self._genlm_request(*argv, **kwarg))
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 882, in nlm_request
    for msg in self.get(msg_seq=msg_seq,
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 394, in get
    return tuple(self._genlm_get(*argv, **kwarg))
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 719, in get
    raise msg['header']['error']
pr2modules.netlink.exceptions.NetlinkError: (17, 'File exists')
root@server:~# 

Aha! If you had left your client ping running – you should immediately see this error message. The kernel doesnt want to let you add the same IP address to the same interface since it’s already there. So how should we fix this? The easiest thing to do would be to just not ping that IP. So let’s stop the ping on the client and run the script again on the server. Now on the client – try and ping a new IP – let’s say 1.1.1.1

Note: I assume that the client server has a default route pointing at the server

root@client:~# ping 1.1.1.1 -c 5
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=2 ttl=64 time=0.509 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=64 time=0.299 ms
64 bytes from 1.1.1.1: icmp_seq=4 ttl=64 time=0.180 ms
64 bytes from 1.1.1.1: icmp_seq=5 ttl=64 time=0.252 ms

--- 1.1.1.1 ping statistics ---
5 packets transmitted, 4 received, 20% packet loss, time 4102ms
rtt min/avg/max/mdev = 0.180/0.310/0.509/0.122 ms
root@client:~# 

Woohhoo it worked! Note that we missed the first sequence number. That’s to be expected in this case as the server needs to receive the first ICMP echo request and then add the IP address to it’s interface. Unfortunately, if we look at the server we see we crashed again…

root@server:~# python3 ping_return.py 
Received an ICMP echo request from source 10.10.10.0 with a destination of 1.1.1.1
Received an ICMP echo request from source 10.10.10.0 with a destination of 1.1.1.1
Traceback (most recent call last):
  File "ping_return.py", line 22, in <module>
    ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sendrecv.py", line 1263, in sniff
    sniffer._run(*args, **kwargs)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sendrecv.py", line 1210, in _run
    session.on_packet_received(p)
  File "/usr/local/lib/python3.8/dist-packages/scapy/sessions.py", line 108, in on_packet_received
    result = self.prn(pkt)
  File "ping_return.py", line 17, in process_packet
    add_interface_ip("ens6", dst_ip_address)
  File "ping_return.py", line 8, in add_interface_ip
    ipr.addr("add", index=interface_index_id, address=ip_address, prefixlen=32)
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/iproute/linux.py", line 1518, in addr
    ret = self.nlm_request(msg,
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 391, in nlm_request
    return tuple(self._genlm_request(*argv, **kwarg))
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 882, in nlm_request
    for msg in self.get(msg_seq=msg_seq,
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 394, in get
    return tuple(self._genlm_get(*argv, **kwarg))
  File "/usr/local/lib/python3.8/dist-packages/pr2modules/netlink/nlsocket.py", line 719, in get
    raise msg['header']['error']
pr2modules.netlink.exceptions.NetlinkError: (17, 'File exists')
root@server:~# 

Uggh. Well at least it’s for the same reason! We can see that when the first request came in all seemed to be well. However when the second request came in we ran into the same issue. So now that we know that the base premise works – let’s think about a better way of doing this.

The base problem can be addressed by having the server keep track of what IPs it has already configured. That seems easy enough so let’s tackle that first…

import scapy.all as scapy
import pyroute2

configured_ips = []
ipr = pyroute2.IPRoute()

def add_interface_ip(interface_name, ip_address):
    interface_index_id = ipr.link_lookup(ifname=interface_name)[0]
    ipr.addr("add", index=interface_index_id, address=ip_address, prefixlen=32)

def process_packet(packet):
    if (packet.haslayer(scapy.ICMP)):
        icmp_type_code = packet[0][2].type
        if icmp_type_code == 8:
            src_ip_address = packet[0][1].src
            dst_ip_address = packet[0][1].dst
            print("Received an ICMP echo request from source %s with a destination of %s" % (src_ip_address, dst_ip_address))
            if dst_ip_address not in configured_ips:
                configured_ips.append(dst_ip_address)
                add_interface_ip("ens6", dst_ip_address)
    else:
        print("We received a non-ICMP packet")
        print(packet.summary())

ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)

To fix that we simply add a list of IPs we’ve configured and make sure that we don’t process the same IP twice. Easy! However the problem still remains even with this code that the IPs we configured in previous runs will still be there and cause the same problem for us. We should probably write a clean up routine that checks for extra IPs and cleans them up before we start sniffing for traffic. As part of this, I’d suggest we also move the configuration of the IPs we sniff to the loopback interface rather than the physical interface. This will keep things much cleaner and not pollute the actual dataplane interfaces with extraneous IPs. So let’s start with a cleanup routine. We’ll create a function like this…

def cleanup_ips(interface_name):
    interface_index_id = ipr.link_lookup(ifname=interface_name)[0]
    interface_addresses = ipr.get_addr(index=interface_index_id, family=2)
    for address in interface_addresses:
        ip = address.get_attrs("IFA_ADDRESS")[0]
        if ip != "127.0.0.1":
            print("Cleanup - Deleting IP address %s from interface %s" % (ip, interface_name))
            delete_interface_ip(interface_name, ip)

So all we’re doing here is taking in an interface name and then using pyroute2 to grab all of the IP addresses from that interface. As before, we need to do this by link index so we have to grab that first. Also by specifying family=2 as part of the call we tell it to only return IPv4 addresses.

Note: If you’re wondering how 2 equals the IPv4 family you need to look a little deeper at Linux. The best place I can find this defined is here. Here you can see that AF_INET which is the IPv4 address family matches to a type of 2.

The pyroute2 call will return a list of IP addresses. We then need to iterate through them and delete the ones that we no longer want. Specifically we want to avoid deleting 127.0.0.1. The line ip = address.get_attrs("IFA_ADDRESS")[0] might be slightly confusing. If it is – it might help to look at the data that is returned from pyroute2. You can see this yourself by simply adding a print statement for address at the beginning of the for loop. If we did that – we might see this in the output…

{'family': 2, 'prefixlen': 32, 'flags': 128, 'scope': 0, 'index': 1, 'attrs': [('IFA_ADDRESS', '1.2.3.4'), ('IFA_LOCAL', '1.2.3.4'), ('IFA_LABEL', 'lo'), ('IFA_FLAGS', 128), ('IFA_CACHEINFO', {'ifa_preferred': 4294967295, 'ifa_valid': 4294967295, 'cstamp': 23959062, 'tstamp': 23959062})], 'header': {'length': 76, 'type': 20, 'flags': 2, 'sequence_number': 256, 'pid': 3992, 'error': None, 'target': 'localhost', 'stats': Stats(qsize=0, delta=0, delay=0)}, 'event': 'RTM_NEWADDR'}

And now formatted more nicely….

{
    "family": 2,
    "prefixlen": 32,
    "flags": 128,
    "scope": 0,
    "index": 1,
    "attrs": [
        ("IFA_ADDRESS", "1.2.3.4"),
        ("IFA_LOCAL", "1.2.3.4"),
        ("IFA_LABEL", "lo"),
        ("IFA_FLAGS", 128),
        (
            "IFA_CACHEINFO",
            {
                "ifa_preferred": 4294967295,
                "ifa_valid": 4294967295,
                "cstamp": 23959062,
                "tstamp": 23959062,
            },
        ),
    ],
    "header": {
        "length": 76,
        "type": 20,
        "flags": 2,
        "sequence_number": 256,
        "pid": 3992,
        "error": None,
        "target": "localhost",
        "stats": Stats(qsize=0, delta=0, delay=0),
    },
    "event": "RTM_NEWADDR",
}

You can see that there’s a lot of information here. pyroute2 has these handy functions such as get_attr to help you grab some of this data. So in our case – when we say address.get_attrs("IFA_ADDRESS")[0] we’re saying that we want to parse through the attributes for the base item (this address) and return the value for the key "IFLA_ADDRESS". And while it doesn’t look like it – that function will actually return a list hence the need to say [0] at the end. It’s interesting to note that if you want the full address, that is the IP and the netmask, you need to grab the netmask from elsewhere. The netmask is called prefixlen and is in the base object and not in the attribute section.

Once we have that info – we call a function called delete_interface_ip which looks suspicously like the add_interface_ip function we already wrote…

def delete_interface_ip(interface_name, ip_address):
    interface_index_id = ipr.link_lookup(ifname=interface_name)[0]
    ipr.addr("delete", index=interface_index_id, address=ip_address, prefixlen=32)

Now if we put this all together our final script will look like this….

import scapy.all as scapy
import pyroute2

configured_ips = []
ipr = pyroute2.IPRoute()

def add_interface_ip(interface_name, ip_address):
    interface_index_id = ipr.link_lookup(ifname=interface_name)[0]
    ipr.addr("add", index=interface_index_id, address=ip_address, prefixlen=32)

def delete_interface_ip(interface_name, ip_address):
    interface_index_id = ipr.link_lookup(ifname=interface_name)[0]
    ipr.addr("delete", index=interface_index_id, address=ip_address, prefixlen=32)

def cleanup_ips(interface_name):
    interface_index_id = ipr.link_lookup(ifname=interface_name)[0]
    interface_addresses = ipr.get_addr(index=interface_index_id, family=2)
    for address in interface_addresses:
        ip = address.get_attrs("IFA_ADDRESS")[0]
        if ip != "127.0.0.1":
            print("Cleanup - Deleting IP address %s from interface %s" % (ip, interface_name))
            delete_interface_ip(interface_name, ip)

def process_packet(packet):
    if (packet.haslayer(scapy.ICMP)):
        icmp_type_code = packet[0][2].type
        if icmp_type_code == 8:
            src_ip_address = packet[0][1].src
            dst_ip_address = packet[0][1].dst
            if dst_ip_address not in configured_ips:
                configured_ips.append(dst_ip_address)
                print("Received an ICMP echo request from source %s with a destination of %s" % (src_ip_address, dst_ip_address))
                add_interface_ip("lo", dst_ip_address)
    else:
        print("We received a non-ICMP packet")
        print(packet.summary())


cleanup_ips("lo")
ens6_traffic = scapy.sniff(iface="ens6", prn=process_packet)

Beyond the check to make sure we don’t process an IP twice the only other new piece is calling our cleanup function before we start sniffing. So let’s give this a try…

Note: Make sure to go and manually clean up any IPs you have on ens6 from earlier testing

root@client:~# ping 1.1.1.1 -c 5
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=2 ttl=64 time=0.550 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=64 time=0.254 ms
64 bytes from 1.1.1.1: icmp_seq=4 ttl=64 time=0.239 ms
64 bytes from 1.1.1.1: icmp_seq=5 ttl=64 time=0.277 ms

--- 1.1.1.1 ping statistics ---
5 packets transmitted, 4 received, 20% packet loss, time 4082ms
rtt min/avg/max/mdev = 0.239/0.330/0.550/0.127 ms
root@client:~# ping 2.2.2.2 -c 5
PING 2.2.2.2 (2.2.2.2) 56(84) bytes of data.
64 bytes from 2.2.2.2: icmp_seq=2 ttl=64 time=0.320 ms
64 bytes from 2.2.2.2: icmp_seq=3 ttl=64 time=0.256 ms
64 bytes from 2.2.2.2: icmp_seq=4 ttl=64 time=0.271 ms
64 bytes from 2.2.2.2: icmp_seq=5 ttl=64 time=0.241 ms

--- 2.2.2.2 ping statistics ---
5 packets transmitted, 4 received, 20% packet loss, time 4081ms
rtt min/avg/max/mdev = 0.241/0.272/0.320/0.029 ms
root@client:~# ping 3.3.3.3 -c 5
PING 3.3.3.3 (3.3.3.3) 56(84) bytes of data.
64 bytes from 3.3.3.3: icmp_seq=2 ttl=64 time=0.309 ms
64 bytes from 3.3.3.3: icmp_seq=3 ttl=64 time=0.256 ms
64 bytes from 3.3.3.3: icmp_seq=4 ttl=64 time=0.258 ms
64 bytes from 3.3.3.3: icmp_seq=5 ttl=64 time=0.316 ms

--- 3.3.3.3 ping statistics ---
5 packets transmitted, 4 received, 20% packet loss, time 4089ms
rtt min/avg/max/mdev = 0.256/0.284/0.316/0.027 ms
root@client:~# 

The client looks happy! It seems it can ping any IP address it wants. On the server side we see the log messages for things the sniffer picked up…

Received an ICMP echo request from source 10.10.10.0 with a destination of 1.1.1.1
We received a non-ICMP packet
Ether / ARP who has 10.10.10.1 says 10.10.10.0
We received a non-ICMP packet
Ether / ARP is at 52:ab:54:cd:02:01 says 10.10.10.1
We received a non-ICMP packet
Ether / ARP who has 10.10.10.0 says 10.10.10.1
We received a non-ICMP packet
Ether / ARP is at 52:ab:54:cd:01:01 says 10.10.10.0
Received an ICMP echo request from source 10.10.10.0 with a destination of 2.2.2.2
Received an ICMP echo request from source 10.10.10.0 with a destination of 3.3.3.3

And now if we restart the server we should see it clean up those IPs as well…

root@server:~# python3 ping_return.py 
Cleanup - Deleting IP address 1.1.1.1 from interface lo
Cleanup - Deleting IP address 2.2.2.2 from interface lo
Cleanup - Deleting IP address 3.3.3.3 from interface lo

Nice! Now this still really isnt a great solution. The only way this is working right now is because the source IP address of the ping is on a directly connected route for the server. A better solution would be to add a static route pointing back out the sniffer interface for each source IP we see. This would mean that the client could be many hops away and it should still work. I’m not going to write that code in this blog, but I can assure you it’s easy to do using pyroute2 (wow – that rhymed. That should be their slogan or something).

So I hope you can see that performing actions based on packets on the wire isn’t hard to do. This script is less than 40 lines of Python and we were already able to fulfill our requirements for the ping return application. In the next post we’ll attempt the same thing but in a different programming language! Stay tuned!

1 thought on “Packet Actions – Python and Scapy

  1. Robert

    If you connect your server to a switch this will not work. The last hop router that has the destination subnet your IP destination is in will ARP for the destination IP. Your client sends a ping out: Where does it get the destination MAC from? Your DST IP is 1.1.1.1, you need to have a next hop which you ARP for and route to, or your destination IP has to be part of a locally connected subnet. Regardless, in the end the “final hop” before which does switch the ICMP ping to the server NEEDS to have a destination MAC to create the frame. You cannot make the server see the ping to an IP it does not have configured, it will never send an ARP reply to the ARP request of the client (or the last hop router). So the last hop route or the client (in case directly connected) will never be able to create the frame as it does not send it out. If you are connected to your subnet 192.168.1.0/24 and you ping IP 192.168.1.100/24 – you will ARP for this address to get the destination MAC for the frame to send out. If you will never get an ARP reply because nobody does have this IP configured, you will not be able to send the frame out. You would need to create a static ARP entry to be able to send the frame. A switch in the network, when receiving this frame, does not have the MAC address learned, it will send forward this via an unknown unicast. If there is a routed network in between, you will need to ensure that the last hop router that is L2 adjacent to your – not yet configured destination – does have a static ARP entry to resolve the L2 destination before sending an unknown unicast out of all ports. Otherwise your server – with no IP yet – will never see this ping. I do not even understand your current example. If you have 2 devices connected directly, how does your client even send out the ping request if it cannot resolve the MAC address of the IP you are sending the ping to. You do need an IP first to reach the destination, you cannot send a letter to house in street X with number Y, if the street has no name yet and the house no number and after the house did receive the letter (how?) you name the street with the name on the letter and assign the house the number that was on the letter.

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *