Home » Tutorials » How to Build a DHCP Packet Sniffer with Scapy in Python

How to Build a DHCP Packet Sniffer with Scapy in Python

In today’s world, understanding how data moves across a network is critical for cybersecurity, network monitoring, and even troubleshooting. One key element of this is DHCP (Dynamic Host Configuration Protocol), which helps assign IP addresses to devices on a network. But what if you could see those DHCP packets as they move through the network in real-time?

Today, you’ll learn how to build a DHCP Packet Sniffer with Scapy in Python. We’ll combine Scapy, Python’s powerful networking library, with a simple GUI to create a tool that captures and displays DHCP packets live. Let’s dive into the world of network monitoring with Python and Scapy!

Table of Contents

Necessary Libraries

Before we get started, make sure to install these libraries so the code runs smoothly:

$ pip install scapy
$ pip install tk

Imports

import threading
import logging
import tkinter as tk
from tkinter.scrolledtext import ScrolledText
from scapy.all import sniff
from scapy.layers.dhcp import BOOTP, DHCP
import socket

Let’s kick things off by gathering the tools we need:

  • First up is threading. This lets us capture network packets in the background without freezing up the main window, so everything stays smooth.
  • To keep track of all those DHCP packets we’ll capture, we’ll use logging to neatly store all the details in a file.
  • For the graphical interface, we’ve got tkinter with a handy ScrolledText widget to display the packets in real-time.
  • And, of course, the star of the show: Scapy. This is what will do the heavy lifting, analyzing all the network traffic.
  • Finally, if we hit any network issues, socket is there to help us handle them smoothly.

Setting Up Logging and Global Variables

With our tools ready, let’s set up the logging system. First, we’ll choose a filename to act as our logbook, where all the important stuff will be recorded. But we don’t want to overload it with too much information, so we’ll set the logging level to focus on the key events. And to keep track of when everything happens, we’ll format the logs to include timestamps.

# Configure logging to log DHCP details to a file
logging.basicConfig(filename="dhcp_listener.log", level=logging.INFO, format="%(asctime)s - %(message)s")

To control our sniffing process, we’ll use two simple but important global variables that act like switches to start and stop the sniffing. Let’s break them down:

  • sniffing: This is our first global variable, which starts out as False, meaning sniffing isn’t running yet. Once we set it to True, the packet capturing begins.
  • sniff_thread: This is the second global variable, and it’s the one doing the heavy lifting. It holds the thread that’s responsible for managing the sniffing process in the background.
# Global variable to stop sniffing
sniffing = False
sniff_thread = None

Handling DHCP Packets with Scapy

Decoding the DHCP Messages

Still, we do have a problem—the DHCP messages coming through are just numbers, which can be pretty boring and hard to understand. But don’t worry, we’ve got a solution: the dhcp_message_types dictionary. It turns those numbers into meaningful words, so now you can easily see what’s really happening in the network.

# DHCP message type map
dhcp_message_types = {
   1: "DHCP Discover",
   2: "DHCP Offer",
   3: "DHCP Request",
   4: "DHCP Decline",
   5: "DHCP ACK",
   6: "DHCP NAK",
   7: "DHCP Release",
   8: "DHCP Inform"
}

Handling Incoming Packets

Now, let’s explore the heart of our program: the dhcp_packet_callback() function, which acts as our detective on the case. It inspects each incoming packet, using packet.haslayer(DHCP) to check if it’s a DHCP packet—if it’s not, it simply moves on. Once we confirm it’s a DHCP packet, we use bootp_layer to pull in the client hardware details, and dhcp_layer gives us all the DHCP specifics.

We grab the client’s MAC address with client_mac and snag the unique transaction ID with transaction_id. Finally, we try to retrieve the client IP address, all while ensuring our program stays safe from crashing if that address isn’t available.

# Function to handle incoming DHCP packets
def dhcp_packet_callback(packet, text_widget):
   if packet.haslayer(DHCP):
       bootp_layer = packet[BOOTP]
       dhcp_layer = packet[DHCP]


       client_mac = bootp_layer.chaddr[:6].hex(":")
       transaction_id = bootp_layer.xid
       client_ip = bootp_layer.yiaddr if bootp_layer.yiaddr else "N/A"

Uncovering the Message Type

Thanks to the dhcp_packet_callback() function, we have our basic clues ready. Now it’s time to uncover the message type. How, you might ask? The answer is simple: we create a loop to check each option in the dhcp_layer until we find message_type. Then, using the dictionary we created earlier, we decode it into a format that’s easy to read and understand.

       # Identify the DHCP message type
       message_type = None
       for opt in dhcp_layer.options:
           if isinstance(opt, tuple) and opt[0] == 'message-type':
               message_type = dhcp_message_types.get(opt[1], "Unknown")
               break

Extracting More DHCP Details

Although we’ve gathered a fair amount of information, let’s dive deeper and extract even more details from the packet. This includes how long the client can use the IP address (lease_time), the IP address offered by the server (offered_ip), the IP of the DHCP server (server_ip), and last but not least, the hostname of the client (hostname).

       # Extract other DHCP options
       lease_time = None
       offered_ip = None
       server_ip = None
       hostname = None
       for opt in dhcp_layer.options:
           if isinstance(opt, tuple):
               if opt[0] == "lease-time":
                   lease_time = opt[1]
               elif opt[0] == "requested_addr":
                   offered_ip = opt[1]
               elif opt[0] == "server_id":
                   server_ip = opt[1]
               elif opt[0] == "hostname":
                   hostname = opt[1]

Displaying and Logging the Findings

With all the clues gathered, we can now present our findings. First, we’ll organize how the information will be displayed. We’ll use text_widget.insert() to add the details to the GUI and text_widget.see() to ensure the latest information is visible. Lastly, we’ll log everything into our log file to keep a record and ensure we don’t lose any important information.

       # Format the information
       info = (f"\n--- Captured DHCP {message_type} Packet ---\n"
               f"Client MAC Address: {client_mac}\n"
               f"Transaction ID: {transaction_id}\n"
               f"Client IP: {client_ip}\n"
               f"Offered IP: {offered_ip}\n"
               f"Server IP: {server_ip}\n"
               f"Hostname: {hostname}\n"
               f"Lease Time: {lease_time}\n")


       # Insert into the Tkinter text widget and log it
       text_widget.insert(tk.END, info)
       text_widget.see(tk.END)  # Auto-scroll to the bottom
       logging.info(info)

Starting and Stopping the DHCP Sniffer

Starting the Sniffing Process

Now that our code is ready to handle packets, let’s flip the switch and kick off the sniffing process with the start_sniffing() function. This function changes the sniffing global variable from False to True, officially starting the sniffing process. To keep the user in the loop, we’ll also print a message to let them know it’s happening.

# Function to start sniffing on the specified interface
def start_sniffing(interface, text_widget):
   global sniffing, sniff_thread
   sniffing = True
   print(f"Starting DHCP listener on {interface}")

Sniffing in The Background

The sniffing process can be quite demanding and might cause the main window to freeze. To prevent this issue, we’ve created the sniff_thread_func() function:

This function utilizes the sniff() function from Scapy to capture packets on the specified network interface (Wi-Fi in my case) and filters for UDP packets on ports 67 and 68, which are used by DHCP. For each captured packet, the dhcp_packet_callback() function is called. Additionally, this function handles errors effectively, displaying any issues that arise with the interface.

   def sniff_thread_func():
       try:
           sniff(iface=interface, filter="udp and (port 67 or port 68)",
                 prn=lambda pkt: dhcp_packet_callback(pkt, text_widget), store=0, stop_filter=lambda x: not sniffing)
       except socket.error:
           error_msg = f"Error: Unable to start sniffing on interface {interface}. Please check the interface name."
           text_widget.insert(tk.END, error_msg + "\n")
           text_widget.see(tk.END)
           logging.error(error_msg)
           stop_sniffing()


   sniff_thread = threading.Thread(target=sniff_thread_func, daemon=True)
   sniff_thread.start()

Stopping the Sniffing

When we’re finished and want to stop sniffing, we simply call the stop_sniffing() function. This function sets the global variable sniffing back to False and prints a message to let the user know that the process has stopped.

# Function to stop sniffing
def stop_sniffing():
   global sniffing
   sniffing = False
   print("Stopping DHCP listener...")

Button Handlers

Let’s jump into setting up a user-friendly control system with two handy functions:

  • First, we have on_start_button(). This function kicks off the listening process by grabbing the interface name from the entry field. If the user has entered a valid interface name, we call start_sniffing() to begin monitoring that interface. To keep things tidy, we disable the Start button and enable the Stop button, signaling to the user that the sniffing is now active.
# Function to start listener when button is pressed
def on_start_button():
   interface = interface_entry.get()
   if interface:
       start_sniffing(interface, text_area)
       start_button.config(state=tk.DISABLED)
       stop_button.config(state=tk.NORMAL)
  • Next up is on_stop_button(). This function is pretty straightforward—it simply calls stop_sniffing() to halt the packet capturing. After stopping, it re-enables the Start Listener button and disables the Stop Listener button, allowing the user to restart the sniffing process whenever they’re ready.
# Function to stop listener when button is pressed
def on_stop_button():
   stop_sniffing()
   start_button.config(state=tk.NORMAL)
   stop_button.config(state=tk.DISABLED)

Building the Tkinter GUI for Packet Display

We’ve reached the exciting part—bringing our code to life visually! We kick things off by using tk to create the main window, setting its title and defining its size. Next, we add an entry box for the network interface, complete with a label, and we even insert a default interface that users can change to fit their needs.

# Tkinter Setup
root = tk.Tk()
root.title("DHCP Listener - The Pycodes")
root.geometry("600x400")

# Interface label and entry
interface_label = tk.Label(root, text="Interface:")
interface_label.pack(pady=10)

interface_entry = tk.Entry(root)
interface_entry.pack(pady=5)
interface_entry.insert(0, "Wi-Fi")  # Default interface

Then, we create the “Start Listener” button, which will trigger the on_start_button() function when clicked. Following that, we add the “Stop Listener” button, linked to the on_stop_button() function to halt the packet capturing process.

# Start button
start_button = tk.Button(root, text="Start Listener", command=on_start_button)
start_button.pack(pady=5)

# Stop button
stop_button = tk.Button(root, text="Stop Listener", command=on_stop_button, state=tk.DISABLED)
stop_button.pack(pady=5)

After that, we set up the text_area, which is a text box equipped with a scrollbar that will display the output from our sniffing activity. Finally, we wrap it all up by starting the main event loop with the mainloop() method, ensuring our window stays responsive and ready for user interaction.

# Text area to display packets
text_area = ScrolledText(root, wrap=tk.WORD, height=10)
text_area.pack(pady=10, padx=10, fill=tk.BOTH, expand=True)

# Start the Tkinter event loop
root.mainloop()

Example

I ran this code on my Windows machine, using Wi-Fi as the interface as shown in the image below:

Also, on my friend’s Linux computer, to find out your interface on Ubuntu, just use this command:

$ ip link show

In my case, the interface is called wlp2s0.

NB: I’m using these commands to release and renew my IP address on Ubuntu. You can achieve similar functionality to ipconfig /release and ipconfig /renew on Windows Command Prompt run as an administrator with the following commands on Ubuntu:

When I run sudo dhclient -r, it releases my current IP address, essentially letting the network know I’m no longer using it. Then, when I run sudo dhclient, it requests a new IP address from the DHCP server, allowing me to refresh my connection and possibly resolve any network issues.

Full Code

import threading
import logging
import tkinter as tk
from tkinter.scrolledtext import ScrolledText
from scapy.all import sniff
from scapy.layers.dhcp import BOOTP, DHCP
import socket


# Configure logging to log DHCP details to a file
logging.basicConfig(filename="dhcp_listener.log", level=logging.INFO, format="%(asctime)s - %(message)s")


# Global variable to stop sniffing
sniffing = False
sniff_thread = None


# DHCP message type map
dhcp_message_types = {
   1: "DHCP Discover",
   2: "DHCP Offer",
   3: "DHCP Request",
   4: "DHCP Decline",
   5: "DHCP ACK",
   6: "DHCP NAK",
   7: "DHCP Release",
   8: "DHCP Inform"
}


# Function to handle incoming DHCP packets
def dhcp_packet_callback(packet, text_widget):
   if packet.haslayer(DHCP):
       bootp_layer = packet[BOOTP]
       dhcp_layer = packet[DHCP]


       client_mac = bootp_layer.chaddr[:6].hex(":")
       transaction_id = bootp_layer.xid
       client_ip = bootp_layer.yiaddr if bootp_layer.yiaddr else "N/A"


       # Identify the DHCP message type
       message_type = None
       for opt in dhcp_layer.options:
           if isinstance(opt, tuple) and opt[0] == 'message-type':
               message_type = dhcp_message_types.get(opt[1], "Unknown")
               break


       # Extract other DHCP options
       lease_time = None
       offered_ip = None
       server_ip = None
       hostname = None
       for opt in dhcp_layer.options:
           if isinstance(opt, tuple):
               if opt[0] == "lease-time":
                   lease_time = opt[1]
               elif opt[0] == "requested_addr":
                   offered_ip = opt[1]
               elif opt[0] == "server_id":
                   server_ip = opt[1]
               elif opt[0] == "hostname":
                   hostname = opt[1]


       # Format the information
       info = (f"\n--- Captured DHCP {message_type} Packet ---\n"
               f"Client MAC Address: {client_mac}\n"
               f"Transaction ID: {transaction_id}\n"
               f"Client IP: {client_ip}\n"
               f"Offered IP: {offered_ip}\n"
               f"Server IP: {server_ip}\n"
               f"Hostname: {hostname}\n"
               f"Lease Time: {lease_time}\n")


       # Insert into the Tkinter text widget and log it
       text_widget.insert(tk.END, info)
       text_widget.see(tk.END)  # Auto-scroll to the bottom
       logging.info(info)


# Function to start sniffing on the specified interface
def start_sniffing(interface, text_widget):
   global sniffing, sniff_thread
   sniffing = True
   print(f"Starting DHCP listener on {interface}")


   def sniff_thread_func():
       try:
           sniff(iface=interface, filter="udp and (port 67 or port 68)",
                 prn=lambda pkt: dhcp_packet_callback(pkt, text_widget), store=0, stop_filter=lambda x: not sniffing)
       except socket.error:
           error_msg = f"Error: Unable to start sniffing on interface {interface}. Please check the interface name."
           text_widget.insert(tk.END, error_msg + "\n")
           text_widget.see(tk.END)
           logging.error(error_msg)
           stop_sniffing()


   sniff_thread = threading.Thread(target=sniff_thread_func, daemon=True)
   sniff_thread.start()


# Function to stop sniffing
def stop_sniffing():
   global sniffing
   sniffing = False
   print("Stopping DHCP listener...")


# Function to start listener when button is pressed
def on_start_button():
   interface = interface_entry.get()
   if interface:
       start_sniffing(interface, text_area)
       start_button.config(state=tk.DISABLED)
       stop_button.config(state=tk.NORMAL)


# Function to stop listener when button is pressed
def on_stop_button():
   stop_sniffing()
   start_button.config(state=tk.NORMAL)
   stop_button.config(state=tk.DISABLED)


# Tkinter Setup
root = tk.Tk()
root.title("DHCP Listener - The Pycodes")
root.geometry("600x400")


# Interface label and entry
interface_label = tk.Label(root, text="Interface:")
interface_label.pack(pady=10)


interface_entry = tk.Entry(root)
interface_entry.pack(pady=5)
interface_entry.insert(0, "Wi-Fi")  # Default interface


# Start button
start_button = tk.Button(root, text="Start Listener", command=on_start_button)
start_button.pack(pady=5)


# Stop button
stop_button = tk.Button(root, text="Stop Listener", command=on_stop_button, state=tk.DISABLED)
stop_button.pack(pady=5)


# Text area to display packets
text_area = ScrolledText(root, wrap=tk.WORD, height=10)
text_area.pack(pady=10, padx=10, fill=tk.BOTH, expand=True)


# Start the Tkinter event loop
root.mainloop()

Happy Sniffing!

Related:

Leave a Comment

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

Scroll to Top