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:
- Network Automation: Automate IP assignment in private test environments or container orchestration.
- Security Analysis: Understand rogue DHCP servers, starvation attacks, and mitigation techniques.
- Embedded Systems: Implement lightweight DHCP clients in firmware without relying on OS daemons.
- Custom Services: Build captive portals, VPN gateways, or specialized lease management.
- Debugging: Diagnose why a device fails to obtain an address using packet-level insight.
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
- DHCPDISCOVER (1): Client broadcast to locate available servers.
- DHCPOFFER (2): Server response offering an IP address and configuration parameters.
- DHCPREQUEST (3): Client request for a specific offered IP (or renewal of existing lease).
- DHCPACK (5): Server confirmation, committing the lease.
- DHCPNAK (6): Server refusal (e.g., address already in use or invalid).
- DHCPRELEASE (7): Client voluntarily relinquishes an IP.
- DHCPINFORM (8): Client obtains additional configuration without IP assignment.
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:
op(1 byte): 1 = BOOTREQUEST (client to server), 2 = BOOTREPLY (server to client).htype(1 byte): Hardware address type, 1 for Ethernet.hlen(1 byte): Hardware address length, 6 for MAC.hops(1 byte): Set to 0 by client; incremented by relay agents.xid(4 bytes): Transaction ID, a random number chosen by client to match responses.secs(2 bytes): Seconds elapsed since client began request.flags(2 bytes): Bit 0 (most significant) is the broadcast flag; when set, server sends replies as broadcast.ciaddr(4 bytes): Client IP address (if known, e.g., during renewal).yiaddr(4 bytes): 'Your' (client) IP address, assigned by server in OFFER/ACK.siaddr(4 bytes): Server IP address (next server for booting, rarely used).giaddr(4 bytes): Gateway (relay agent) IP address.chaddr(16 bytes): Client hardware address (MAC), padded with zeros.sname(64 bytes): Server hostname (optional).file(128 bytes): Boot file name (optional).options(variable): Encoded as TLV (type, length, value) after the magic cookie.
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:
- Option Code (1 byte): Identifies the parameter.
- Length (1 byte): Number of bytes in the value field.
- Value (variable): Actual data.
Essential option codes include:
1β Subnet Mask (e.g., 255.255.255.0)3β Router (Default Gateway)6β Domain Name Server (DNS)12β Host Name51β Lease Time (in seconds)53β DHCP Message Type (mandatory for all DHCP messages)54β DHCP Server Identifier (IP address of the server)55β Parameter Request List (client asks for specific options)255β End option (marks end of options field)
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