← Back to DevBytes

Implementing DHCP Protocol: From Theory to Practice

Introduction to DHCP

The Dynamic Host Configuration Protocol (DHCP) is a network management protocol used on IP networks to automatically assign IP addresses and other communication parameters to devices. Defined in RFC 2131 and updated by RFC 3315 for IPv6, DHCP eliminates the need for manual configuration of network settings, enabling seamless connectivity for clients joining a network.

From a developer's perspective, DHCP is not just a background serviceβ€”it is a protocol with a well-defined packet structure, message flow, and extensible option format. Understanding its inner workings allows you to build custom DHCP clients, servers, or relay agents, integrate address assignment into embedded systems, and troubleshoot networking issues at a granular level.

Why DHCP Matters for Developers

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

DHCP is ubiquitous: every time a laptop, smartphone, or IoT device connects to a Wi-Fi or wired network, it likely uses DHCP. For developers building networked applications, infrastructure tools, or even game servers that need dynamic IP coordination, grasping DHCP brings several advantages:

DHCP Protocol Fundamentals

DHCP operates over UDP, using port 67 for servers and port 68 for clients. The protocol is connectionless and relies on broadcasts for initial discovery, as the client typically lacks a valid IP address. The entire exchange consists of four key messages (DISCOVER, OFFER, REQUEST, ACK), although others like NAK, RELEASE, and INFORM exist for special cases.

Message Types

Packet Structure

A DHCP packet builds upon the legacy BOOTP format, using the same fixed fields but adding a variable-length options section. The fixed part is 236 bytes, followed by options starting with a magic cookie (0x63825363).

Key fields in the header:

Option Format

DHCP options are encoded in a flexible Type-Length-Value (TLV) scheme. The magic cookie 63 82 53 63 starts the options field. Each option consists of:

Essential option codes include:

Implementing a DHCP Client

We'll build a minimal DHCP client in Python using the Scapy library, which provides powerful packet crafting and sending capabilities. The client will perform a full DISCOVER-OFFER-REQUEST-ACK cycle, extract the offered IP, subnet mask, gateway, and DNS, and print them. The implementation is intentionally straightforward to illustrate the protocol mechanics.

Setting Up the Environment

Install Scapy (requires root/administrator privileges for raw socket operations):

pip install scapy

Ensure you run the script with sufficient permissions (e.g., sudo on Linux).

Crafting a DHCP Discover Packet

A DHCPDISCOVER message is a UDP datagram sent from port 68 to the broadcast address 255.255.255.255 on port 67. Inside, the DHCP layer carries the mandatory option 53 (Message Type = 1), option 55 (Parameter Request List) asking for subnet mask, router, DNS, and lease time, plus the magic cookie and end option.

Here's how to build it with Scapy:


from scapy.all import *
import random

def create_discover(mac_address, transaction_id=None):
    if transaction_id is None:
        transaction_id = random.randint(0, 0xFFFFFFFF)
    
    # Build DHCP layer
    dhcp_discover = DHCP(
        op=1,           # BOOTREQUEST
        htype=1,        # Ethernet
        hlen=6,         # MAC length
        hops=0,
        xid=transaction_id,
        secs=0,
        flags=0x8000,   # Broadcast flag set (client cannot receive unicast yet)
        chaddr=mac_to_bytes(mac_address),  # Helper to convert MAC string to bytes
        options=[
            DHCPOptions("message-type", "discover"),
            DHCPOptions("param_req_list", [1, 3, 6, 51]),  # subnet, router, DNS, lease time
            DHCPOptions("end")
        ]
    )
    
    # Wrap in UDP and IP layers
    pkt = IP(src="0.0.0.0", dst="255.255.255.255") / UDP(sport=68, dport=67) / dhcp_discover
    return pkt

def mac_to_bytes(mac_str):
    return bytes(int(b, 16) for b in mac_str.split(':'))

Sending and Receiving the Offer

The client sends the DISCOVER packet as a broadcast. Because Scapy can listen for responses, we'll use a combined send/receive function that sends the packet and then waits for a matching reply. The response should be a DHCPOFFER with the same transaction ID.


def send_discover_and_receive_offer(iface="eth0"):
    # Get MAC of the interface (simplified; on Linux use get_if_hwaddr)
    mac = get_if_hwaddr(iface)
    discover = create_discover(mac)
    
    # Send and wait for reply
    ans, unans = sr(discover, timeout=5, filter="udp and src port 67", iface=iface)
    if ans:
        for sent, received in ans:
            if DHCP in received and received[DHCP].options[0].value == 2:  # OFFER
                return received
    return None

The sr() function sends the packet and captures responses for up to 5 seconds. We filter for UDP packets coming from port 67. The first DHCP option (message-type) should equal 2 for an OFFER.

Parsing the DHCP Offer

Once we have the offer packet, we extract the offered IP (yiaddr), server identifier (option 54), subnet mask (option 1), router (option 3), DNS (option 6), and lease time (option 51). Scapy's DHCP layer parses options automatically, but we can also iterate manually.


def parse_offer(offer_pkt):
    dhcp_layer = offer_pkt[DHCP]
    offered_ip = dhcp_layer.yiaddr
    server_id = None
    subnet_mask = None
    router = None
    dns_servers = []
    lease_time = None
    
    # Iterate over options
    for option in dhcp_layer.options:
        if option.name == "server_id":
            server_id = option.value
        elif option.name == "subnet_mask":
            subnet_mask = option.value
        elif option.name == "router":
            router = option.value
        elif option.name == "name_server":
            dns_servers = option.value
        elif option.name == "lease_time":
            lease_time = option.value
        # Also handle end option
        if option.name == "end":
            break
    
    return {
        "offered_ip": offered_ip,
        "server_id": server_id,
        "subnet_mask": subnet_mask,
        "router": router,
        "dns_servers": dns_servers,
        "lease_time": lease_time
    }

Sending DHCP Request and Receiving ACK

To accept the offered IP, the client sends a DHCPREQUEST broadcast. This message must include option 54 (Server Identifier) to select the specific server, option 50 (Requested IP Address) to confirm the chosen IP, and the message-type option set to 3 (REQUEST). The server responds with a DHCPACK (or NAK).


def create_request(offer_info, mac_address, transaction_id):
    req = DHCP(
        op=1,
        htype=1,
        hlen=6,
        hops=0,
        xid=transaction_id,
        secs=0,
        flags=0x8000,
        chaddr=mac_to_bytes(mac_address),
        options=[
            DHCPOptions("message-type", "request"),
            DHCPOptions("server_id", offer_info["server_id"]),
            DHCPOptions("requested_addr", offer_info["offered_ip"]),
            DHCPOptions("param_req_list", [1, 3, 6, 51]),
            DHCPOptions("end")
        ]
    )
    pkt = IP(src="0.0.0.0", dst="255.255.255.255") / UDP(sport=68, dport=67) / req
    return pkt

def send_request_and_receive_ack(request_pkt, iface="eth0"):
    ans, unans = sr(request_pkt, timeout=5, filter="udp and src port 67", iface=iface)
    if ans:
        for sent, received in ans:
            if DHCP in received:
                msg_type = received[DHCP].options[0].value
                if msg_type == 5:  # ACK
                    return received
                elif msg_type == 6:  # NAK
                    print("Received NAK - address refused")
                    return None
    return None

Complete Client Script

Combining everything, here is a runnable DHCP client that obtains an IP lease:


#!/usr/bin/env python3
import random, sys
from scapy.all import *

def mac_to_bytes(mac_str):
    return bytes(int(b, 16) for b in mac_str.split(':'))

def create_discover(mac, xid):
    dhcp_discover = DHCP(
        op=1, htype=1, hlen=6, hops=0,
        xid=xid, secs=0, flags=0x8000,
        chaddr=mac_to_bytes(mac),
        options=[
            DHCPOptions("message-type", "discover"),
            DHCPOptions("param_req_list", [1, 3, 6, 51]),
            DHCPOptions("end")
        ]
    )
    return IP(src="0.0.0.0", dst="255.255.255.255") / UDP(sport=68, dport=67) / dhcp_discover

def create_request(offer_ip, server_id, mac, xid):
    req = DHCP(
        op=1, htype=1, hlen=6, hops=0,
        xid=xid, secs=0, flags=0x8000,
        chaddr=mac_to_bytes(mac),
        options=[
            DHCPOptions("message-type", "request"),
            DHCPOptions("server_id", server_id),
            DHCPOptions("requested_addr", offer_ip),
            DHCPOptions("param_req_list", [1, 3, 6, 51]),
            DHCPOptions("end")
        ]
    )
    return IP(src="0.0.0.0", dst="255.255.255.255") / UDP(sport=68, dport=67) / req

def parse_offer(pkt):
    dhcp = pkt[DHCP]
    info = {"offered_ip": dhcp.yiaddr, "server_id": None, "subnet_mask": None, "router": None, "dns": [], "lease_time": None}
    for opt in dhcp.options:
        if opt.name == "server_id":
            info["server_id"] = opt.value
        elif opt.name == "subnet_mask":
            info["subnet_mask"] = opt.value
        elif opt.name == "router":
            info["router"] = opt.value
        elif opt.name == "name_server":
            info["dns"] = opt.value
        elif opt.name == "lease_time":
            info["lease_time"] = opt.value
        if opt.name == "end":
            break
    return info

if __name__ == "__main__":
    iface = sys.argv[1] if len(sys.argv) > 1 else conf.iface
    mac = get_if_hwaddr(iface)
    xid = random.randint(0, 0xFFFFFFFF)
    
    print("Sending DHCPDISCOVER...")
    disc_pkt = create_discover(mac, xid)
    ans, _ = sr(disc_pkt, timeout=10, filter="udp and src port 67", iface=iface)
    
    offer_pkt = None
    for s, r in ans:
        if DHCP in r and r[DHCP].options[0].value == 2:
            offer_pkt = r
            break
    if not offer_pkt:
        print("No OFFER received"); sys.exit(1)
    
    info = parse_offer(offer_pkt)
    print(f"Offered IP: {info['offered_ip']}, Server: {info['server_id']}")
    
    print("Sending DHCPREQUEST...")
    req_pkt = create_request(info["offered_ip"], info["server_id"], mac, xid)
    ans, _ = sr(req_pkt, timeout=10, filter="udp and src port 67", iface=iface)
    
    ack_pkt = None
    for s, r in ans:
        if DHCP in r:
            mtype = r[DHCP].options[0].value
            if mtype == 5:
                ack_pkt = r
            elif mtype == 6:
                print("DHCPNAK received"); sys.exit(1)
    if not ack_pkt:
        print("No ACK received"); sys.exit(1)
    
    final = parse_offer(ack_pkt)  # ACK carries same options
    print(f"Lease obtained: IP={final['offered_ip']}, Subnet={final['subnet_mask']}, Router={final['router']}, DNS={final['dns']}, Lease={final['lease_time']}s")

Implementing a DHCP Server

Building a DHCP server requires listening on port 67, parsing incoming DISCOVER and REQUEST packets, managing an address pool, and crafting appropriate replies. We'll create a simple server in Python that assigns addresses from a predefined range and logs leases in memory. The server will handle the basic DISCOVER-OFFER-REQUEST-ACK flow.

Listening for Discover

The server opens a raw UDP socket bound to 0.0.0.0:67, allowing it to receive broadcast traffic. Incoming packets are parsed with Scapy's DHCP() dissector. We'll filter for DHCPDISCOVER (message-type 1).


from scapy.all import *
import socket, struct, random, threading

class DHCPServer:
    def __init__(self, pool_start="192.168.1.100", pool_end="192.168.1.200", subnet="255.255.255.0",
                 gateway="192.168.1.1", dns="8.8.8.8", lease_time=86400):
        self.pool_start = list(map(int, pool_start.split('.')))
        self.pool_end = list(map(int, pool_end.split('.')))
        self.subnet = subnet
        self.gateway = gateway
        self.dns = dns
        self.lease_time = lease_time
        self.leases = {}  # mac -> assigned IP
        self.next_ip = self.pool_start[:]  # simple sequential allocation
    
    def allocate_ip(self, mac):
        if mac in self.leases:
            return self.leases[mac]
        # Find next available
        ip = self.next_ip.copy()
        # Increment for next allocation
        self.next_ip[3] += 1
        if self.next_ip[3] > self.pool_end[3]:
            self.next_ip[3] = self.pool_start[3]
        self.leases[mac] = ".".join(map(str, ip))
        return self.leases[mac]
    
    def start(self):
        # Bind raw socket on UDP 67
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind(("0.0.0.0", 67))
        print("DHCP Server listening on port 67...")
        
        while True:
            data, addr = sock.recvfrom(1024)
            # Parse with Scapy
            pkt = IP(data)
            if DHCP not in pkt:
                continue
            dhcp_layer = pkt[DHCP]
            # Extract message type
            msg_type = None
            for opt in dhcp_layer.options:
                if opt.name == "message-type":
                    msg_type = opt.value
                    break
            if msg_type is None:
                continue
            
            client_mac = ":".join(f"{b:02x}" for b in dhcp_layer.chaddr[:dhcp_layer.hlen])
            xid = dhcp_layer.xid
            
            if msg_type == 1:  # DISCOVER
                print(f"DISCOVER from {client_mac}")
                self.send_offer(sock, client_mac, xid, addr)
            elif msg_type == 3:  # REQUEST
                print(f"REQUEST from {client_mac}")
                self.send_ack(sock, client_mac, xid, addr)
            elif msg_type == 7:  # RELEASE
                if client_mac in self.leases:
                    del self.leases[client_mac]
                    print(f"RELEASE from {client_mac} - IP freed")

Building the Offer Packet

An OFFER is constructed with op=2 (BOOTREPLY), the assigned IP in yiaddr, and options for message type (2), server identifier (our IP), subnet mask, router, DNS, lease time, and the end option. The destination address is the broadcast address (or client's address if unicast) and UDP port 68.


def send_offer(self, sock, client_mac, xid, addr):
    offered_ip = self.allocate_ip(client_mac)
    server_ip = self.get_server_ip()  # e.g., gateway IP
    # Build DHCP OFFER
    dhcp_offer = DHCP(
        op=2, htype=1, hlen=6, hops=0,
        xid=xid, secs=0, flags=0x8000,
        ciaddr="0.0.0.0",
        yiaddr=offered_ip,
        siaddr=server_ip,
        giaddr="0.0.0.0",
        chaddr=bytes.fromhex(client_mac.replace(':', '')),
        options=[
            DHCPOptions("message-type", "offer"),
            DHCPOptions("server_id", server_ip),
            DHCPOptions("lease_time", self.lease_time),
            DHCPOptions("subnet_mask", self.subnet),
            DHCPOptions("router", self.gateway),
            DHCPOptions("name_server", self.dns),
            DHCPOptions("end")
        ]
    )
    pkt = IP(src=server_ip, dst="255.255.255.255") / UDP(sport=67, dport=68) / dhcp_offer
    sock.sendto(bytes(pkt), ("255.255.255.255", 68))
    print(f"OFFER sent: IP={offered_ip} to {client_mac}")

Handling Request and Sending ACK

When a REQUEST arrives, we verify that the requested IP matches the one we offered (by checking requested_addr option). If valid, we send an ACK with the same options. If invalid or the IP is unavailable, we send a NAK. After ACK, we confirm the lease.


def send_ack(self, sock, client_mac, xid, addr):
    # Parse the REQUEST to extract requested IP
    pkt = IP(data)  # data from recvfrom
    dhcp_req = pkt[DHCP]
    requested_ip = None
    server_id = None
    for opt in dhcp_req.options:
        if opt.name == "requested_addr":
            requested_ip = opt.value
        elif opt.name == "server_id":
            server_id = opt.value
    if not requested_ip or not server_id:
        return
    
    assigned_ip = self.leases.get(client_mac)
    if assigned_ip != requested_ip:
        # Send NAK
        dhcp_nak = DHCP(
            op=2, htype=1, hlen=6, hops=0,
            xid=xid, secs=0, flags=0x8000,
            ciaddr="0.0.0.0", yiaddr="0.0.0.0", siaddr="0.0.0.0",
            giaddr="0.0.0.0", chaddr=bytes.fromhex(client_mac.replace(':', '')),
            options=[
                DHCPOptions("message-type", "nak"),
                DHCPOptions("server_id", server_id),
                DHCPOptions("end")
            ]
        )
        nak_pkt = IP(src=self.get_server_ip(), dst="255.255.255.255") / UDP(sport=67, dport=68) / dhcp_nak
        sock.sendto(bytes(nak_pkt), ("255.255.255.255", 68))
        print(f"NAK sent to {client_mac}")
        return
    
    # Build ACK
    dhcp_ack = DHCP(
        op=2, htype=1, hlen=6, hops=0,
        xid=xid, secs=0, flags=0x8000,
        ciaddr="0.0.0.0",
        yiaddr=requested_ip,
        siaddr=self.get_server_ip(),
        giaddr="0.0.0.0",
        chaddr=bytes.fromhex(client_mac.replace(':', '')),
        options=[
            DHCPOptions("message-type", "ack"),
            DHCPOptions("server_id", server_id),
            DHCPOptions("lease_time", self.lease_time),
            DHCPOptions("subnet_mask", self.subnet),
            DHCPOptions("router", self.gateway),
            DHCPOptions("name_server", self.dns),
            DHCPOptions("end")
        ]
    )
    ack_pkt = IP(src=self.get_server_ip(), dst="255.255.255.255") / UDP(sport=67, dport=68) / dhcp_ack
    sock.sendto(bytes(ack_pkt), ("255.255.255.255", 68))
    print(f"ACK sent: IP={requested_ip} to {client_mac}")

Complete Server Script

Here's the full server implementation with a runnable main entry point. It uses the gateway IP as the server identifier. Remember to run as root.


#!/usr/bin/env python3
import socket, struct, threading
from scapy.all import *

class DHCPServer:
    def __init__(self, pool_start="192.168.1.100", pool_end="192.168.1.200",
                 subnet="255.255.255.0", gateway="192.168.1.1",
                 dns="8.8.8.8", lease_time=86400):
        self.pool_start = list(map(int, pool_start.split('.')))
        self.pool_end = list(map(int, pool_end.split('.')))
        self.subnet = subnet
        self.gateway = gateway
        self.dns = dns
        self.lease_time = lease_time
        self.leases = {}
        self.next_ip = self.pool_start[:]
        self.server_ip = gateway  # Use gateway as server IP for simplicity
    
    def allocate_ip(self, mac):
        if mac in self.leases:
            return self.leases[mac]
        ip = self.next_ip.copy()
        self.next_ip[3] += 1
        if self.next_ip[3] > self.pool_end[3]:
            self.next_ip[3] = self.pool_start[3]
        ip_str = ".".join(map(str, ip))
        self.leases[mac] = ip_str
        return ip_str
    
    def get_server_ip(self):
        return self.server_ip
    
    def start(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind(("0.0.0.0", 67))
        print("DHCP Server running on port 67...")
        
        while True:
            data, addr = sock.recvfrom(2048)
            pkt = IP(data)
            if DHCP not in pkt:
                continue
            dhcp_layer = pkt[DHCP]
            msg_type = None
            for opt in dhcp_layer.options:
                if opt.name == "message-type":
                    msg_type = opt.value
                    break
            if msg_type is None:
                continue
            client_mac = ":".join(f"{b:02x}" for b in dhcp_layer.chaddr[:dhcp_layer.hlen])
            xid = dhcp_layer.xid
            
            if msg_type == 1:  # DISCOVER
                self.handle_discover(sock, client_mac, xid)
            elif msg_type == 3:  # REQUEST
                self.handle_request(sock, client_mac, xid, data)
            elif msg_type == 7:  # RELEASE
                if client_mac in self.leases:
                    del self.leases[client_mac]
                    print(f"Released IP from {client_mac}")
    
    def handle_discover(self, sock, client_mac, xid):
        offered_ip = self.allocate_ip(client_mac)
        dhcp_offer = DHCP(
            op=2, htype=1, hlen=6, hops=0,
            xid=xid, secs=0, flags=0x8000,
            ciaddr="0.0.0.0", yiaddr=offered_ip,
            siaddr=self.get_server_ip(), giaddr="0.0.0.0",
            chaddr=bytes.fromhex(client_mac.replace(':', '')),
            options=[
                DHCPOptions("message-type", "offer"),
                DHCPOptions("server_id", self.get_server_ip()),
                DHCPOptions("lease_time", self.lease_time),
                DHCPOptions("subnet_mask", self.subnet),
                DHCPOptions("router", self.gateway),
                DHCPOptions("name_server", self.dns),
                DHCPOptions("end")
            ]
        )
        pkt = IP(src=self.get_server_ip(), dst="255.255.255.255") / UDP(sport=67, dport=68) / dhcp_offer
        sock.sendto(bytes(pkt), ("255.255.255.255", 68))
        print(f"OFFER {offered_ip} to {client_mac}")
    
    def handle_request(self, sock, client_mac, xid, data):
        pkt = IP(data)
        dhcp_req = pkt[DHCP]
        requested_ip = None
        server_id = None
        for opt in dhcp_req.options:
            if opt.name == "requested_addr":
                requested_ip = opt.value
            elif opt.name == "server_id":
                server_id = opt.value
        if not requested_ip or not server_id:
            return
        
        assigned_ip = self.leases.get(client_mac)
        if assigned_ip != requested_ip:
            # Send NAK
            nak = DHCP(
                op=2, htype=1, hlen=6, hops=0,
                xid=xid, secs=0, flags=0x8000,
                ciaddr="0.0.0.0", yiaddr="0.0.0.0",
                siaddr="0.0.0.0", giaddr="0.0.0.0",
                chaddr=bytes.fromhex(client_mac.replace(':', '')),
                options=[
                    DHCPOptions("message-type", "nak"),
                    DHCPOptions("server_id", server_id),
                    DHCPOptions("end")
                ]
            )
            nak_pkt = IP(src=self.get_server_ip(), dst="255.255.255.255

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles