> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# DHCP DORA Explained with a Real Packet Capture
- URL: https://www.pinglabz.com/dhcp-dora-packet-capture/
- Published: 2026-08-29T22:55:40.000Z
- Updated: 2026-08-29T23:05:13.000Z
- Author: Jaime
- Tags: DHCP, Packet Analysis, IP Services, Cisco, Troubleshooting

Discover, Offer, Request, Ack. You can recite DORA in your sleep, and it still will not help you the day a client comes up with a 169.254 address and the server insists the pool is fine. What helps is knowing which of the four packets actually appeared, which fields they carried, and where in the path they stopped.

This walkthrough takes DORA off the wire twice: once from a real Linux client leasing an address directly from a Cisco IOS XE DHCP server, and once through a relay agent, where the four packets look completely different. Every byte below came from that lab. If you want the general picture of where to tap and how to filter first, the [packet capture and analysis guide](https://www.pinglabz.com/packet-analysis/) sets that up.

## The lab

An `iol-xe` router running IOS XE 17.18.2 as the DHCP server, a Layer 2 switch, and a real Debian 13 host bridged onto the same VLAN through a CML external connector. A second segment behind another router provides the relay case.

```
ip dhcp excluded-address 10.66.0.1 10.66.0.149
ip dhcp excluded-address 10.66.0.201 10.66.0.254
ip dhcp excluded-address 10.66.20.1 10.66.20.99
!
ip dhcp pool EDGE-POOL
 network 10.66.0.0 255.255.255.0
 default-router 10.66.0.1
 dns-server 10.66.0.1 8.8.8.8
 domain-name pinglabz.lab
 lease 0 2
!
ip dhcp pool BRANCH-POOL
 network 10.66.20.0 255.255.255.0
 default-router 10.66.20.1
 dns-server 10.66.0.1
 domain-name pinglabz.lab
 lease 0 1
```

Two hours of lease on the edge, one on the branch, and the exclusions leave a usable window of .150 to .200\. Worth noting the pool statement uses the whole subnet and the exclusions carve it down, which is why the CLI later reports 254 total addresses rather than 51.

## The client's view

```
j@llmbits:~$ sudo dhclient -v -1 ens224
Listening on LPF/ens224/00:0c:29:b1:cc:47
Sending on   LPF/ens224/00:0c:29:b1:cc:47
Sending on   Socket/fallback
DHCPDISCOVER on ens224 to 255.255.255.255 port 67 interval 8
DHCPOFFER of 10.66.0.150 from 10.66.0.1
DHCPREQUEST for 10.66.0.150 on ens224 to 255.255.255.255 port 67
DHCPACK of 10.66.0.150 from 10.66.0.1
bound to 10.66.0.150 -- renewal in 2767 seconds.
```

Four lines, four packets, and a renewal timer of 2767 seconds rather than the 3600 you would expect from a two hour lease at T1\. The client applies a small random jitter so that a room full of machines that booted together does not renew in lockstep. That randomization is in the client, not the server, and it is why two identical hosts with identical leases renew at slightly different times.

## The four packets, decoded

### 1\. Discover

```
2026-08-29 14:20:35.721239 IP (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
    0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from 00:0c:29:b1:cc:47, length 300, xid 0xe57c086d, Flags [none]
	  Client-Ethernet-Address 00:0c:29:b1:cc:47
	  Vendor-rfc1048 Extensions
	    Magic Cookie 0x63825363
	    DHCP-Message (53), length 1: Discover
	    Hostname (12), length 7: "llmbits"
	    Parameter-Request (55), length 13:
	      Subnet-Mask (1), BR (28), Time-Zone (2), Default-Gateway (3)
	      Domain-Name (15), Domain-Name-Server (6), Unknown (119), Hostname (12)
	      Netbios-Name-Server (44), Netbios-Scope (47), MTU (26), Classless-Static-Route (121)
	      NTP (42)
```

Source 0.0.0.0, destination 255.255.255.255, UDP 68 to 67\. The client has no address and no idea who the server is, so it can only shout. Two fields here do the real work.

**The transaction ID, 0xe57c086d.** All four packets carry it. It is how the client matches an Offer to its own Discover in a room where several clients are broadcasting at once, and it is the first thing to check when a capture looks like a conversation but is actually two conversations interleaved.

**Option 55, the Parameter Request List.** Thirteen options the client is asking for, in preference order. This is the answer to "why is my DHCP server handing out an NTP server that the client ignores": if the option is not in this list, most servers will not send it, and a client that never asks will never receive. Option 121, classless static routes, is in there, which is the modern way to push routes to a client.

### 2\. Offer

```
2026-08-29 14:20:37.725875 IP (tos 0x0, ttl 255, id 13, offset 0, flags [none], proto UDP (17), length 332)
    10.66.0.1.67 > 10.66.0.150.68: BOOTP/DHCP, Reply, length 304, xid 0xe57c086d, Flags [none]
	  Your-IP 10.66.0.150
	  Client-Ethernet-Address 00:0c:29:b1:cc:47
	  Vendor-rfc1048 Extensions
	    Magic Cookie 0x63825363
	    DHCP-Message (53), length 1: Offer
	    Server-ID (54), length 4: 10.66.0.1
	    Lease-Time (51), length 4: 7200
	    RN (58), length 4: 3600
	    RB (59), length 4: 6300
	    Subnet-Mask (1), length 4: 255.255.255.0
	    Default-Gateway (3), length 4: 10.66.0.1
	    Domain-Name-Server (6), length 8: 10.66.0.1,8.8.8.8
	    Domain-Name (15), length 12: "pinglabz.lab"
```

Look at the destination: **10.66.0.150, not 255.255.255.255**. The server unicast the Offer to an address the client does not have configured yet. That works because the broadcast flag in the BOOTP header is clear (`Flags [none]`), which is the client telling the server "I can accept a unicast reply even though my stack is not configured". Linux clients set it that way; plenty of embedded stacks and PXE ROMs do not, and set the flag so the Offer comes back as a broadcast. If you are ever debugging a client that sees the Discover leave and never sees an Offer, this flag and the switch's handling of unicast-to-unknown-MAC is the place to look.

The three timers matter too. Lease 7200, RN (renewal, T1) 3600, RB (rebinding, T2) 6300\. That is the standard 50% and 87.5% split of the lease, and IOS calculates it for you from the single `lease 0 2` line.

### 3\. Request

```
2026-08-29 14:20:37.726971 IP (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
    0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from 00:0c:29:b1:cc:47, length 300, xid 0xe57c086d, Flags [none]
	  Vendor-rfc1048 Extensions
	    DHCP-Message (53), length 1: Request
	    Server-ID (54), length 4: 10.66.0.1
	    Requested-IP (50), length 4: 10.66.0.150
	    Hostname (12), length 7: "llmbits"
```

Still broadcast, still from 0.0.0.0, one millisecond after the Offer. The client has been offered an address and has not accepted it yet, so it cannot use it as a source.

The Request is broadcast on purpose. Options 54 and 50 together say "I am taking 10.66.0.150 from the server at 10.66.0.1", and every other DHCP server on the segment hears that and withdraws its own offer. This is the mechanism that makes multiple DHCP servers on one VLAN work at all, and it is also why a rogue server is so effective: it hears the same broadcast and knows exactly which client to race next time.

### 4\. Ack

```
2026-08-29 14:20:37.730315 IP (tos 0x0, ttl 255, id 14, offset 0, flags [none], proto UDP (17), length 332)
    10.66.0.1.67 > 10.66.0.150.68: BOOTP/DHCP, Reply, length 304, xid 0xe57c086d, Flags [none]
	  Your-IP 10.66.0.150
	  Vendor-rfc1048 Extensions
	    DHCP-Message (53), length 1: ACK
	    Server-ID (54), length 4: 10.66.0.1
	    Lease-Time (51), length 4: 7200
	    RN (58), length 4: 3600
	    RB (59), length 4: 6300
	    Subnet-Mask (1), length 4: 255.255.255.0
	    Default-Gateway (3), length 4: 10.66.0.1
	    Domain-Name-Server (6), length 8: 10.66.0.1,8.8.8.8
	    Domain-Name (15), length 12: "pinglabz.lab"
```

Byte for byte the same option set as the Offer, with option 53 changed from Offer to ACK. That is not laziness in the implementation: the Offer is a proposal and the Ack is the binding commitment, so the client is required to use the values from the Ack. If a server hands out different options in the two, the Ack wins.

Timestamps are worth reading here. The Offer, Request and Ack all land within 4.4 milliseconds of each other, while the gap between the Discover and the Offer is a full 2.005 seconds. That gap is the IOS server pinging the candidate address before it offers it, which is conflict detection working as designed, and it accounts for essentially the whole 2.009 seconds the lease took.

## The Discover, byte for byte

DHCP is BOOTP with options bolted on, and the hex makes that history obvious.

```
j@llmbits:~$ sudo tcpdump -r /tmp/dora.pcap -nn -c 1 -XX
14:20:35.721239 IP 0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from 00:0c:29:b1:cc:47, length 300
	0x0000:  ffff ffff ffff 000c 29b1 cc47 0800 4510  ........)..G..E.
	0x0010:  0148 0000 0000 8011 3996 0000 0000 ffff  .H......9.......
	0x0020:  ffff 0044 0043 0134 a880 0101 0600 e57c  ...D.C.4.......|
	0x0030:  086d 0000 0000 0000 0000 0000 0000 0000  .m..............
	0x0040:  0000 0000 0000 000c 29b1 cc47 0000 0000  ........)..G....
	...
	0x0110:  0000 0000 0000 6382 5363 3501 010c 076c  ......c.Sc5....l
	0x0120:  6c6d 6269 7473 370d 011c 0203 0f06 770c  lmbits7.......w.
	0x0130:  2c2f 1a79 2aff 0000 0000 0000 0000 0000  ,/.y*...........
```

0x00

ffff ffff ffff

Layer 2 broadcast. The client cannot ARP for a server it has not met.

0x22, 0x24

0044, 0043

UDP source port 68, destination 67\. Reversed on the replies.

0x2a

01 01 06 00

op=1 (BOOTREQUEST), htype=1 (Ethernet), hlen=6, hops=0\. The hops byte is the one a relay agent increments.

0x2e

e57c 086d

The transaction ID, echoed in all four packets.

0x34

0000

The flags field. The top bit is the broadcast flag, clear here, which is why the Offer came back unicast.

0x36 to 0x45

four zero addresses

ciaddr, yiaddr, siaddr, giaddr. All 0.0.0.0 in a directly attached Discover. giaddr is the interesting one below.

0x46

000c 29b1 cc47

chaddr, the client hardware address, in a 16-byte field padded with zeros.

0x116

6382 5363

The magic cookie 99.130.83.99\. Everything before it is BOOTP; everything after it is DHCP options.

The options themselves are trivial to read once you know the type-length-value pattern. `3501 01` is option 53, length 1, value 1: Discover. `0c07 6c6c6d62697473` is option 12, length 7, "llmbits". `370d 011c0203...` is option 55, length 13, followed by the thirteen requested option numbers. `ff` ends the list, and the rest is padding.

Between chaddr at 0x46 and the cookie at 0x116 sit 192 bytes of nothing: the 64-byte `sname` and 128-byte `file` fields, both zero. They exist because BOOTP used them to name a boot server and a boot image. In modern DHCP they are usually empty, which is why a minimum DHCP packet is a stubborn 300 bytes of mostly zeros.

## The relay case looks nothing like this

Now the same exchange for a client on a different subnet, behind a router with `ip helper-address 10.66.0.1`. The client side is unremarkable:

```
H1:/# udhcpc -i eth0 -n -q -f
udhcpc: started, v1.36.1
udhcpc: broadcasting discover
udhcpc: broadcasting select for 10.66.20.101, server 10.66.0.1
udhcpc: lease of 10.66.20.101 obtained from 10.66.0.1, lease time 3600
```

The server side is where it gets interesting. This is a capture taken on the link between the switch and the DHCP server, filtered on `udp port 67`:

```
No  Time       Source        Destination   Len  Info
1   0.000000   10.66.20.1    10.66.0.1     342  DHCP Discover - Transaction ID 0xf0e73670
2   2.003150   10.66.0.1     10.66.20.1    351  DHCP Offer    - Transaction ID 0xf0e73670
3   2.022609   10.66.20.1    10.66.0.1     342  DHCP Request  - Transaction ID 0xf0e73670
4   2.023833   10.66.0.1     10.66.20.1    351  DHCP ACK      - Transaction ID 0xf0e73670
```

Every one of those is **unicast, routed, between two router interfaces**. There is not a single broadcast in the exchange. The relay agent received the client's broadcast on Ethernet0/1, rewrote it as a unicast to the helper address, and stamped its own interface address 10.66.20.1 into the giaddr field. The server used that giaddr for two things: to pick BRANCH-POOL rather than EDGE-POOL, and to know where to send the reply. The relay then converted it back to a broadcast on the client segment.

This is the single most useful thing to internalize about DHCP relay. The pool is chosen by giaddr, not by anything the client says. If a branch client is getting an address from the wrong subnet, the giaddr is wrong, which usually means the helper is configured on the wrong interface or on a secondary address. And if you are capturing on the client segment and see no Offer, capturing on the server segment tells you instantly whether the relay ever sent anything. That is the sequence walked through end to end in [the DHCP relay and ip helper-address lab](https://www.pinglabz.com/ccna-lab-ips-02-dhcp-relay-ip-helper-address/).

## Matching the capture to the server's state

```
R1#show ip dhcp binding
Bindings from all pools not associated with VRF:
IP address      Client-ID/              Lease expiration        Type       State      Interface
                Hardware address/
                User name
10.66.0.150     000c.29b1.cc47          Aug 29 2026 11:20 PM    Automatic  Active     Ethernet0/0
10.66.20.101    0152.5400.3b54.b0       Aug 29 2026 10:22 PM    Automatic  Active     Ethernet0/0
```

Two bindings, and they are identified differently. The Debian host appears as a bare hardware address, `000c.29b1.cc47`. The BusyBox client appears as `0152.5400.3b54.b0`, which is seven bytes: a leading type byte of 01 (Ethernet) followed by the six-byte MAC. That is DHCP option 61, the client identifier, and when a client sends one the server keys the binding on it rather than on chaddr.

This bites people constantly. Change the client identifier and the same physical machine gets a new address. Clone a VM and copy its DUID or client-ID and two machines fight over one lease. If a binding table entry does not match the MAC you expect, check whether you are looking at a client-ID rather than a hardware address.

Note also that both bindings show Ethernet0/0 as the interface, including the relayed one. The server records the interface it received the packet on, not the client's actual location. The client's real segment is only recoverable from the giaddr in the packet or from the address itself.

```
R1#show ip dhcp pool

Pool EDGE-POOL :
 Utilization mark (high/low)    : 100 / 0
 Subnet size (first/next)       : 0 / 0
 Total addresses                : 254
 Leased addresses               : 1
 Excluded addresses             : 203
 Pending event                  : none
 1 subnet is currently in the pool :
 Current index        IP address range                    Leased/Excluded/Total
 10.66.0.151          10.66.0.1        - 10.66.0.254       1     / 203   / 254

Pool BRANCH-POOL :
 Utilization mark (high/low)    : 100 / 0
 Subnet size (first/next)       : 0 / 0
 Total addresses                : 254
 Leased addresses               : 1
 Excluded addresses             : 99
 Pending event                  : none
 1 subnet is currently in the pool :
 Current index        IP address range                    Leased/Excluded/Total
 10.66.20.102         10.66.20.1       - 10.66.20.254      1     / 99    / 254
```

Read "Total addresses 254" carefully. That is the size of the subnet, not the size of the usable pool. Subtract the 203 exclusions and EDGE-POOL can actually hand out 51 addresses. A monitoring system that alerts on utilization against the total will tell you a pool is 0.4% used right up to the moment it runs dry. [The IOS DHCP server in depth](https://www.pinglabz.com/ios-dhcp-server-in-depth/) goes further into pool sizing and conflict handling.

```
R1#show ip dhcp server statistics | include DHCPDISCOVER|DHCPOFFER|DHCPREQUEST|DHCPACK
DHCPDISCOVER         15
DHCPREQUEST          2
DHCPOFFER            15
DHCPACK              2
```

Fifteen Discovers and fifteen Offers against two Requests and two Acks. Only two clients actually completed, and the other thirteen Discovers came from earlier retries during the lab build. A large gap between Offers and Acks in production means clients are being offered addresses and walking away, which usually points at a second DHCP server winning the race or at Offers not reaching the client.

## Filters worth keeping

```
# both directions of DHCP, any client
sudo tcpdump -i ens224 -nn -v 'port 67 or port 68'

# write it out for Wireshark
sudo tcpdump -i ens224 -nn -s0 -w dora.pcap 'port 67 or port 68'

# one client only, by MAC
sudo tcpdump -i ens224 -nn -v 'port 67 and ether host 00:0c:29:b1:cc:47'
```

Capture `port 67 or port 68` rather than just 67\. On the relay path everything is 67 to 67, but on the client segment the replies land on 68, and filtering on one port only is the classic way to capture half a conversation and conclude the server never answered. In Wireshark, `bootp` (or `dhcp` in recent versions) is the display filter, and `dhcp.option.dhcp == 1` isolates Discovers. More `tcpdump` syntax in [tcpdump for network engineers](https://www.pinglabz.com/tcpdump-for-network-engineers/).

## What this tells you when DHCP breaks

Discover, no Offer

Capture at the server. If the Discover arrived there, the problem is the return path or the pool. If it did not, the problem is the relay or the VLAN.

Offer sent, client never sees it

Check the broadcast flag in the Discover. A unicast Offer to an unconfigured client depends on the switch forwarding it correctly.

Address from the wrong subnet

Read the giaddr in the relayed Discover. The server picks the pool from that field and nothing else.

Same host, new address every boot

Option 61\. The binding is keyed on the client identifier, not the MAC, and something is changing it.

Two Offers for one Discover

A second DHCP server. The Server-ID in the client's Request tells you which one won. [DHCP snooping](https://www.pinglabz.com/dhcp-snooping-in-depth/) is the fix.

Option requested but never delivered

Compare option 55 in the Discover against the options in the Ack. Servers generally will not send what the client did not ask for.

## Key takeaways

- All four DORA packets share one transaction ID. It is the first field to check when a capture contains more than one client.
- The Offer is not necessarily broadcast. The broadcast flag in the client's Discover decides, and Linux clients usually clear it.
- The Request is broadcast on purpose so that losing servers withdraw their offers. Options 50 and 54 together are the announcement.
- Everything before the magic cookie 0x63825363 is BOOTP. That is why a minimal DHCP packet carries 192 bytes of empty sname and file fields.
- Through a relay there are no broadcasts at all on the server side, and giaddr alone chooses the pool.
- Bindings are keyed on option 61 when the client sends one, which is why the binding table sometimes shows seven bytes instead of six.
- "Total addresses" in `show ip dhcp pool` is subnet size, not pool size. Read it alongside the exclusion count.

This teardown is part of the PingLabz packet capture cluster. The [packet capture and analysis guide](https://www.pinglabz.com/packet-analysis/) covers tap points, filters and what to do with a pcap once you have one, and [DHCP and DNS on Cisco](https://www.pinglabz.com/dhcp-and-dns-cisco/) puts the protocol back in the context of a working services design.